1165 lines
46 KiB
Python
Executable File
1165 lines
46 KiB
Python
Executable File
from django.forms import (
|
|
Form,
|
|
ModelForm,
|
|
ModelMultipleChoiceField,
|
|
MultipleChoiceField,
|
|
ModelChoiceField,
|
|
ChoiceField,
|
|
CharField,
|
|
HiddenInput,
|
|
EmailField,
|
|
IntegerField,
|
|
DateField,
|
|
SplitDateTimeField,
|
|
SplitDateTimeWidget,
|
|
|
|
)
|
|
from django.forms import inlineformset_factory
|
|
from atlas.models import CaseCollection
|
|
from django.contrib.auth.models import User
|
|
|
|
from generic.models import (
|
|
CidUser,
|
|
CidUserGroup,
|
|
Examination,
|
|
ExamCollection,
|
|
QuestionNote,
|
|
Supervisor,
|
|
UserGrades,
|
|
UserProfile,
|
|
UserUserGroup,
|
|
QuestionReview
|
|
)
|
|
|
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
from django.forms.widgets import RadioSelect, Textarea
|
|
|
|
from rapids.models import Rapid
|
|
from anatomy.models import AnatomyQuestion
|
|
from longs.models import Long
|
|
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
|
|
from physics.models import UserAnswer as PhysicsUserAnswer, Question
|
|
from physics.models import Exam as PhysicsExam
|
|
from physics.models import Question as SbasQuestion
|
|
|
|
from anatomy.models import UserAnswer as AnatomyUserAnswer
|
|
from anatomy.models import Exam as AnatomyExam
|
|
from anatomy.models import AnatomyQuestion
|
|
from anatomy.models import Answer as AnatomyAnswer
|
|
|
|
from rapids.models import UserAnswer as RapidsUserAnswer
|
|
from rapids.models import Exam as RapidsExam
|
|
from rapids.models import Rapid
|
|
from rapids.models import Answer as RapidAnswer
|
|
|
|
from shorts.models import UserAnswer as ShortsUserAnswer
|
|
from shorts.models import Exam as ShortsExam
|
|
from shorts.models import Question as ShortsQuestion
|
|
|
|
from longs.models import UserAnswer as LongsUserAnswer
|
|
from longs.models import Exam as LongsExam
|
|
from longs.models import Long
|
|
|
|
from sbas.models import UserAnswer as SbasUserAnswer
|
|
from sbas.models import Exam as SbasExam
|
|
from sbas.models import Question as SbasQuestion
|
|
from django.core.exceptions import ValidationError
|
|
import datetime
|
|
from django.forms.utils import from_current_timezone, to_current_timezone
|
|
|
|
from crispy_forms.helper import FormHelper
|
|
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
|
|
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
|
|
|
from dal import autocomplete
|
|
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
|
from django.utils.safestring import mark_safe
|
|
from django.urls import reverse
|
|
from django.contrib.auth.models import User
|
|
|
|
|
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
|
def compress(self, data_list):
|
|
if data_list:
|
|
# Raise a validation error if time or date is empty
|
|
# (possible if SplitDateTimeField has required=False).
|
|
if data_list[0] in self.empty_values:
|
|
raise ValidationError(
|
|
self.error_messages["invalid_date"], code="invalid_date"
|
|
)
|
|
if data_list[1] in self.empty_values:
|
|
data_list[1] = datetime.time(0, 1)
|
|
# raise ValidationError(
|
|
# self.error_messages["invalid_time"], code="invalid_time"
|
|
# )
|
|
result = datetime.datetime.combine(*data_list)
|
|
return from_current_timezone(result)
|
|
return None
|
|
|
|
|
|
class SplitDateTimeFieldDefaultTimeEnd(SplitDateTimeField):
|
|
def compress(self, data_list):
|
|
if data_list:
|
|
# Raise a validation error if time or date is empty
|
|
# (possible if SplitDateTimeField has required=False).
|
|
if data_list[0] in self.empty_values:
|
|
raise ValidationError(
|
|
self.error_messages["invalid_date"], code="invalid_date"
|
|
)
|
|
if data_list[1] in self.empty_values:
|
|
data_list[1] = datetime.time(23, 59)
|
|
# raise ValidationError(
|
|
# self.error_messages["invalid_time"], code="invalid_time"
|
|
# )
|
|
result = datetime.datetime.combine(*data_list)
|
|
return from_current_timezone(result)
|
|
return None
|
|
|
|
|
|
class ExamFormMixin:
|
|
def __init__(self, *args, user, **kwargs) -> None:
|
|
super(ModelForm, self).__init__(*args, **kwargs)
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_id = "id-exam-form"
|
|
self.helper.form_class = "exam-form"
|
|
self.helper.form_method = "post"
|
|
self.helper.form_action = "submit"
|
|
|
|
self.helper.layout = Layout(
|
|
Fieldset(
|
|
"Exam Details",
|
|
"name",
|
|
"time_limit",
|
|
"open_access",
|
|
"authors_only",
|
|
"exam_mode",
|
|
"exam_open_access",
|
|
"include_history",
|
|
"active",
|
|
"publish_results",
|
|
"archive",
|
|
Accordion(
|
|
AccordionGroup("Date restrictions",
|
|
"restrict_to_dates",
|
|
"start_date",
|
|
"end_date",
|
|
active=False,
|
|
)
|
|
),
|
|
),
|
|
Div(
|
|
Submit("submit", "Submit"),
|
|
css_class="form-group",
|
|
),
|
|
)
|
|
|
|
# self.helper.add_input(Submit("submit", "Submit"))
|
|
|
|
# if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
|
|
# cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
|
|
# user_user_group_queryset = UserUserGroup.objects.filter(archive=False)
|
|
# else:
|
|
# cid_user_group_queryset = CidUserGroup.objects.none()
|
|
# user_user_group_queryset = UserUserGroup.objects.none()
|
|
|
|
# self.fields["cid_user_groups"] = ModelMultipleChoiceField(
|
|
# required=False,
|
|
# queryset=cid_user_group_queryset,
|
|
# )
|
|
# self.fields["user_user_groups"] = ModelMultipleChoiceField(
|
|
# required=False,
|
|
# queryset=user_user_group_queryset,
|
|
# )
|
|
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
|
widget=SplitDateTimeWidget(
|
|
date_attrs={"type": "date", "class": "datepicker"},
|
|
time_attrs={"type": "time", "class": "timepicker"},
|
|
date_format="%Y-%m-%d",
|
|
time_format="%H:%M",
|
|
),
|
|
required=False,
|
|
help_text="The date the exam is due to start (time is optional)",
|
|
)
|
|
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
|
widget=SplitDateTimeWidget(
|
|
date_attrs={"type": "date", "class": "datepicker"},
|
|
time_attrs={"type": "time", "class": "timepicker"},
|
|
date_format="%Y-%m-%d",
|
|
time_format="%H:%M",
|
|
),
|
|
required=False,
|
|
help_text="The date the exam is due to start (time is optional)",
|
|
)
|
|
|
|
class Meta:
|
|
fields = [
|
|
"name",
|
|
"time_limit",
|
|
"open_access",
|
|
"authors_only",
|
|
"exam_mode",
|
|
"exam_open_access",
|
|
"include_history",
|
|
"restrict_to_dates",
|
|
"start_date",
|
|
"end_date",
|
|
"active",
|
|
"publish_results",
|
|
"archive",
|
|
"results_supervisor_visible"
|
|
# "cid_user_groups",
|
|
# "user_user_groups",
|
|
# "author",
|
|
]
|
|
|
|
# widgets = {
|
|
# "start_date": TextInput(attrs={"type": "date"}),
|
|
# "end_date": TextInput(attrs={"type": "date"}),
|
|
# }
|
|
|
|
def save(self, commit=True):
|
|
instance = ModelForm.save(self, False)
|
|
instance.save()
|
|
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
|
|
class ExamAuthorFormMixin(ModelForm):
|
|
class Meta:
|
|
fields = ["author"]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
self.fields["author"] = ModelMultipleChoiceField(
|
|
queryset=User.objects.all(),
|
|
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False),
|
|
)
|
|
|
|
|
|
class ExamMarkerFormMixin(ModelForm):
|
|
class Meta:
|
|
fields = ["markers"]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
self.fields["markers"] = ModelMultipleChoiceField(
|
|
queryset=User.objects.all(),
|
|
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
|
|
)
|
|
self.fields["markers"].required = False
|
|
|
|
|
|
class ExamGroupsFormMixin(ModelForm):
|
|
class Meta:
|
|
# model = ExamCollection
|
|
fields = ("cid_user_groups", "user_user_groups")
|
|
|
|
def __init__(self, *args, user, **kwargs) -> None:
|
|
super(ModelForm, self).__init__(*args, **kwargs)
|
|
|
|
# TODO: decide how we should handle managing user groups
|
|
# i.e who should be able to access a list of all users?
|
|
|
|
if user.is_superuser or user.groups.filter(name="cid_user_manager").exists():
|
|
cid_user_group_queryset = CidUserGroup.objects.filter(archive=False)
|
|
user_user_group_queryset = UserUserGroup.objects.filter(archive=False)
|
|
else:
|
|
cid_user_group_queryset = CidUserGroup.objects.filter(
|
|
archive=False, open_access=True
|
|
)
|
|
user_user_group_queryset = UserUserGroup.objects.filter(
|
|
archive=False, open_access=True
|
|
)
|
|
# cid_user_group_queryset = CidUserGroup.objects.none()
|
|
# user_user_group_queryset = UserUserGroup.objects.none()
|
|
|
|
self.fields["cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=cid_user_group_queryset,
|
|
label="CID User Groups",
|
|
)
|
|
self.fields["user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=user_user_group_queryset,
|
|
label="User Groups",
|
|
)
|
|
self.fields["cid_user_groups"].widget.attrs["size"] = 8
|
|
self.fields["user_user_groups"].widget.attrs["size"] = 8
|
|
|
|
|
|
class ExamCollectionBulkAddGroupsForm(Form):
|
|
"""Form used by the HTMX modal to bulk-add CID/User groups to an ExamCollection.
|
|
|
|
Accepts `user` kwarg in __init__ to limit available groups based on permissions
|
|
(mirrors behaviour of `ExamGroupsFormMixin`).
|
|
"""
|
|
cid_user_groups = ModelMultipleChoiceField(required=False, queryset=CidUserGroup.objects.none(), label="CID User Groups")
|
|
user_user_groups = ModelMultipleChoiceField(required=False, queryset=UserUserGroup.objects.none(), label="User Groups")
|
|
EXAM_TYPE_CHOICES = [
|
|
("all", "All"),
|
|
("anatomy", "Anatomy"),
|
|
("longs", "Longs"),
|
|
("rapids", "Rapids"),
|
|
("shorts", "Shorts"),
|
|
("physics", "Physics"),
|
|
("sbas", "SBAs"),
|
|
]
|
|
exam_types = MultipleChoiceField(required=False, choices=EXAM_TYPE_CHOICES, label="Exam types (leave empty for all)")
|
|
|
|
def __init__(self, *args, user=None, **kwargs):
|
|
# keep compatibility with other forms that pass user as kwarg
|
|
# This is a plain Form (not a ModelForm) so call Form.__init__
|
|
Form.__init__(self, *args, **kwargs)
|
|
# choose queryset depending on permissions
|
|
if user and (user.is_superuser or user.groups.filter(name="cid_user_manager").exists()):
|
|
cid_qs = CidUserGroup.objects.filter(archive=False)
|
|
user_qs = UserUserGroup.objects.filter(archive=False)
|
|
else:
|
|
cid_qs = CidUserGroup.objects.filter(archive=False, open_access=True)
|
|
user_qs = UserUserGroup.objects.filter(archive=False, open_access=True)
|
|
|
|
self.fields["cid_user_groups"].queryset = cid_qs
|
|
self.fields["user_user_groups"].queryset = user_qs
|
|
self.fields["cid_user_groups"].widget.attrs["size"] = 8
|
|
self.fields["user_user_groups"].widget.attrs["size"] = 8
|
|
|
|
# Crispy helper: render fields prettily in templates. We set form_tag=False
|
|
# because the HTMX partial includes the <form> tag with hx attributes.
|
|
try:
|
|
self.helper = FormHelper()
|
|
# render only the fields (the template contains the form element)
|
|
self.helper.form_tag = False
|
|
# use bootstrap5 templates if available in the project
|
|
self.helper.template_pack = "bootstrap5"
|
|
self.helper.form_class = "form-vertical"
|
|
self.helper.layout = Layout(
|
|
Div(
|
|
Field("cid_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
|
|
css_class="mb-3",
|
|
),
|
|
Div(
|
|
Field("user_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
|
|
css_class="mb-3",
|
|
),
|
|
Div(
|
|
Field("exam_types", css_class="form-select"),
|
|
css_class="mb-3",
|
|
),
|
|
)
|
|
except Exception:
|
|
# If crispy isn't available for some reason, degrade gracefully.
|
|
self.helper = None
|
|
|
|
|
|
class ExaminationForm(ModelForm):
|
|
def __init__(self, *args, **kwargs):
|
|
# Where is this coming from?
|
|
if "user" in kwargs:
|
|
user = kwargs.pop("user")
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
super(ExaminationForm, self).__init__(*args, **kwargs)
|
|
|
|
class Meta:
|
|
model = Examination
|
|
fields = ["examination", "modality"]
|
|
|
|
|
|
class ExaminationMergeForm(Form):
|
|
examination_to_merge_into = ModelChoiceField(queryset=Examination.objects.all())
|
|
|
|
|
|
class QuestionNoteForm(ModelForm):
|
|
class Meta:
|
|
model = QuestionNote
|
|
fields = ["content_type", "object_id", "author", "note_type", "note"]
|
|
widgets = {"content_type": HiddenInput(), "object_id": HiddenInput()}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
question_type = kwargs.pop("question_type")
|
|
pk = kwargs.pop("pk")
|
|
user = kwargs.pop("user")
|
|
|
|
# if question_type == "rapid":
|
|
# question = Rapid
|
|
# content_type = ContentType.objects.get(model="rapid")
|
|
# elif question_type == "anatomy":
|
|
# question = AnatomyQuestion
|
|
# content_type = ContentType.objects.get(model="anatomyquestion")
|
|
# elif question_type == "long":
|
|
# question = Long
|
|
# content_type = ContentType.objects.get(model="long")
|
|
# else:
|
|
# raise PermissionError()
|
|
|
|
# get_object_or_404(question, pk=pk)
|
|
# We get the 'initial' keyword argument or initialize it
|
|
# as a dict if it didn't exist.
|
|
# if kwargs.get("instance"):
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
super(QuestionNoteForm, self).__init__(*args, **kwargs)
|
|
|
|
# initial = kwargs.setdefault("initial", {})
|
|
## The widget for a ModelMultipleChoiceField expects
|
|
## a list of primary key for the selected data.
|
|
##initial["content_type"] = question
|
|
##initial["content_id"] = pk
|
|
# self.fields["content_type"].initial = content_type
|
|
# self.fields["object_id"].initial = pk
|
|
|
|
|
|
class CidUserForm(ModelForm):
|
|
class Meta:
|
|
model = CidUser
|
|
fields = [
|
|
"passcode",
|
|
"active",
|
|
"internal_candidate",
|
|
"name",
|
|
"email",
|
|
"supervisor",
|
|
"login_email_sent",
|
|
"results_email_sent",
|
|
"group",
|
|
]
|
|
|
|
|
|
class CidUserExamForm(ModelForm):
|
|
def __init__(self, *args, **kwargs):
|
|
if kwargs.get("instance"):
|
|
# We get the 'initial' keyword argument or initialize it
|
|
# as a dict if it didn't exist.
|
|
initial = kwargs.setdefault("initial", {})
|
|
# The widget for a ModelMultipleChoiceField expects
|
|
# a list of primary key for the selected data.
|
|
initial["physics_exams"] = [
|
|
t.pk for t in kwargs["instance"].physics_exams.all()
|
|
]
|
|
initial["anatomy_exams"] = [
|
|
t.pk for t in kwargs["instance"].anatomy_exams.all()
|
|
]
|
|
initial["sba_exams"] = [t.pk for t in kwargs["instance"].sba_exams.all()]
|
|
initial["rapid_exams"] = [
|
|
t.pk for t in kwargs["instance"].rapid_exams.all()
|
|
]
|
|
initial["longs_exams"] = [
|
|
t.pk for t in kwargs["instance"].longs_exams.all()
|
|
]
|
|
initial["casecollection_exams"] = [
|
|
t.pk for t in kwargs["instance"].casecollection_exams.all()
|
|
]
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
|
|
super(CidUserExamForm, self).__init__(*args, **kwargs)
|
|
|
|
self.fields["physics_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=PhysicsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Physics Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["anatomy_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=AnatomyExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Anatomy Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["sba_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=SbasExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="SBA Exams", is_stacked=False),
|
|
)
|
|
self.fields["rapid_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=RapidsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
|
)
|
|
self.fields["longs_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=LongsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Long Exams", is_stacked=False),
|
|
)
|
|
self.fields["casecollection_exams"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=CaseCollection.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Case Collection", is_stacked=False
|
|
),
|
|
)
|
|
|
|
def save(self, commit=True):
|
|
# Get the unsaved Long instance
|
|
instance = ModelForm.save(self, False)
|
|
|
|
# Prepare a 'save_m2m' method for the form,
|
|
old_save_m2m = self.save_m2m
|
|
|
|
def save_m2m():
|
|
old_save_m2m()
|
|
instance.physics_exams.clear()
|
|
for physics_exams in self.cleaned_data["physics_exams"]:
|
|
instance.physics_exams.add(physics_exams.pk)
|
|
instance.anatomy_exams.clear()
|
|
for anatomy_exams in self.cleaned_data["anatomy_exams"]:
|
|
instance.anatomy_exams.add(anatomy_exams.pk)
|
|
instance.sba_exams.clear()
|
|
for sba_exams in self.cleaned_data["sba_exams"]:
|
|
instance.sba_exams.add(sba_exams.pk)
|
|
instance.rapid_exams.clear()
|
|
for rapid_exams in self.cleaned_data["rapid_exams"]:
|
|
instance.rapid_exams.add(rapid_exams.pk)
|
|
instance.longs_exams.clear()
|
|
for longs_exams in self.cleaned_data["longs_exams"]:
|
|
instance.longs_exams.add(longs_exams.pk)
|
|
instance.casecollection_exams.clear()
|
|
for casecollection_exams in self.cleaned_data["casecollection_exams"]:
|
|
instance.casecollection_exams.add(casecollection_exams.pk)
|
|
|
|
self.save_m2m = save_m2m
|
|
|
|
# Do we need to save all changes now?
|
|
# if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
class Meta:
|
|
model = CidUser
|
|
fields = []
|
|
|
|
|
|
class CidUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|
def label_from_instance(self, obj):
|
|
return f"{obj.cid} [{obj.email}]"
|
|
|
|
|
|
class CidUserGroupForm(ModelForm):
|
|
class Meta:
|
|
model = CidUserGroup
|
|
fields = ["name", "archive", "open_access"]
|
|
|
|
|
|
class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|
def label_from_instance(self, obj):
|
|
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
|
|
|
|
|
class UserUserGroupForm(ModelForm):
|
|
class UserSearchSelectMultiple(object):
|
|
"""
|
|
Lightweight widget renderer for a searchable multi-user selector.
|
|
Renders a hidden <select multiple> containing selected user ids and
|
|
a search box with results. Uses a small inline script to fetch
|
|
results from the `generic:user_search_widget` endpoint and add/remove
|
|
users from the selection.
|
|
This keeps implementation simple and avoids external JS dependencies.
|
|
"""
|
|
|
|
def __init__(self, name, value):
|
|
self.name = name
|
|
# value is a list of selected user ids
|
|
self.value = value or []
|
|
|
|
def render(self):
|
|
field_id = f"id_{self.name}"
|
|
search_id = f"user_search_{self.name}"
|
|
results_id = f"user_search_results_{self.name}"
|
|
selected_list_id = f"selected_users_{self.name}"
|
|
|
|
# Build initial options for selected users
|
|
users = User.objects.filter(pk__in=self.value) if self.value else []
|
|
options_html = ""
|
|
selected_html = ""
|
|
for u in users:
|
|
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
|
# include grade display in the selected list if available
|
|
grade_text = ""
|
|
try:
|
|
up = getattr(u, "userprofile", None)
|
|
if up and getattr(up, "grade", None):
|
|
g = up.grade
|
|
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
|
except Exception:
|
|
grade_text = ""
|
|
|
|
selected_html += (
|
|
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
|
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
|
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
|
+ '</span>'
|
|
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
|
f'</li>'
|
|
)
|
|
|
|
url = reverse("generic:user_search_widget")
|
|
|
|
# Build a small grade filter select
|
|
grades_options = '<option value="">All grades</option>'
|
|
try:
|
|
from generic.models import UserGrades
|
|
grades_qs = UserGrades.objects.all()
|
|
for g in grades_qs:
|
|
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
|
except Exception:
|
|
grades_options = '<option value="">All grades</option>'
|
|
|
|
html = f"""
|
|
<div class="user-search-widget">
|
|
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
|
{options_html}
|
|
</select>
|
|
|
|
<div class="mb-2">
|
|
<div class="d-flex gap-2">
|
|
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
|
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
|
{grades_options}
|
|
</select>
|
|
</div>
|
|
<div id="{results_id}" class="mt-2"></div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="form-label small">Selected users</label>
|
|
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
|
{selected_html}
|
|
</ul>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {{
|
|
const search = document.getElementById('{search_id}');
|
|
const results = document.getElementById('{results_id}');
|
|
const select = document.getElementById('{field_id}');
|
|
const selectedList = document.getElementById('{selected_list_id}');
|
|
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
|
|
|
let timeout = null;
|
|
function fetchResults(q) {{
|
|
const grade = gradeSelect ? gradeSelect.value : '';
|
|
if (!q || q.trim().length === 0) {{
|
|
results.innerHTML = '';
|
|
return;
|
|
}}
|
|
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
|
fetch(url)
|
|
.then(r => r.text())
|
|
.then(html => {{ results.innerHTML = html; }});
|
|
}}
|
|
|
|
search.addEventListener('keyup', function(e) {{
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fetchResults(search.value), 300);
|
|
}});
|
|
|
|
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
|
// re-run search with same query when grade changes
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fetchResults(search.value), 50);
|
|
}});
|
|
|
|
// fieldName for this widget instance
|
|
const widgetFieldName = '{self.name}';
|
|
|
|
// Add button click delegation: results list buttons have data attributes
|
|
results.addEventListener('click', function(e) {{
|
|
const btn = e.target.closest('.user-add-btn');
|
|
if (!btn) return;
|
|
const id = btn.getAttribute('data-user-id');
|
|
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
|
const grade = btn.getAttribute('data-user-grade') || '';
|
|
addUserToField(widgetFieldName, id, text, grade);
|
|
}});
|
|
|
|
// Wire up the "Add all results" button if present
|
|
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
|
if (addAllBtn) {{
|
|
addAllBtn.addEventListener('click', function(e) {{
|
|
e.preventDefault();
|
|
addAllFromResults(widgetFieldName);
|
|
}});
|
|
}}
|
|
|
|
window.addUserToField = function(fieldName, id, text, grade) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
// prevent duplicate
|
|
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
|
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
|
sel.appendChild(opt);
|
|
|
|
// add to visible list (include grade if provided)
|
|
const li = document.createElement('li');
|
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
li.id = 'selected_user_' + fieldName + '_' + id;
|
|
const span = document.createElement('span');
|
|
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
|
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
|
li.appendChild(span); li.appendChild(btn);
|
|
selectedList.appendChild(li);
|
|
}}
|
|
|
|
window.removeUserFromField = function(fieldName, id) {{
|
|
const sel = document.getElementById('id_' + fieldName);
|
|
if (!sel) return;
|
|
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
|
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
}}
|
|
|
|
window.addAllFromResults = function(fieldName) {{
|
|
const list = document.getElementById('user_search_results_list_' + fieldName);
|
|
if (!list) return;
|
|
const items = list.querySelectorAll('li[data-user-id]');
|
|
items.forEach(function(it) {{
|
|
const id = it.getAttribute('data-user-id');
|
|
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
|
const grade = it.getAttribute('data-user-grade') || '';
|
|
addUserToField(fieldName, id, text, grade);
|
|
}});
|
|
}}
|
|
}})();
|
|
</script>
|
|
</div>
|
|
"""
|
|
|
|
return mark_safe(html)
|
|
|
|
users = UserUserGroupModelChoiceField(
|
|
required=False,
|
|
queryset=User.objects.all(),
|
|
# use our lightweight widget rendering
|
|
widget=None,
|
|
)
|
|
|
|
# monkey-patch the field's widget rendering when the form is instantiated
|
|
def __init__(self, *args, **kwargs):
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
# replace widget with callable that renders our HTML
|
|
if 'users' in self.fields:
|
|
orig_field = self.fields['users']
|
|
|
|
class RenderWrapper:
|
|
def __init__(self, field):
|
|
self.field = field
|
|
|
|
# Minimal widget-compatible wrapper used by Django templates and
|
|
# form machinery. We implement the small subset of the Widget
|
|
# API that the templates/checks expect: `is_hidden`,
|
|
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
|
|
is_hidden = False
|
|
needs_multipart_form = False
|
|
use_fieldset = False
|
|
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
|
|
attrs = {}
|
|
|
|
def render(self, name, value, attrs=None, renderer=None):
|
|
# store attrs so template helpers can inspect them
|
|
self.attrs = attrs or {}
|
|
value = value or []
|
|
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
|
|
return widget.render()
|
|
|
|
def value_from_datadict(self, data, files, name):
|
|
# data is usually QueryDict with getlist
|
|
if hasattr(data, 'getlist'):
|
|
return data.getlist(name)
|
|
# fallback
|
|
val = data.get(name)
|
|
if val is None:
|
|
return []
|
|
return [val]
|
|
|
|
def id_for_label(self, id_):
|
|
# Called by BoundField.id_for_label; prefer explicit id in attrs
|
|
try:
|
|
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
|
return self.attrs.get('id')
|
|
except Exception:
|
|
pass
|
|
return id_
|
|
|
|
def use_required_attribute(self, initial):
|
|
# Called by Django to decide whether to add the required HTML attribute.
|
|
# We defer to the underlying field/widget if available; otherwise
|
|
# return False to avoid unexpected 'required' attributes.
|
|
try:
|
|
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
|
# If original field/widget had such method, prefer it.
|
|
orig_widget = getattr(self.field, 'widget', None)
|
|
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
|
return orig_widget.use_required_attribute(initial)
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
self.fields['users'].widget = RenderWrapper(orig_field)
|
|
|
|
class Meta:
|
|
model = UserUserGroup
|
|
fields = ["name", "archive", "open_access", "users"]
|
|
|
|
|
|
class UserGroupExamForm(ModelForm):
|
|
GROUP_TYPES = [
|
|
"anatomy_user_user_groups",
|
|
"rapid_user_user_groups",
|
|
"shorts_user_user_groups",
|
|
"longs_user_user_groups",
|
|
"physics_user_user_groups",
|
|
"sba_user_user_groups",
|
|
]
|
|
|
|
# users = UserUserGroupModelChoiceField(
|
|
# required=False,
|
|
# queryset=User.objects.all(),
|
|
# widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False),
|
|
# )
|
|
def __init__(self, *args, **kwargs):
|
|
if kwargs.get("instance"):
|
|
# We get the 'initial' keyword argument or initialize it
|
|
# as a dict if it didn't exist.
|
|
initial = kwargs.setdefault("initial", {})
|
|
# The widget for a ModelMultipleChoiceField expects
|
|
# a list of primary key for the selected data.
|
|
for groups in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
# obj = getattr(instance,groups)
|
|
initial[groups] = [
|
|
t.pk for t in getattr(kwargs["instance"], groups).all()
|
|
]
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
|
|
super(UserGroupExamForm, self).__init__(*args, **kwargs)
|
|
|
|
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=AnatomyExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Anatomy Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=RapidsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
|
)
|
|
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=ShortsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False),
|
|
)
|
|
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=LongsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
|
|
)
|
|
self.fields["physics_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=PhysicsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Physics Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=SbasExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
|
)
|
|
|
|
class Meta:
|
|
model = UserUserGroup
|
|
fields = []
|
|
|
|
def save(self, commit=True):
|
|
# Get the unsaved Long instance
|
|
instance = ModelForm.save(self, False)
|
|
|
|
# Prepare a 'save_m2m' method for the form,
|
|
old_save_m2m = self.save_m2m
|
|
|
|
def save_m2m():
|
|
old_save_m2m()
|
|
|
|
for groups in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
obj = getattr(instance, groups)
|
|
obj.clear()
|
|
for exam in self.cleaned_data[groups]:
|
|
obj.add(exam.pk)
|
|
|
|
self.save_m2m = save_m2m
|
|
|
|
# Do we need to save all changes now?
|
|
# if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
|
|
class CidGroupExamForm(ModelForm):
|
|
GROUP_TYPES = [
|
|
"anatomy_cid_user_groups",
|
|
"rapid_cid_user_groups",
|
|
"longs_cid_user_groups",
|
|
"physics_cid_user_groups",
|
|
"sba_cid_user_groups",
|
|
]
|
|
|
|
# users = CidUserGroupModelChoiceField(
|
|
# required=False,
|
|
# queryset=User.objects.all(),
|
|
# widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False),
|
|
def __init__(self, *args, **kwargs):
|
|
if kwargs.get("instance"):
|
|
# We get the 'initial' keyword argument or initialize it
|
|
# as a dict if it didn't exist.
|
|
initial = kwargs.setdefault("initial", {})
|
|
# The widget for a ModelMultipleChoiceField expects
|
|
# a list of primary key for the selected data.
|
|
for groups in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
# obj = getattr(instance,groups)
|
|
initial[groups] = [
|
|
t.pk for t in getattr(kwargs["instance"], groups).all()
|
|
]
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
|
|
super(CidGroupExamForm, self).__init__(*args, **kwargs)
|
|
|
|
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=AnatomyExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Anatomy Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=RapidsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
|
|
)
|
|
self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=LongsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
|
|
)
|
|
self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=PhysicsExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(
|
|
verbose_name="Physics Exams", is_stacked=False
|
|
),
|
|
)
|
|
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=SbasExam.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
|
|
)
|
|
|
|
class Meta:
|
|
model = CidUserGroup
|
|
fields = []
|
|
|
|
def save(self, commit=True):
|
|
# Get the unsaved Long instance
|
|
instance = ModelForm.save(self, False)
|
|
|
|
# Prepare a 'save_m2m' method for the form,
|
|
old_save_m2m = self.save_m2m
|
|
|
|
def save_m2m():
|
|
old_save_m2m()
|
|
|
|
for groups in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
obj = getattr(instance, groups)
|
|
obj.clear()
|
|
for exam in self.cleaned_data[groups]:
|
|
obj.add(exam.pk)
|
|
|
|
self.save_m2m = save_m2m
|
|
|
|
# Do we need to save all changes now?
|
|
# if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
|
|
# class UserForm(ModelForm):
|
|
# class Meta:
|
|
# model = User
|
|
# fields = ["first_name", "last_name", "email"]
|
|
#
|
|
# class UserProfileForm(ModelForm):
|
|
# class Meta:
|
|
# model = UserProfile
|
|
# fields = ["supervisor_name", "supervisor_email", "registration_number", "peninsula_trainee"]
|
|
|
|
GRADE_CHOICES = (
|
|
("ST1", "ST1"),
|
|
("ST2", "ST2"),
|
|
("ST3", "ST3"),
|
|
("ST4", "ST4"),
|
|
("ST5", "ST5"),
|
|
)
|
|
|
|
|
|
class UserUserForm(Form):
|
|
username = EmailField(
|
|
required=True,
|
|
help_text="Username / email should be the same. Ideally this should be an nhs email.",
|
|
)
|
|
first_name = CharField(max_length=255, required=True)
|
|
last_name = CharField(max_length=255, required=True)
|
|
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
|
|
|
|
|
|
class TraineeForm(Form):
|
|
username = EmailField(
|
|
required=True,
|
|
help_text="Username / email should be the same. Ideally this should be an nhs email.",
|
|
)
|
|
first_name = CharField(max_length=255, required=True)
|
|
last_name = CharField(max_length=255, required=True)
|
|
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
|
|
supervisor = ModelChoiceField(
|
|
Supervisor.objects.all().order_by("name"),
|
|
required=False,
|
|
widget=autocomplete.ModelSelect2(url="generic:supervisor-autocomplete"),
|
|
) # Needs to be a user/object ref
|
|
|
|
# class Meta:
|
|
# widgets = {
|
|
# "supervisor" : autocomplete.ModelSelect2(url='generic:supervisor-autocomplete')
|
|
# }
|
|
|
|
|
|
class SupervisorForm(ModelForm):
|
|
class Meta:
|
|
model = Supervisor
|
|
exclude = ("",)
|
|
|
|
|
|
class ExamCollectionForm(ModelForm):
|
|
GROUP_TYPES = (
|
|
("Rapid Exams", "rapids_exams", RapidsExam),
|
|
("Shorts Exams", "shorts_exams", ShortsExam),
|
|
("Long Exams", "longs_exams", LongsExam),
|
|
("SBA Exams", "sbas_exams", SbasExam),
|
|
("Physic Exams", "physics_exams", PhysicsExam),
|
|
("Anatomy Exams", "anatomy_exams", AnatomyExam),
|
|
)
|
|
|
|
class Meta:
|
|
model = ExamCollection
|
|
exclude = ("",)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
if kwargs.get("instance"):
|
|
# We get the 'initial' keyword argument or initialize it
|
|
# as a dict if it didn't exist.
|
|
initial = kwargs.setdefault("initial", {})
|
|
# The widget for a ModelMultipleChoiceField expects
|
|
# a list of primary key for the selected data.
|
|
for name, reverse_prop, exam_obj in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
# obj = getattr(instance,groups)
|
|
initial[reverse_prop] = [
|
|
t.pk for t in getattr(kwargs["instance"], reverse_prop).all()
|
|
]
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
|
|
super(ExamCollectionForm, self).__init__(*args, **kwargs)
|
|
|
|
for name, reverse_prop, exam_obj in self.GROUP_TYPES:
|
|
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=exam_obj.objects.filter(archive=False),
|
|
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
|
|
)
|
|
|
|
self.fields["author"] = ModelMultipleChoiceField(
|
|
queryset=User.objects.all(),
|
|
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
|
|
)
|
|
|
|
def save(self, commit=True):
|
|
# Get the unsaved Long instance
|
|
instance = ModelForm.save(self, False)
|
|
|
|
# Prepare a 'save_m2m' method for the form,
|
|
old_save_m2m = self.save_m2m
|
|
|
|
def save_m2m():
|
|
old_save_m2m()
|
|
|
|
for name, reverse_prop, exam_obj in self.GROUP_TYPES:
|
|
# instance.anatomy_cid_user_groups.clear()
|
|
obj = getattr(instance, reverse_prop)
|
|
obj.clear()
|
|
for exam in self.cleaned_data[reverse_prop]:
|
|
obj.add(exam)
|
|
|
|
self.save_m2m = save_m2m
|
|
|
|
# Do we need to save all changes now?
|
|
# if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
|
|
class ExamCollectionCloneForm(ModelForm):
|
|
class Meta:
|
|
model = ExamCollection
|
|
fields = ("name", "date")
|
|
|
|
|
|
class UserAutocomplete(ModelAutocomplete):
|
|
model = User
|
|
search_attrs = ["first_name", "last_name", "username"]
|
|
|
|
class UsersAutocompleteForm(Form):
|
|
# Use a ModelMultipleChoiceField so the widget's multiselect option maps to a multi-value field
|
|
users = ModelMultipleChoiceField(
|
|
queryset=User.objects.all(),
|
|
widget=AutocompleteWidget(ac_class=UserAutocomplete, options={"multiselect": True}),
|
|
)
|
|
|
|
autocomplete_register(UserAutocomplete)
|
|
|
|
class QuestionReviewForm(ModelForm):
|
|
class Meta:
|
|
model = QuestionReview
|
|
fields = ["status", "comment"]
|
|
widgets = {
|
|
# Use Bootstrap "btn-check" inputs so we can style labels as button toggles
|
|
"status": RadioSelect(attrs={"class": "btn-check", "autocomplete": "off"}),
|
|
"comment": Textarea(attrs={"class": "form-control form-control-sm", "placeholder": "Comment (optional)", "rows": 2, "autofocus": "autofocus"}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
# Accept an optional `user` for future validation/use
|
|
self.user = kwargs.pop("user", None)
|
|
super().__init__(*args, **kwargs) |