diff --git a/atlas/views.py b/atlas/views.py
index 25709c5a..d3665f13 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -1090,10 +1090,12 @@ def author_list(request):
return render(request, "atlas/author_list.html", {"authors": authors})
+@login_required
def index(request):
return render(request, "atlas/index.html", {})
+@login_required
def user_collections(request):
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
available_collections = request.user.user_casecollection_exams.all()
diff --git a/deploy/settings_local.py b/deploy/settings_local.py
index 66c13638..da1ad29f 100644
--- a/deploy/settings_local.py
+++ b/deploy/settings_local.py
@@ -117,6 +117,10 @@ SECURE_HSTS_SECONDS = 0
CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000",
"http://localhost:8000",
+ "http://127.0.0.1:8080",
+ "http://localhost:8080",
+ "http://127.0.0.1:8080/",
+ "http://localhost:8080/",
]
REMOTE_URL = "http://127.0.0.1:8000"
\ No newline at end of file
diff --git a/generic/forms.py b/generic/forms.py
index 32cb7700..5e55644b 100755
--- a/generic/forms.py
+++ b/generic/forms.py
@@ -81,6 +81,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import User
+from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -240,7 +241,7 @@ class ExamAuthorFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
- widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False),
+ widget=UserSearchWidget(),
)
@@ -252,7 +253,7 @@ class ExamMarkerFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
self.fields["markers"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
- widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
+ widget=UserSearchWidget(),
)
self.fields["markers"].required = False
@@ -556,219 +557,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
-class UserSearchSelectMultipleWidget:
- """Reusable lightweight widget renderer for a searchable multi-user selector.
- Usage: instantiate with (name, value) where value is an iterable of user ids
- and call .render() to get the HTML fragment. This mirrors the previous
- nested widget but is available module-wide for reuse.
- """
-
- def __init__(self, name, value):
- self.name = name
- 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}"
-
- users = User.objects.filter(pk__in=self.value) if self.value else []
- options_html = ""
- selected_html = ""
- for u in users:
- options_html += f''
- 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'
'
- f'{u.get_full_name() or u.username} {u.email}'
- + (f' {grade_text}' if grade_text else '')
- + ''
- f''
- f'
'
- )
-
- url = reverse("generic:user_search_widget")
-
- grades_options = ''
- try:
- from generic.models import UserGrades
-
- grades_qs = UserGrades.objects.all()
- for g in grades_qs:
- grades_options += f''
- except Exception:
- grades_options = ''
-
- html = f"""
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Advanced search tips
-
-
Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: smith or joe@example.com.
-
Wildcards: use * or % as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.
-
Field-specific searches: prefix with field:term to restrict the search. Supported fields:
-
-
name: or full_name: — matches first OR last name (e.g. name:smith).
-
first: or first_name: — matches first name.
-
last: or last_name: — matches last name.
-
email: — matches email address.
-
username: — matches username.
-
id: or pk: — match by numeric user id (e.g. id:123).
-
grade: — match by grade name or grade id (e.g. grade:ST3 or grade:2).
-
-
-
Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.
-
To add users, click the Add button beside a result. If an Add all results button appears, it will add all currently visible results.
-
If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.
-
-
-
-
-
-
-
- {selected_html}
-
-
-
-
-
- """
-
- return mark_safe(html)
@@ -789,61 +578,9 @@ class UserUserGroupForm(ModelForm):
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 = UserSearchSelectMultipleWidget(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)
+ # Use the reusable UserSearchWidget wrapper so templates and other
+ # modules can reuse the same behaviour consistently.
+ self.fields['users'].widget = UserSearchWidget(orig_field)
class Meta:
model = UserUserGroup
@@ -1137,7 +874,7 @@ class ExamCollectionForm(ModelForm):
self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
- widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
+ widget=UserSearchWidget(), required=False,
)
def save(self, commit=True):
diff --git a/generic/widgets.py b/generic/widgets.py
new file mode 100644
index 00000000..caaff6d6
--- /dev/null
+++ b/generic/widgets.py
@@ -0,0 +1,270 @@
+from django.contrib.auth.models import User
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+
+
+class UserSearchSelectMultipleWidget:
+ """Reusable lightweight widget renderer for a searchable multi-user selector.
+
+ Usage: instantiate with (name, value) where value is an iterable of user ids
+ and call .render() to get the HTML fragment.
+ """
+
+ def __init__(self, name, value):
+ self.name = name
+ 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}"
+
+ users = User.objects.filter(pk__in=self.value) if self.value else []
+ options_html = ""
+ selected_html = ""
+ for u in users:
+ options_html += f''
+ 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'
'
+ f'{u.get_full_name() or u.username} {u.email}'
+ + (f' {grade_text}' if grade_text else '')
+ + ''
+ f''
+ f'
'
+ )
+
+ url = reverse("generic:user_search_widget")
+
+ grades_options = ''
+ try:
+ from generic.models import UserGrades
+
+ grades_qs = UserGrades.objects.all()
+ for g in grades_qs:
+ grades_options += f''
+ except Exception:
+ grades_options = ''
+
+ html = f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advanced search tips
+
+
Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: smith or joe@example.com.
+
Wildcards: use * or % as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.
+
Field-specific searches: prefix with field:term to restrict the search. Supported fields:
+
+
name: or full_name: — matches first OR last name (e.g. name:smith).
+
first: or first_name: — matches first name.
+
last: or last_name: — matches last name.
+
email: — matches email address.
+
username: — matches username.
+
id: or pk: — match by numeric user id (e.g. id:123).
+
grade: — match by grade name or grade id (e.g. grade:ST3 or grade:2).
+
+
+
Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.
+
To add users, click the Add button beside a result. If an Add all results button appears, it will add all currently visible results.
+
If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.
+
+
+
+
+
+
+
+ {selected_html}
+
+
+
+
+
+ """
+
+ return mark_safe(html)
+
+
+class UserSearchWidget:
+ """Django-widget-compatible lightweight wrapper around
+ `UserSearchSelectMultipleWidget` so it can be reused as a field widget.
+
+ Implements the small subset of the Widget API that Django's forms
+ and templates expect: `render`, `value_from_datadict`, `id_for_label`,
+ `use_required_attribute`, and a couple of simple attributes.
+ """
+
+ is_hidden = False
+ needs_multipart_form = False
+ use_fieldset = False
+
+ def __init__(self, field=None):
+ # keep a reference to the original field if available (used for
+ # delegation such as `use_required_attribute`).
+ self.field = field
+ self.attrs = {}
+
+ def render(self, name, value, attrs=None, renderer=None):
+ self.attrs = attrs or {}
+ value = value or []
+ widget = UserSearchSelectMultipleWidget(name, value)
+ return widget.render()
+
+ def value_from_datadict(self, data, files, name):
+ # Most form backends provide getlist
+ if hasattr(data, "getlist"):
+ return data.getlist(name)
+ val = data.get(name)
+ if val is None:
+ return []
+ return [val]
+
+ def id_for_label(self, id_):
+ 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):
+ try:
+ if hasattr(self, "field") and getattr(self, "field") is not None:
+ 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
diff --git a/rad/settings.py b/rad/settings.py
index 205f4198..52b62e79 100644
--- a/rad/settings.py
+++ b/rad/settings.py
@@ -31,6 +31,8 @@ DEBUG = int(os.environ.get("DEBUG", default=0))
ALLOWED_HOSTS = [
"localhost",
+ "localhost:8000",
+ "localhost:8080",
"127.0.0.1",
"161.35.163.87",
"46.101.13.46",
@@ -241,7 +243,7 @@ MEDIA_ROOT = "media/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
-INTERNAL_IPS = ["localhost", "127.0.0.1"]
+INTERNAL_IPS = ["localhost", "127.0.0.1", "localhost:8000", "localhost:8080"]
#LOGGING = {
# "version": 1,
@@ -255,6 +257,18 @@ INTERNAL_IPS = ["localhost", "127.0.0.1"]
CORS_ORIGIN_ALLOW_ALL = True
+# Development: trust local origins (including ports) so the dev nginx reverse
+# proxy and the Django runserver can POST/PUT without CSRF Origin errors.
+if DEBUG:
+ CSRF_TRUSTED_ORIGINS = [
+ "http://localhost",
+ "http://127.0.0.1",
+ "http://localhost:8000",
+ "http://127.0.0.1:8000",
+ "http://localhost:8080",
+ "http://127.0.0.1:8080",
+ ]
+
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",