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.
    • -
    -
    -
    - -
    - - -
    - - -
    - """ - - 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.
    • +
    +
    +
    + +
    + + +
    + + +
    + """ + + 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",