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"]
|
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ..
|
# Use the rad/ directory as the build context and the Dockerfile inside it.
|
||||||
dockerfile: rad/Dockerfile
|
# 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: python manage.py runserver 0.0.0.0:8000
|
||||||
#command: pip install -r requirements.txt
|
#command: pip install -r requirements.txt
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/usr/src/app # mount the whole repo into the container
|
# Mount the rad app directory into the container WORKDIR used by the Dockerfile
|
||||||
- ./backups:/usr/src/backups
|
- ./rad:/usr/src/rad
|
||||||
- ./media:/usr/src/media
|
# Mount host media/static/backups into sensible container paths
|
||||||
- ./static:/usr/src/static
|
- ./backups:/usr/src/rad/backups
|
||||||
|
- ./media:/usr/src/rad/media
|
||||||
|
- ./static:/usr/src/rad/static
|
||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
- 3459:3459
|
- 3459:3459
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="w-100">
|
class="w-100">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="row g-2 align-items-start">
|
<div class="row g-2 align-items-start">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label mb-1">Status</label>
|
<label class="form-label mb-1">Status</label>
|
||||||
@@ -22,15 +21,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
<label for="{{ form.comment.id_for_label }}" class="form-label mb-1">Comment</label>
|
||||||
{{ form.comment }}
|
{{ form.comment }}
|
||||||
{% if form.comment.errors %}
|
{% if form.comment.errors %}
|
||||||
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
|
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-auto d-flex align-items-end">
|
<div class="col-auto d-flex align-items-end mt-2">
|
||||||
<button class="btn btn-primary" type="submit">Save</button>
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% 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 '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 '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>
|
<h2>
|
||||||
{% if grade %}
|
{% if grade %}
|
||||||
@@ -18,10 +19,13 @@
|
|||||||
{% endif %}Trainees
|
{% endif %}Trainees
|
||||||
</h2>
|
</h2>
|
||||||
<form>
|
<form>
|
||||||
<table>
|
<table id="trainees-table">
|
||||||
<tr class="header">
|
<tr class="header">
|
||||||
<th>Name</th>
|
<th class="sortable" data-col="0">Name <span class="sort-indicator"></span></th>
|
||||||
<th>Grade</th><th>Email</th><th>Supervisor</th><th>Edit</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/>
|
<th class="trainee-bulk-edit">Bulk Edit<br/>
|
||||||
<span _="on click log 'test' then log 'hello' then repeat for i in <input/>
|
<span _="on click log 'test' then log 'hello' then repeat for i in <input/>
|
||||||
-- set i.checked to not i.checked
|
-- set i.checked to not i.checked
|
||||||
@@ -143,6 +147,63 @@
|
|||||||
background-color: darkgrey;
|
background-color: darkgrey;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
th .sort-indicator {
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</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 %}
|
{% 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),
|
views.SupervisorAutocomplete.as_view(model=Supervisor),
|
||||||
name="supervisor-autocomplete",
|
name="supervisor-autocomplete",
|
||||||
),
|
),
|
||||||
|
path("user-search/", views.user_search, name="user_search"),
|
||||||
|
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||||
path(
|
path(
|
||||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
"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,
|
generic_views.question_reviews_list,
|
||||||
name="question_reviews",
|
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/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
|
||||||
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
|
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
|
||||||
path(
|
path(
|
||||||
|
|||||||
+361
-5
@@ -68,6 +68,7 @@ from .forms import (
|
|||||||
CidUserExamForm,
|
CidUserExamForm,
|
||||||
ExamCollectionCloneForm,
|
ExamCollectionCloneForm,
|
||||||
ExamCollectionForm,
|
ExamCollectionForm,
|
||||||
|
QuestionReviewForm,
|
||||||
ExaminationForm,
|
ExaminationForm,
|
||||||
CidUserGroupForm,
|
CidUserGroupForm,
|
||||||
ExaminationMergeForm,
|
ExaminationMergeForm,
|
||||||
@@ -96,8 +97,10 @@ from .models import (
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
UserUserGroup,
|
UserUserGroup,
|
||||||
get_next_cid,
|
get_next_cid,
|
||||||
|
QuestionReview,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from rapids.models import (
|
from rapids.models import (
|
||||||
Rapid as RapidQuestion,
|
Rapid as RapidQuestion,
|
||||||
ExamQuestionDetail as RapidsExamQuestionDetail,
|
ExamQuestionDetail as RapidsExamQuestionDetail,
|
||||||
@@ -2954,9 +2957,6 @@ class GenericViewBase:
|
|||||||
GET: renders the current review block or the form (if ?edit=1)
|
GET: renders the current review block or the form (if ?edit=1)
|
||||||
POST: creates a new QuestionReview and returns the rendered block
|
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")
|
logger.debug("question_set_review called")
|
||||||
|
|
||||||
@@ -3018,8 +3018,6 @@ class GenericViewBase:
|
|||||||
|
|
||||||
def question_reviews_list(self, request, pk):
|
def question_reviews_list(self, request, pk):
|
||||||
"""Return a partial listing all QuestionReview instances for a question."""
|
"""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)
|
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})
|
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))
|
@method_decorator(user_passes_test(lambda u: u.is_superuser))
|
||||||
def user_answer_delete_multiple(self, request):
|
def user_answer_delete_multiple(self, request):
|
||||||
print(request.POST["answer_ids"])
|
print(request.POST["answer_ids"])
|
||||||
@@ -4087,6 +4254,195 @@ def trainees(request, grade: None | str = None):
|
|||||||
return render(request, "generic/trainees.html", context)
|
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):
|
def create_trainee(request, context=None):
|
||||||
return create_user(request, context, trainee=True)
|
return create_user(request, context, trainee=True)
|
||||||
|
|
||||||
|
|||||||
+31
-2
@@ -108,6 +108,7 @@ if not DEBUG:
|
|||||||
'rad.middleware.coep.COEPMiddleware'
|
'rad.middleware.coep.COEPMiddleware'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
ROOT_URLCONF = "rad.urls"
|
ROOT_URLCONF = "rad.urls"
|
||||||
|
|
||||||
#default_loaders = [
|
#default_loaders = [
|
||||||
@@ -117,6 +118,8 @@ ROOT_URLCONF = "rad.urls"
|
|||||||
#
|
#
|
||||||
#cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
|
#cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
# Development: do not use cached loader so templates reload on each request
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
@@ -130,9 +133,34 @@ TEMPLATES = [
|
|||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"django.template.context_processors.static",
|
"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_BROKER_URL = "redis://redis:6379"
|
||||||
CELERY_RESULT_BACKEND = "redis://redis:6379"
|
CELERY_RESULT_BACKEND = "redis://redis:6379"
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .settings_local import *
|
from .settings_local import *
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"accounts/trainees/<str:grade>/", generic_views.trainees, name="trainees_grade"
|
"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(
|
path(
|
||||||
"accounts/update/<str:slug>/",
|
"accounts/update/<str:slug>/",
|
||||||
views.UpdateUserView.as_view(),
|
views.UpdateUserView.as_view(),
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class QuestionForm(ModelForm):
|
|||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
|
"title",
|
||||||
"stem",
|
"stem",
|
||||||
"feedback",
|
"feedback",
|
||||||
"category",
|
"category",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu" aria-labelledby="sbasQuestionsDropdown">
|
<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_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 -->
|
<!-- Additional items can be added here -->
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -12,6 +12,21 @@
|
|||||||
{% include "exam_clock.html" %}
|
{% include "exam_clock.html" %}
|
||||||
<h2>Exam: {{exam}}</h2>
|
<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 %}
|
{% if exam.publish_results %}
|
||||||
<div class="alert alert-primary review-mode-alert" role="alert">
|
<div class="alert alert-primary review-mode-alert" role="alert">
|
||||||
Exam is in review mode. Score are available
|
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,
|
views.import_llm_confirm,
|
||||||
name="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(
|
#path(
|
||||||
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
||||||
# views.exam_scores_cid_user,
|
# views.exam_scores_cid_user,
|
||||||
|
|||||||
+187
@@ -29,6 +29,10 @@ from django.http import Http404, HttpResponseBadRequest, JsonResponse
|
|||||||
from django.http import HttpResponseRedirect, HttpResponse
|
from django.http import HttpResponseRedirect, HttpResponse
|
||||||
|
|
||||||
from .models import Question, Category, Exam, UserAnswer
|
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.views.decorators.http import require_http_methods
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
import re
|
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)
|
exam.check_user_can_take(cid, passcode, request.user)
|
||||||
|
|
||||||
|
can_edit = exam.check_user_can_edit(request.user)
|
||||||
|
|
||||||
questions = exam.get_questions()
|
questions = exam.get_questions()
|
||||||
|
|
||||||
if cid is not None:
|
if cid is not None:
|
||||||
@@ -182,6 +188,7 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
|
|||||||
"exam_length": len(questions),
|
"exam_length": len(questions),
|
||||||
"passcode": passcode,
|
"passcode": passcode,
|
||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
|
"can_edit": can_edit,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,6 +383,133 @@ class QuestionClone(QuestionCreateBase):
|
|||||||
class UserAnswerView(LoginRequiredMixin, DetailView):
|
class UserAnswerView(LoginRequiredMixin, DetailView):
|
||||||
model = UserAnswer
|
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):
|
class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||||
model = UserAnswer
|
model = UserAnswer
|
||||||
@@ -878,6 +1012,59 @@ def import_llm_questions(request):
|
|||||||
return JsonResponse({"ok": True, "report": report})
|
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
|
@login_required
|
||||||
@user_passes_test(lambda u: u.is_superuser)
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
|
|||||||
Reference in New Issue
Block a user