Implement hiding and showing functionality for user exams and collections
This commit is contained in:
@@ -98,13 +98,39 @@ Instead
|
|||||||
|
|
||||||
## Docker (Dev)
|
## Docker (Dev)
|
||||||
|
|
||||||
|
### Start Dev Environment
|
||||||
|
Start the development server with local configurations:
|
||||||
```bash
|
```bash
|
||||||
# Start dev environment
|
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d --build
|
||||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
|
```
|
||||||
|
|
||||||
# Run management commands inside the container
|
### Run Django Management Commands inside the Container
|
||||||
docker compose exec web python manage.py migrate
|
Since PostgreSQL and Redis run in docker, run all database/management commands within the web container:
|
||||||
docker compose exec web python manage.py createsuperuser
|
```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).
|
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
|
||||||
|
|||||||
@@ -5,40 +5,73 @@
|
|||||||
<h5 class="card-title">Your collections</h5>
|
<h5 class="card-title">Your collections</h5>
|
||||||
|
|
||||||
<h6 class="mt-3">Available to start</h6>
|
<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 %}
|
{% for collection in available_collections %}
|
||||||
<li>
|
<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 %}">
|
||||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
<div>
|
||||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
<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>
|
</li>
|
||||||
{% empty %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h6 class="mt-3">In progress</h6>
|
<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 %}
|
{% for collection in in_progress_collections %}
|
||||||
<li>
|
<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 %}">
|
||||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
<div>
|
||||||
<span class="badge bg-warning text-dark ms-2">In progress</span>
|
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
<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>
|
</li>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<li class="text-muted">No in-progress collections.</li>
|
<li class="list-group-item text-muted">No in-progress collections.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h6 class="mt-3">Finished</h6>
|
<h6 class="mt-3">Finished</h6>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-group list-group-sm">
|
||||||
{% for collection in finished_collections %}
|
{% for collection in finished_collections %}
|
||||||
<li>
|
<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 %}">
|
||||||
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
<div>
|
||||||
<span class="badge bg-success ms-2">Completed</span>
|
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
|
||||||
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
|
<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>
|
</li>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<li class="text-muted">No finished collections.</li>
|
<li class="list-group-item text-muted">No finished collections.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -112,6 +112,7 @@ from .forms import (
|
|||||||
UserQuestionAnswerForm,
|
UserQuestionAnswerForm,
|
||||||
UserReportAnswerForm,
|
UserReportAnswerForm,
|
||||||
)
|
)
|
||||||
|
from generic.models import UserHiddenItem
|
||||||
from .models import (
|
from .models import (
|
||||||
Case,
|
Case,
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
@@ -2786,11 +2787,32 @@ def user_collections(request):
|
|||||||
# Finished (completed)
|
# Finished (completed)
|
||||||
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
|
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 = {
|
context = {
|
||||||
"target_user": target_user,
|
"target_user": target_user,
|
||||||
"available_collections": available_qs,
|
"available_collections": available_list,
|
||||||
"in_progress_collections": in_progress_qs,
|
"in_progress_collections": in_progress_list,
|
||||||
"finished_collections": finished_qs,
|
"finished_collections": finished_list,
|
||||||
|
"show_hidden": show_hidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If called via HTMX return a small fragment suitable for swapping
|
# 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,
|
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(
|
authors_only = models.BooleanField(
|
||||||
help_text="If true only exam/collection authors will be able to view.",
|
help_text="If true only exam/collection authors will be able to view.",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -2390,3 +2396,28 @@ class FindingBase(models.Model):
|
|||||||
return get_pretty_json(json.loads(self.viewport_json))
|
return get_pretty_json(json.loads(self.viewport_json))
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
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
|
# User content review views
|
||||||
path("user-content/", views.user_content_list, name="user_content_list"),
|
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("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/", views.ExaminationView.as_view(), name="examination_view"),
|
||||||
path("examination/<int:pk>", views.examination_detail, name="examination_detail"),
|
path("examination/<int:pk>", views.examination_detail, name="examination_detail"),
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -7114,4 +7114,43 @@ def user_content_review(request, user_id):
|
|||||||
# Render HTMX partial (or full page if not HTMX)
|
# Render HTMX partial (or full page if not HTMX)
|
||||||
template = "generic/partials/user_content_review.html"
|
template = "generic/partials/user_content_review.html"
|
||||||
return render(request, template, context)
|
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
|
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
|
user = request.user
|
||||||
admin = False
|
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)
|
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 = []
|
available_exams = []
|
||||||
|
|
||||||
for n, t in USER_EXAM_TYPES:
|
for n, t in USER_EXAM_TYPES:
|
||||||
exam_rel = getattr(user, t)
|
exam_rel = getattr(user, t)
|
||||||
if exam_rel.exists():
|
if exam_rel.exists():
|
||||||
temp_exams = exam_rel.filter(exam_mode=True, archive=False).order_by("name")
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"user_scores.html",
|
"user_scores.html",
|
||||||
{
|
{
|
||||||
# "physics_exams": physics_exams,
|
"all_exams": filtered_exams,
|
||||||
# "anatomy_exams": anatomy_exams,
|
|
||||||
# "rapid_exams": rapid_exams,
|
|
||||||
# "longs_exams": longs_exams,
|
|
||||||
# "sba_exams": sba_exams,
|
|
||||||
"all_exams": exams,
|
|
||||||
# "passcode": passcode,
|
|
||||||
"cid_user": user,
|
"cid_user": user,
|
||||||
"available_exams": available_exams,
|
"available_exams": available_exams,
|
||||||
"case_collections": [], # case_collections,
|
"case_collections": [],
|
||||||
"admin": admin,
|
"admin": admin,
|
||||||
|
"show_hidden": show_hidden,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def user_marking(request):
|
def user_marking(request):
|
||||||
"""Main page for user marking overview. Uses HTMX to fetch per-app partials."""
|
"""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 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>
|
</div>
|
||||||
<h4 class="card-title mb-1">{{ user.get_full_name|default:user.username }}</h4>
|
<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="text-muted mb-1">@<span id="username">{{ user.username }}</span></p>
|
||||||
<p class="mb-0"><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
|
<p class="mb-0"><a href="mailto:{{ user.email }}" id="email">{{ user.email }}</a></p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<small class="text-muted">Permission groups:</small>
|
<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">
|
<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="container py-4">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-8">
|
<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" %}
|
{% help "Help" %}
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
<div class="card-body small">
|
<div class="card-body small">
|
||||||
@@ -32,18 +45,31 @@
|
|||||||
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
{% for exam in exams %}
|
{% 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>
|
<div>
|
||||||
{{ exam }}
|
{{ exam }}
|
||||||
{% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% 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.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>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% if exam.active %}
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<a class="btn btn-primary" href="{{ exam.get_take_url }}" target="_blank" title="Click to take exam">Start</a>
|
{% if exam.active %}
|
||||||
{% endif %}
|
<a class="btn btn-primary" href="{{ exam.get_take_url }}" target="_blank" title="Click to take exam">Start</a>
|
||||||
{% if exam.publish_results %}
|
{% endif %}
|
||||||
<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>
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -66,7 +92,7 @@
|
|||||||
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
<h6 class="mt-3">{{ exam_type|title }}</h6>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
{% for exam in exams %}
|
{% 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>
|
<div>
|
||||||
{% if admin %}
|
{% if admin %}
|
||||||
<a href="{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}">{{ exam }}</a>
|
<a href="{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}">{{ exam }}</a>
|
||||||
@@ -75,8 +101,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% 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.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>
|
||||||
<div class="small text-muted"> </div>
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -94,7 +133,7 @@
|
|||||||
<p class="text-muted mb-2">Available, in-progress, and completed Atlas collections for this user.</p>
|
<p class="text-muted mb-2">Available, in-progress, and completed Atlas collections for this user.</p>
|
||||||
<div
|
<div
|
||||||
id="atlas-collections-scores"
|
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-trigger="load"
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
|
|||||||
Reference in New Issue
Block a user