Implement hiding and showing functionality for user exams and collections
This commit is contained in:
@@ -98,13 +98,39 @@ Instead
|
||||
|
||||
## Docker (Dev)
|
||||
|
||||
### Start Dev Environment
|
||||
Start the development server with local configurations:
|
||||
```bash
|
||||
# Start dev environment
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
# Run management commands inside the container
|
||||
docker compose exec web python manage.py migrate
|
||||
docker compose exec web python manage.py createsuperuser
|
||||
### Run Django Management Commands inside the Container
|
||||
Since PostgreSQL and Redis run in docker, run all database/management commands within the web container:
|
||||
```bash
|
||||
# Run database migrations
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py migrate
|
||||
|
||||
# Generate new migrations
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations
|
||||
|
||||
# Check if migrations are missing (useful for CI/pre-commit)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations --check
|
||||
|
||||
# Run system checks
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py check
|
||||
|
||||
# Create a Django superuser
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### Runserver and service commands
|
||||
To restart the web server/runserver or tail the container output:
|
||||
```bash
|
||||
# Restart the runserver/web service container
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml restart web
|
||||
|
||||
# View container logs (interactive)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml logs -f web
|
||||
```
|
||||
|
||||
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
|
||||
|
||||
@@ -5,40 +5,73 @@
|
||||
<h5 class="card-title">Your collections</h5>
|
||||
|
||||
<h6 class="mt-3">Available to start</h6>
|
||||
<ul class="list-unstyled">
|
||||
<ul class="list-group list-group-sm mb-3">
|
||||
{% for collection in available_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ collection.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
|
||||
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No collections available to start.</li>
|
||||
<li class="list-group-item text-muted">No collections available to start.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">In progress</h6>
|
||||
<ul class="list-unstyled">
|
||||
<ul class="list-group list-group-sm mb-3">
|
||||
{% for collection in in_progress_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-warning text-dark ms-2">In progress</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-warning text-dark ms-2">In progress</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ collection.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
|
||||
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No in-progress collections.</li>
|
||||
<li class="list-group-item text-muted">No in-progress collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h6 class="mt-3">Finished</h6>
|
||||
<ul class="list-unstyled">
|
||||
<ul class="list-group list-group-sm">
|
||||
{% for collection in finished_collections %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-success ms-2">Completed</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||
<span class="badge bg-success ms-2">Completed</span>
|
||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
||||
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ collection.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
|
||||
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">No finished collections.</li>
|
||||
<li class="list-group-item text-muted">No finished collections.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
||||
+25
-3
@@ -112,6 +112,7 @@ from .forms import (
|
||||
UserQuestionAnswerForm,
|
||||
UserReportAnswerForm,
|
||||
)
|
||||
from generic.models import UserHiddenItem
|
||||
from .models import (
|
||||
Case,
|
||||
CaseCollection,
|
||||
@@ -2786,11 +2787,32 @@ def user_collections(request):
|
||||
# Finished (completed)
|
||||
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
|
||||
|
||||
show_hidden = request.GET.get("show_hidden") in ("true", "1")
|
||||
|
||||
# Get content type for CaseCollection
|
||||
ct_cc = ContentType.objects.get_for_model(CaseCollection)
|
||||
hidden_ids = set(
|
||||
UserHiddenItem.objects.filter(user=target_user, content_type=ct_cc).values_list("object_id", flat=True)
|
||||
)
|
||||
|
||||
if not show_hidden:
|
||||
available_qs = available_qs.exclude(pk__in=hidden_ids)
|
||||
in_progress_qs = in_progress_qs.exclude(pk__in=hidden_ids)
|
||||
finished_qs = finished_qs.exclude(pk__in=hidden_ids)
|
||||
|
||||
available_list = list(available_qs)
|
||||
in_progress_list = list(in_progress_qs)
|
||||
finished_list = list(finished_qs)
|
||||
|
||||
for coll in available_list + in_progress_list + finished_list:
|
||||
coll.is_hidden = coll.pk in hidden_ids
|
||||
|
||||
context = {
|
||||
"target_user": target_user,
|
||||
"available_collections": available_qs,
|
||||
"in_progress_collections": in_progress_qs,
|
||||
"finished_collections": finished_qs,
|
||||
"available_collections": available_list,
|
||||
"in_progress_collections": in_progress_list,
|
||||
"finished_collections": finished_list,
|
||||
"show_hidden": show_hidden,
|
||||
}
|
||||
|
||||
# If called via HTMX return a small fragment suitable for swapping
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-15 09:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('generic', '0032_ciduserexam_shared_with_users'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserHiddenItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hidden_items', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'content_type', 'object_id'), name='unique_user_hidden_item')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -698,6 +698,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
|
||||
default=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def content_type_id(self) -> int:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
return ContentType.objects.get_for_model(self).id
|
||||
|
||||
|
||||
authors_only = models.BooleanField(
|
||||
help_text="If true only exam/collection authors will be able to view.",
|
||||
default=False,
|
||||
@@ -2390,3 +2396,28 @@ class FindingBase(models.Model):
|
||||
return get_pretty_json(json.loads(self.viewport_json))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
class UserHiddenItem(models.Model):
|
||||
"""Model to track which exams or collections have been hidden by a user.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="hidden_items",
|
||||
)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "content_type", "object_id"],
|
||||
name="unique_user_hidden_item",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} hid {self.content_object}"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
||||
# User content review views
|
||||
path("user-content/", views.user_content_list, name="user_content_list"),
|
||||
path("user-content/<int:user_id>/", views.user_content_review, name="user_content_review"),
|
||||
path("toggle-hide-item/", views.toggle_hide_item, name="toggle_hide_item"),
|
||||
path("examination/", views.ExaminationView.as_view(), name="examination_view"),
|
||||
path("examination/<int:pk>", views.examination_detail, name="examination_detail"),
|
||||
path(
|
||||
|
||||
@@ -7114,4 +7114,43 @@ def user_content_review(request, user_id):
|
||||
# Render HTMX partial (or full page if not HTMX)
|
||||
template = "generic/partials/user_content_review.html"
|
||||
return render(request, template, context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_hide_item(request):
|
||||
"""Toggle the hidden status of an exam or collection for the current user.
|
||||
|
||||
Scope: Authenticated end users.
|
||||
Functionality: Adds or removes a UserHiddenItem record for the given
|
||||
content_type_id and object_id, then triggers a page reload via HX-Refresh.
|
||||
"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import UserHiddenItem
|
||||
|
||||
content_type_id = request.POST.get("content_type_id")
|
||||
object_id = request.POST.get("object_id")
|
||||
|
||||
if not content_type_id or not object_id:
|
||||
return HttpResponse("Missing parameters", status=400)
|
||||
|
||||
try:
|
||||
content_type = ContentType.objects.get(pk=int(content_type_id))
|
||||
object_id = int(object_id)
|
||||
except (ContentType.DoesNotExist, ValueError):
|
||||
return HttpResponse("Invalid parameters", status=400)
|
||||
|
||||
hidden_item, created = UserHiddenItem.objects.get_or_create(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
)
|
||||
|
||||
if not created:
|
||||
hidden_item.delete()
|
||||
|
||||
response = HttpResponse("")
|
||||
response["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -182,4 +182,103 @@ def test_people_page_and_user_search(client, create_users, create_cid_manager):
|
||||
assert b"No users found matching" in response_no_match.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_hide_exams_and_collections(client, create_users):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from physics.models import Exam as PhysicsExam
|
||||
from atlas.models import CaseCollection
|
||||
from generic.models import UserHiddenItem
|
||||
|
||||
user1, user2 = create_users
|
||||
|
||||
# 1. Create a PhysicsExam and associate with user1
|
||||
exam = PhysicsExam.objects.create(
|
||||
name="Test Physics Exam", exam_mode=True, active=True
|
||||
)
|
||||
user1.user_physics_exams.add(exam)
|
||||
|
||||
# 2. Create a CaseCollection and associate with user1
|
||||
collection = CaseCollection.objects.create(
|
||||
name="Test Case Collection", exam_mode=True, active=True
|
||||
)
|
||||
user1.user_casecollection_exams.add(collection)
|
||||
|
||||
# Log in as user1
|
||||
client.force_login(user1)
|
||||
|
||||
# Verify they are visible on user scores page initially
|
||||
response = client.get(reverse("user_scores"))
|
||||
assert response.status_code == 200
|
||||
assert b"Test Physics Exam" in response.content
|
||||
|
||||
# Load atlas collections partial
|
||||
response_cc = client.get(reverse("atlas:user_collections"))
|
||||
assert response_cc.status_code == 200
|
||||
assert b"Test Case Collection" in response_cc.content
|
||||
|
||||
# 3. Hide the PhysicsExam
|
||||
exam_ct = ContentType.objects.get_for_model(PhysicsExam)
|
||||
response_hide_exam = client.post(
|
||||
reverse("generic:toggle_hide_item"),
|
||||
{"content_type_id": exam_ct.id, "object_id": exam.id},
|
||||
)
|
||||
assert response_hide_exam.status_code == 200
|
||||
assert response_hide_exam.headers.get("HX-Refresh") == "true"
|
||||
|
||||
# Verify UserHiddenItem exists
|
||||
assert UserHiddenItem.objects.filter(
|
||||
user=user1, content_type=exam_ct, object_id=exam.id
|
||||
).exists()
|
||||
|
||||
# Verify exam is hidden now from standard user_scores
|
||||
response_hidden = client.get(reverse("user_scores"))
|
||||
assert response_hidden.status_code == 200
|
||||
assert b"Test Physics Exam" not in response_hidden.content
|
||||
|
||||
# But present when show_hidden=true is requested
|
||||
response_show_hidden = client.get(reverse("user_scores") + "?show_hidden=true")
|
||||
assert response_show_hidden.status_code == 200
|
||||
assert b"Test Physics Exam" in response_show_hidden.content
|
||||
assert b"Hidden" in response_show_hidden.content
|
||||
|
||||
# 4. Hide the CaseCollection
|
||||
cc_ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
response_hide_cc = client.post(
|
||||
reverse("generic:toggle_hide_item"),
|
||||
{"content_type_id": cc_ct.id, "object_id": collection.pk},
|
||||
)
|
||||
assert response_hide_cc.status_code == 200
|
||||
assert response_hide_cc.headers.get("HX-Refresh") == "true"
|
||||
|
||||
assert UserHiddenItem.objects.filter(
|
||||
user=user1, content_type=cc_ct, object_id=collection.pk
|
||||
).exists()
|
||||
|
||||
# Verify collection is hidden from normal view
|
||||
response_cc_hidden = client.get(reverse("atlas:user_collections"))
|
||||
assert response_cc_hidden.status_code == 200
|
||||
assert b"Test Case Collection" not in response_cc_hidden.content
|
||||
|
||||
# Verify collection is visible in show_hidden view
|
||||
response_cc_show_hidden = client.get(reverse("atlas:user_collections") + "?show_hidden=true")
|
||||
assert response_cc_show_hidden.status_code == 200
|
||||
assert b"Test Case Collection" in response_cc_show_hidden.content
|
||||
assert b"Hidden" in response_cc_show_hidden.content
|
||||
|
||||
# 5. Unhide the PhysicsExam by toggling again
|
||||
response_unhide_exam = client.post(
|
||||
reverse("generic:toggle_hide_item"),
|
||||
{"content_type_id": exam_ct.id, "object_id": exam.id},
|
||||
)
|
||||
assert response_unhide_exam.status_code == 200
|
||||
assert not UserHiddenItem.objects.filter(
|
||||
user=user1, content_type=exam_ct, object_id=exam.id
|
||||
).exists()
|
||||
|
||||
response_unhidden = client.get(reverse("user_scores"))
|
||||
assert response_unhidden.status_code == 200
|
||||
assert b"Test Physics Exam" in response_unhidden.content
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+42
-12
@@ -706,39 +706,69 @@ def user_scores(request, user=None):
|
||||
user = request.user
|
||||
admin = False
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import UserHiddenItem
|
||||
|
||||
show_hidden = request.GET.get("show_hidden") in ("true", "1")
|
||||
|
||||
# Fetch user's hidden items map: {content_type_id: {object_ids}}
|
||||
hidden_map = {}
|
||||
for ct_id, obj_id in UserHiddenItem.objects.filter(user=user).values_list("content_type_id", "object_id"):
|
||||
hidden_map.setdefault(ct_id, set()).add(obj_id)
|
||||
|
||||
# Filter Exam Results list (all_exams)
|
||||
exams = get_user_exams(user)
|
||||
filtered_exams = []
|
||||
for exam_type, exams_qs in exams:
|
||||
exam_model = exams_qs.model
|
||||
ct_id = ContentType.objects.get_for_model(exam_model).id
|
||||
hidden_ids = hidden_map.get(ct_id, set())
|
||||
|
||||
# case_collections = request.user.casecollection_exams.all()
|
||||
if not show_hidden:
|
||||
exams_qs = exams_qs.exclude(id__in=hidden_ids)
|
||||
|
||||
exams_list = list(exams_qs)
|
||||
for exam in exams_list:
|
||||
exam.is_hidden = exam.id in hidden_ids
|
||||
|
||||
if exams_list:
|
||||
filtered_exams.append((exam_type, exams_list))
|
||||
|
||||
# Filter Assigned Exams list (available_exams)
|
||||
available_exams = []
|
||||
|
||||
for n, t in USER_EXAM_TYPES:
|
||||
exam_rel = getattr(user, t)
|
||||
if exam_rel.exists():
|
||||
temp_exams = exam_rel.filter(exam_mode=True, archive=False).order_by("name")
|
||||
available_exams.append((n, temp_exams))
|
||||
exam_model = temp_exams.model
|
||||
ct_id = ContentType.objects.get_for_model(exam_model).id
|
||||
hidden_ids = hidden_map.get(ct_id, set())
|
||||
|
||||
# available_exams = request.user.get_cid_exams()
|
||||
if not show_hidden:
|
||||
temp_exams = temp_exams.exclude(id__in=hidden_ids)
|
||||
|
||||
temp_exams_list = list(temp_exams)
|
||||
for exam in temp_exams_list:
|
||||
exam.is_hidden = exam.id in hidden_ids
|
||||
|
||||
if temp_exams_list:
|
||||
available_exams.append((n, temp_exams_list))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"user_scores.html",
|
||||
{
|
||||
# "physics_exams": physics_exams,
|
||||
# "anatomy_exams": anatomy_exams,
|
||||
# "rapid_exams": rapid_exams,
|
||||
# "longs_exams": longs_exams,
|
||||
# "sba_exams": sba_exams,
|
||||
"all_exams": exams,
|
||||
# "passcode": passcode,
|
||||
"all_exams": filtered_exams,
|
||||
"cid_user": user,
|
||||
"available_exams": available_exams,
|
||||
"case_collections": [], # case_collections,
|
||||
"case_collections": [],
|
||||
"admin": admin,
|
||||
"show_hidden": show_hidden,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def user_marking(request):
|
||||
"""Main page for user marking overview. Uses HTMX to fetch per-app partials."""
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center" style="width:96px;height:96px;font-size:32px;">{{ user.first_name|default:user.username|slice:":1"|upper }}</div>
|
||||
</div>
|
||||
<h4 class="card-title mb-1">{{ user.get_full_name|default:user.username }}</h4>
|
||||
<p class="text-muted mb-1">@{{ user.username }}</p>
|
||||
<p class="mb-0"><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
|
||||
<p class="text-muted mb-1">@<span id="username">{{ user.username }}</span></p>
|
||||
<p class="mb-0"><a href="mailto:{{ user.email }}" id="email">{{ user.email }}</a></p>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Permission groups:</small>
|
||||
<div id="auth-groups-{{ user.pk }}" class="mt-1 d-flex flex-wrap gap-2 align-items-center">
|
||||
|
||||
+50
-11
@@ -5,7 +5,20 @@
|
||||
<div class="container py-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<h2 class="mb-1">User: {{ cid_user.username }}</h2>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h2 class="mb-0">User: {{ cid_user.username }}</h2>
|
||||
<div>
|
||||
{% if show_hidden %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="?">
|
||||
<i class="bi bi-eye-slash me-1"></i>Hide Hidden
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-info" href="?show_hidden=true">
|
||||
<i class="bi bi-eye me-1"></i>Show Hidden
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% help "Help" %}
|
||||
<div class="card mt-2">
|
||||
<div class="card-body small">
|
||||
@@ -32,18 +45,31 @@
|
||||
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
||||
<ul class="list-group">
|
||||
{% for exam in exams %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if exam.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
{{ exam }}
|
||||
{% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% endif %}
|
||||
{% if exam.publish_results %}<span class="badge bg-info text-dark ms-2">Results Published</span>{% endif %}
|
||||
{% if exam.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if exam.active %}
|
||||
<a class="btn btn-primary" href="{{ exam.get_take_url }}" target="_blank" title="Click to take exam">Start</a>
|
||||
{% endif %}
|
||||
{% if exam.publish_results %}
|
||||
<a class="btn btn-outline-secondary" href="{% url exam_type|lower|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results">Results</a>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if exam.active %}
|
||||
<a class="btn btn-primary" href="{{ exam.get_take_url }}" target="_blank" title="Click to take exam">Start</a>
|
||||
{% endif %}
|
||||
{% if exam.publish_results %}
|
||||
<a class="btn btn-outline-secondary" href="{% url exam_type|lower|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results">Results</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not admin %}
|
||||
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" value="{{ exam.content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ exam.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0 ms-1" title="{% if exam.is_hidden %}Unhide exam{% else %}Hide exam{% endif %}">
|
||||
<i class="bi {% if exam.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
@@ -66,7 +92,7 @@
|
||||
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
||||
<ul class="list-group">
|
||||
{% for exam in exams %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {% if exam.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
|
||||
<div>
|
||||
{% if admin %}
|
||||
<a href="{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}">{{ exam }}</a>
|
||||
@@ -75,8 +101,21 @@
|
||||
{% endif %}
|
||||
{% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% endif %}
|
||||
{% if exam.publish_results %}<span class="badge bg-info text-dark ms-2">Results Published</span>{% endif %}
|
||||
{% if exam.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if not admin %}
|
||||
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" value="{{ exam.content_type_id }}">
|
||||
<input type="hidden" name="object_id" value="{{ exam.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0 ms-1" title="{% if exam.is_hidden %}Unhide exam{% else %}Hide exam{% endif %}">
|
||||
<i class="bi {% if exam.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="small text-muted"> </div>
|
||||
</div>
|
||||
<div class="small text-muted"> </div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -94,7 +133,7 @@
|
||||
<p class="text-muted mb-2">Available, in-progress, and completed Atlas collections for this user.</p>
|
||||
<div
|
||||
id="atlas-collections-scores"
|
||||
hx-get="{% url 'atlas:user_collections' %}?user_id={{ cid_user.id }}"
|
||||
hx-get="{% url 'atlas:user_collections' %}?user_id={{ cid_user.id }}{% if show_hidden %}&show_hidden=1{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
Reference in New Issue
Block a user