Skip to content

Commit 2b18323

Browse files
author
Nils Bars
committed
Introduce student groups with predefined name lists and group-aware grading
Adds a GroupNameList model for admin-curated name pools, a System -> Group Names page to manage them, and two system settings (GROUPS_ENABLED, GROUP_SIZE) that gate the feature. When enabled, student registration offers a datalist of names from enabled lists with current occupancy (n/k), creating or joining a UserGroup under a row lock with a capacity check. Admins can also assign users to groups from the edit page, including names from enabled lists. Grading switches to one submission per group via Exercise.submission_heads_by_group[_global](), falling back to per-user behavior for ungrouped users. Seeds Raid and Fuzzing name lists (32 each) via an Alembic migration. Refs #14
1 parent 9a317be commit 2b18323

21 files changed

Lines changed: 873 additions & 8 deletions
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""
2+
Integration Tests: Groups feature.
3+
4+
Tests the end-to-end groups behavior using remote_exec: creating a
5+
GroupNameList, enabling it, registering students that join/create
6+
UserGroup rows via UserManager, and enforcing max_group_size.
7+
8+
Covers:
9+
- GroupNameList is persisted with names and enabled_for_registration.
10+
- UserManager.create_student(group=...) attaches the user to the group.
11+
- submission_heads_by_group() buckets submissions per group.
12+
13+
Tests never touch DB models directly for writes — they go through
14+
UserManager like the view does.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import uuid
20+
from typing import TYPE_CHECKING, Any
21+
22+
import pytest
23+
24+
if TYPE_CHECKING:
25+
from helpers.ref_instance import REFInstance
26+
27+
28+
def _make_mat_num() -> str:
29+
return str(uuid.uuid4().int)[:8]
30+
31+
32+
def _setup_list_and_users(
33+
ref_instance: "REFInstance",
34+
list_name: str,
35+
group_name: str,
36+
mat_nums: list[str],
37+
group_size: int,
38+
) -> dict[str, Any]:
39+
"""Create a GroupNameList, set group settings, and register students that
40+
all pick the same group name. Returns a dict describing the final state.
41+
"""
42+
43+
def _do() -> dict[str, Any]:
44+
from flask import current_app
45+
46+
from ref.core.user import UserManager
47+
from ref.model import GroupNameList, SystemSettingsManager, UserGroup
48+
49+
SystemSettingsManager.GROUPS_ENABLED.value = True
50+
SystemSettingsManager.GROUP_SIZE.value = group_size
51+
52+
lst = GroupNameList()
53+
lst.name = list_name
54+
lst.enabled_for_registration = True
55+
lst.names = [group_name, "Other Name"]
56+
current_app.db.session.add(lst)
57+
current_app.db.session.flush()
58+
list_id = lst.id
59+
60+
created = []
61+
rejected = 0
62+
for mat_num in mat_nums:
63+
group = (
64+
UserGroup.query.filter(UserGroup.name == group_name)
65+
.with_for_update()
66+
.one_or_none()
67+
)
68+
if group is None:
69+
group = UserGroup()
70+
group.name = group_name
71+
group.source_list_id = list_id
72+
current_app.db.session.add(group)
73+
current_app.db.session.flush()
74+
elif len(group.users) >= SystemSettingsManager.GROUP_SIZE.value:
75+
rejected += 1
76+
continue
77+
78+
user = UserManager.create_student(
79+
mat_num=mat_num,
80+
first_name="Test",
81+
surname=mat_num,
82+
password="TestPassword123!",
83+
pub_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ+dummy",
84+
group=group,
85+
)
86+
current_app.db.session.add(user)
87+
current_app.db.session.commit()
88+
created.append(mat_num)
89+
90+
group = UserGroup.query.filter(UserGroup.name == group_name).one()
91+
return {
92+
"list_id": list_id,
93+
"group_id": group.id,
94+
"group_name": group.name,
95+
"source_list_id": group.source_list_id,
96+
"group_member_count": len(group.users),
97+
"created": created,
98+
"rejected": rejected,
99+
}
100+
101+
return ref_instance.remote_exec(_do)
102+
103+
104+
def _teardown(ref_instance: "REFInstance", mat_nums: list[str], list_name: str) -> None:
105+
def _do() -> bool:
106+
from flask import current_app
107+
108+
from ref.core.user import UserManager
109+
from ref.model import GroupNameList, SystemSettingsManager, UserGroup
110+
from ref.model.user import User
111+
112+
for mat in mat_nums:
113+
user = User.query.filter(User.mat_num == mat).one_or_none()
114+
if user is not None:
115+
UserManager.delete_with_instances(user)
116+
117+
for g in UserGroup.query.all():
118+
if not g.users:
119+
current_app.db.session.delete(g)
120+
121+
lst = GroupNameList.query.filter(GroupNameList.name == list_name).one_or_none()
122+
if lst is not None:
123+
current_app.db.session.delete(lst)
124+
125+
SystemSettingsManager.GROUPS_ENABLED.value = False
126+
SystemSettingsManager.GROUP_SIZE.value = 1
127+
current_app.db.session.commit()
128+
return True
129+
130+
ref_instance.remote_exec(_do)
131+
132+
133+
class TestGroupRegistration:
134+
@pytest.mark.integration
135+
def test_join_and_cap(
136+
self,
137+
ref_instance: "REFInstance",
138+
):
139+
"""
140+
With GROUP_SIZE=2, two users can join the same group; a third is
141+
rejected.
142+
"""
143+
mat_nums = [_make_mat_num() for _ in range(3)]
144+
list_name = f"TestList-{mat_nums[0]}"
145+
group_name = f"TestGroup-{mat_nums[0]}"
146+
147+
try:
148+
result = _setup_list_and_users(
149+
ref_instance,
150+
list_name=list_name,
151+
group_name=group_name,
152+
mat_nums=mat_nums,
153+
group_size=2,
154+
)
155+
156+
assert result["group_member_count"] == 2
157+
assert len(result["created"]) == 2
158+
assert result["rejected"] == 1
159+
assert result["source_list_id"] == result["list_id"]
160+
finally:
161+
_teardown(ref_instance, mat_nums, list_name)

tests/unit/test_groups_logic.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Unit Tests: group-aware helpers on the Exercise model.
3+
4+
These test the pure-Python helpers introduced for group-based grading,
5+
without touching the database.
6+
"""
7+
8+
from types import SimpleNamespace
9+
10+
import pytest
11+
12+
from ref.model.exercise import Exercise
13+
14+
15+
def _user(user_id: int, group_id: int | None) -> SimpleNamespace:
16+
return SimpleNamespace(id=user_id, group_id=group_id)
17+
18+
19+
@pytest.mark.offline
20+
class TestGroupKey:
21+
def test_user_with_group_uses_group_bucket(self):
22+
key = Exercise._group_key(_user(user_id=1, group_id=42))
23+
assert key == ("g", 42)
24+
25+
def test_user_without_group_uses_user_bucket(self):
26+
key = Exercise._group_key(_user(user_id=7, group_id=None))
27+
assert key == ("u", 7)
28+
29+
def test_two_users_same_group_share_bucket(self):
30+
a = Exercise._group_key(_user(user_id=1, group_id=5))
31+
b = Exercise._group_key(_user(user_id=2, group_id=5))
32+
assert a == b
33+
34+
def test_two_users_different_groups_distinct_buckets(self):
35+
a = Exercise._group_key(_user(user_id=1, group_id=5))
36+
b = Exercise._group_key(_user(user_id=2, group_id=6))
37+
assert a != b
38+
39+
def test_two_ungrouped_users_have_distinct_buckets(self):
40+
a = Exercise._group_key(_user(user_id=1, group_id=None))
41+
b = Exercise._group_key(_user(user_id=2, group_id=None))
42+
assert a != b
43+
44+
def test_ungrouped_user_not_confused_with_grouped_same_id(self):
45+
grouped = Exercise._group_key(_user(user_id=3, group_id=3))
46+
ungrouped = Exercise._group_key(_user(user_id=3, group_id=None))
47+
assert grouped != ungrouped
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Add group_name_list and UserGroup.source_list_id, seed predefined lists
2+
3+
Introduces the GroupNameList model used by the System -> Group Names admin page
4+
and the student registration group selector. Adds an optional source_list_id
5+
FK on user_group referencing the predefined list a group was created from.
6+
Seeds two predefined lists (Raid, Fuzzing) that admins can enable for
7+
registration.
8+
9+
Revision ID: c3f5a7d9e1b4
10+
Revises: b2e4f6a8c0d2
11+
Create Date: 2026-04-13
12+
13+
"""
14+
15+
import sqlalchemy as sa
16+
from alembic import op
17+
18+
19+
revision = "c3f5a7d9e1b4"
20+
down_revision = "b2e4f6a8c0d2"
21+
branch_labels = None
22+
depends_on = None
23+
24+
25+
RAID_NAMES = [
26+
"Backprop Bandits (BAB)",
27+
"Botnet Busters (BOB)",
28+
"Debug Dingos (DED)",
29+
"Hackintosh Heros (HAH)",
30+
"Neural Ninjas (NEN)",
31+
"Sigmoid Sniffers (SIS)",
32+
"Adversarial Apes (ADA)",
33+
"Binary Beavers (BIB)",
34+
"Crypto Crows (CRC)",
35+
"Dropout Dragons (DRD)",
36+
"Entropy Eagles (ENE)",
37+
"Firewall Foxes (FIF)",
38+
"Gradient Gorillas (GRG)",
39+
"Hashing Hornets (HAS)",
40+
"Inference Iguanas (INI)",
41+
"Jailbreak Jackals (JAJ)",
42+
"Kernel Koalas (KEK)",
43+
"Logits Lemurs (LOL)",
44+
"Malware Mongoose (MAM)",
45+
"Nonce Nightjars (NON)",
46+
"Overflow Owls (OVO)",
47+
"Payload Pandas (PAP)",
48+
"Quantum Quolls (QUQ)",
49+
"Recurrent Ravens (RER)",
50+
"Softmax Sharks (SOS)",
51+
"Tensor Tigers (TET)",
52+
"Unicode Unicorns (UNU)",
53+
"Vector Vipers (VEV)",
54+
"Weights Wolves (WEW)",
55+
"XOR Xerus (XOX)",
56+
"Yottabyte Yaks (YOY)",
57+
"ZeroDay Zebras (ZEZ)",
58+
]
59+
60+
61+
FUZZING_NAMES = [
62+
"AFL Assassins",
63+
"Angora Antelopes",
64+
"Bitflip Badgers",
65+
"Boofuzz Bears",
66+
"CmpLog Cheetahs",
67+
"Corpus Crusaders",
68+
"Dharma Dragons",
69+
"Driller Dolphins",
70+
"Eclipser Eagles",
71+
"Entropy Elephants",
72+
"FairFuzz Ferrets",
73+
"Fuzzer Falcons",
74+
"Grammar Griffins",
75+
"Grimoire Gazelles",
76+
"Harness Hawks",
77+
"Honggfuzz Hyenas",
78+
"Instrumentation Impalas",
79+
"Jazzer Jaguars",
80+
"KLEE Koalas",
81+
"LibFuzzer Lions",
82+
"Mutation Mantis",
83+
"NAUTILUS Narwhals",
84+
"Oracle Owls",
85+
"PeachPit Pythons",
86+
"Queue Quokkas",
87+
"Radamsa Ravens",
88+
"Sanitizer Sharks",
89+
"Syzkaller Sparrows",
90+
"Taint Tigers",
91+
"Unicorn Ocelots",
92+
"Weizz Wolves",
93+
"Zzuf Zebras",
94+
]
95+
96+
97+
def upgrade():
98+
op.create_table(
99+
"group_name_list",
100+
sa.Column("id", sa.Integer(), primary_key=True),
101+
sa.Column("name", sa.Text(), nullable=False, unique=True),
102+
sa.Column(
103+
"enabled_for_registration",
104+
sa.Boolean(),
105+
nullable=False,
106+
server_default=sa.false(),
107+
),
108+
sa.Column("names", sa.PickleType(), nullable=False),
109+
)
110+
111+
op.add_column(
112+
"user_group",
113+
sa.Column("source_list_id", sa.Integer(), nullable=True),
114+
)
115+
op.create_foreign_key(
116+
"fk_user_group_source_list_id",
117+
"user_group",
118+
"group_name_list",
119+
["source_list_id"],
120+
["id"],
121+
)
122+
123+
group_name_list = sa.table(
124+
"group_name_list",
125+
sa.column("name", sa.Text),
126+
sa.column("enabled_for_registration", sa.Boolean),
127+
sa.column("names", sa.PickleType),
128+
)
129+
130+
op.bulk_insert(
131+
group_name_list,
132+
[
133+
{
134+
"name": "Raid",
135+
"enabled_for_registration": False,
136+
"names": RAID_NAMES,
137+
},
138+
{
139+
"name": "Fuzzing",
140+
"enabled_for_registration": False,
141+
"names": FUZZING_NAMES,
142+
},
143+
],
144+
)
145+
146+
147+
def downgrade():
148+
op.drop_constraint("fk_user_group_source_list_id", "user_group", type_="foreignkey")
149+
op.drop_column("user_group", "source_list_id")
150+
op.drop_table("group_name_list")

webapp/ref/core/user.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from flask import current_app
66

77
from ref.model.enums import UserAuthorizationGroups
8-
from ref.model.user import User
8+
from ref.model.user import User, UserGroup
99

1010
from .instance import InstanceManager
1111

@@ -23,6 +23,7 @@ def create_student(
2323
password: str,
2424
pub_key: str | None = None,
2525
priv_key: str | None = None,
26+
group: UserGroup | None = None,
2627
) -> User:
2728
"""
2829
Create a new student user.
@@ -36,6 +37,7 @@ def create_student(
3637
password: Plain-text password (will be hashed)
3738
pub_key: Optional SSH public key
3839
priv_key: Optional SSH private key
40+
group: Optional UserGroup to attach the new user to
3941
4042
Returns:
4143
The created User object (not yet in session)
@@ -49,6 +51,8 @@ def create_student(
4951
user.priv_key = priv_key
5052
user.registered_date = datetime.datetime.utcnow()
5153
user.auth_groups = [UserAuthorizationGroups.STUDENT]
54+
if group is not None:
55+
user.group = group
5256
return user
5357

5458
@staticmethod

0 commit comments

Comments
 (0)