Skip to content

Commit 727005d

Browse files
prandlat-lenzj-bmn
authored
introduce user groups (#1621, #1666)
--------- Co-authored-by: Tobias Lenz <t_lenz94@web.de> Co-authored-by: Jonathan Baumann <jonathan.baumann@mpi-sp.org>
1 parent 4486dc1 commit 727005d

35 files changed

Lines changed: 1005 additions & 206 deletions

cms/db/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# Copyright © 2016 Masaki Hara <ackie.h.gmai@gmail.com>
1111
# Copyright © 2016 Amir Keivan Mohtashami <akmohtashami97@gmail.com>
1212
# Copyright © 2018 William Di Luigi <williamdiluigi@gmail.com>
13+
# Copyright © 2026 Tobias Lenz <t_lenz94@web.de>
1314
#
1415
# This program is free software: you can redistribute it and/or modify
1516
# it under the terms of the GNU Affero General Public License as
@@ -55,7 +56,7 @@
5556
# contest
5657
"Contest", "Announcement",
5758
# user
58-
"User", "Team", "Participation", "Message", "Question",
59+
"Group", "User", "Team", "Participation", "Message", "Question",
5960
# admin
6061
"Admin",
6162
# task
@@ -79,7 +80,7 @@
7980

8081
# Instantiate or import these objects.
8182

82-
version = 47
83+
version = 48
8384

8485
engine = create_engine(config.database.url, echo=config.database.debug,
8586
pool_timeout=60, pool_recycle=120)
@@ -95,7 +96,7 @@
9596
from .fsobject import FSObject, LargeObject
9697
from .admin import Admin
9798
from .contest import Contest, Announcement
98-
from .user import User, Team, Participation, Message, Question
99+
from .user import Group, User, Team, Participation, Message, Question
99100
from .task import Task, Statement, Attachment, Dataset, Manager, Testcase
100101
from .submission import Submission, File, Token, SubmissionResult, \
101102
Executable, Evaluation

cms/db/contest.py

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
# Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
77
# Copyright © 2012-2018 Luca Wehrstedt <luca.wehrstedt@gmail.com>
88
# Copyright © 2013 Bernard Blackham <bernard@largestprime.net>
9+
# Copyright © 2015 Fabian Gundlach <320pointsguy@gmail.com>
910
# Copyright © 2016 Myungwoo Chun <mc.tamaki@gmail.com>
1011
# Copyright © 2016 Amir Keivan Mohtashami <akmohtashami97@gmail.com>
12+
# Copyright © 2017-2026 Tobias Lenz <t_lenz94@web.de>
1113
# Copyright © 2018 William Di Luigi <williamdiluigi@gmail.com>
1214
#
1315
# This program is free software: you can redistribute it and/or modify
@@ -40,7 +42,7 @@
4042
from . import Codename, Base, Admin
4143
import typing
4244
if typing.TYPE_CHECKING:
43-
from . import Task, Participation
45+
from . import Task, Participation, Group
4446

4547

4648
class Contest(Base):
@@ -50,9 +52,6 @@ class Contest(Base):
5052
"""
5153
__tablename__ = 'contests'
5254
__table_args__ = (
53-
CheckConstraint("start <= stop"),
54-
CheckConstraint("stop <= analysis_start"),
55-
CheckConstraint("analysis_start <= analysis_stop"),
5655
CheckConstraint("token_gen_initial <= token_gen_max"),
5756
)
5857

@@ -197,30 +196,6 @@ class Contest(Base):
197196
CheckConstraint("token_gen_max > 0"),
198197
nullable=True)
199198

200-
# Beginning and ending of the contest.
201-
start: datetime = Column(
202-
DateTime,
203-
nullable=False,
204-
default=datetime(2000, 1, 1))
205-
stop: datetime = Column(
206-
DateTime,
207-
nullable=False,
208-
default=datetime(2030, 1, 1))
209-
210-
# Beginning and ending of the contest anaylsis mode.
211-
analysis_enabled: bool = Column(
212-
Boolean,
213-
nullable=False,
214-
default=False)
215-
analysis_start: datetime = Column(
216-
DateTime,
217-
nullable=False,
218-
default=datetime(2030, 1, 1))
219-
analysis_stop: datetime = Column(
220-
DateTime,
221-
nullable=False,
222-
default=datetime(2030, 1, 1))
223-
224199
# Timezone for the contest. All timestamps in CWS will be shown
225200
# using the timezone associated to the logged-in user or (if it's
226201
# None or an invalid string) the timezone associated to the
@@ -271,8 +246,26 @@ class Contest(Base):
271246
nullable=False,
272247
default=0)
273248

249+
# Main group (id and Group object) of this contest
250+
main_group_id: int | None = Column(
251+
Integer,
252+
ForeignKey("groups.id", use_alter=True, name="fk_contest_main_group_id",
253+
onupdate="CASCADE", ondelete="SET NULL"),
254+
nullable=True,
255+
index=True)
256+
main_group: "Group | None" = relationship(
257+
"Group",
258+
primaryjoin="Group.id==Contest.main_group_id",
259+
post_update=True)
260+
274261
# These one-to-many relationships are the reversed directions of
275262
# the ones defined in the "child" classes using foreign keys.
263+
groups : list["Group"] = relationship(
264+
"Group",
265+
foreign_keys="[Group.contest_id]",
266+
cascade="all, delete-orphan",
267+
passive_deletes=True,
268+
back_populates="contest")
276269

277270
tasks: list["Task"] = relationship(
278271
"Task",
@@ -295,29 +288,6 @@ class Contest(Base):
295288
passive_deletes=True,
296289
back_populates="contest")
297290

298-
def phase(self, timestamp: datetime) -> int:
299-
"""Return: -1 if contest isn't started yet at time timestamp,
300-
0 if the contest is active at time timestamp,
301-
1 if the contest has ended but analysis mode
302-
hasn't started yet
303-
2 if the contest has ended and analysis mode is active
304-
3 if the contest has ended and analysis mode is disabled or
305-
has ended
306-
307-
timestamp: the time we are iterested in.
308-
"""
309-
# NOTE: this logic is duplicated in aws_utils.js.
310-
if timestamp < self.start:
311-
return -1
312-
if timestamp <= self.stop:
313-
return 0
314-
if self.analysis_enabled:
315-
if timestamp < self.analysis_start:
316-
return 1
317-
elif timestamp <= self.analysis_stop:
318-
return 2
319-
return 3
320-
321291

322292
class Announcement(Base):
323293
"""Class to store a messages sent by the contest managers to all

cms/db/user.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
# Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
77
# Copyright © 2012-2018 Luca Wehrstedt <luca.wehrstedt@gmail.com>
88
# Copyright © 2015 William Di Luigi <williamdiluigi@gmail.com>
9+
# Copyright © 2015 Fabian Gundlach <320pointsguy@gmail.com>
910
# Copyright © 2016 Myungwoo Chun <mc.tamaki@gmail.com>
11+
# Copyright © 2017-2026 Tobias Lenz <t_lenz94@web.de>
12+
# Copyright © 2021 Manuel Gundlach <manuel.gundlach@gmail.com>
1013
#
1114
# This program is free software: you can redistribute it and/or modify
1215
# it under the terms of the GNU Affero General Public License as
@@ -31,7 +34,7 @@
3134
from sqlalchemy.dialects.postgresql import ARRAY, CIDR
3235
from sqlalchemy.orm import relationship
3336
from sqlalchemy.schema import Column, ForeignKey, CheckConstraint, \
34-
UniqueConstraint
37+
UniqueConstraint, ForeignKeyConstraint
3538
from sqlalchemy.types import Boolean, Integer, String, Unicode, DateTime, \
3639
Interval
3740

@@ -41,6 +44,102 @@
4144
if typing.TYPE_CHECKING:
4245
from . import Submission, UserTest
4346

47+
48+
class Group(Base):
49+
"""Class to store a group of users (for timing, etc.).
50+
51+
"""
52+
__tablename__ = 'groups'
53+
__table_args__ = (
54+
UniqueConstraint('contest_id', 'name'),
55+
UniqueConstraint('id', 'contest_id'),
56+
CheckConstraint("start <= stop"),
57+
CheckConstraint("stop <= analysis_start"),
58+
CheckConstraint("analysis_start <= analysis_stop"),
59+
)
60+
61+
# Auto increment primary key.
62+
id: int = Column(
63+
Integer,
64+
primary_key=True)
65+
66+
name: str = Column(
67+
Unicode,
68+
nullable=False)
69+
70+
# Beginning and ending of the contest.
71+
start: datetime = Column(
72+
DateTime,
73+
nullable=False,
74+
default=datetime(2000, 1, 1))
75+
stop: datetime = Column(
76+
DateTime,
77+
nullable=False,
78+
default=datetime(2100, 1, 1))
79+
80+
# Beginning and ending of the analysis mode for this group.
81+
analysis_enabled: bool = Column(
82+
Boolean,
83+
nullable=False,
84+
default=False)
85+
analysis_start: datetime = Column(
86+
DateTime,
87+
nullable=False,
88+
default=datetime(2100, 1, 1))
89+
analysis_stop: datetime = Column(
90+
DateTime,
91+
nullable=False,
92+
default=datetime(2100, 1, 1))
93+
94+
# Max contest time for each user in seconds.
95+
per_user_time: timedelta | None = Column(
96+
Interval,
97+
CheckConstraint("per_user_time >= '0 seconds'"),
98+
nullable=True)
99+
100+
# Contest (id and object) to which this user group belongs.
101+
contest_id: int = Column(
102+
Integer,
103+
ForeignKey(Contest.id,
104+
onupdate="CASCADE", ondelete="CASCADE"),
105+
nullable=False,
106+
index=True)
107+
contest: Contest = relationship(
108+
Contest,
109+
foreign_keys=[contest_id],
110+
back_populates="groups")
111+
112+
def phase(self, timestamp: datetime) -> int:
113+
"""Return: -1 if contest isn't started yet at time timestamp,
114+
0 if the contest is active at time timestamp,
115+
1 if the contest has ended but analysis mode
116+
hasn't started yet
117+
2 if the contest has ended and analysis mode is active
118+
3 if the contest has ended and analysis mode is disabled or
119+
has ended
120+
121+
timestamp: the time we are iterested in.
122+
"""
123+
# NOTE: this logic is duplicated in aws_utils.js.
124+
if timestamp < self.start:
125+
return -1
126+
if timestamp <= self.stop:
127+
return 0
128+
if self.analysis_enabled:
129+
if timestamp < self.analysis_start:
130+
return 1
131+
elif timestamp <= self.analysis_stop:
132+
return 2
133+
return 3
134+
135+
participations: list["Participation"] = relationship(
136+
"Participation",
137+
cascade="all, delete-orphan",
138+
passive_deletes=True,
139+
foreign_keys="[Participation.group_id]",
140+
back_populates="group")
141+
142+
44143
class User(Base):
45144
"""Class to store a user.
46145
@@ -149,6 +248,12 @@ class Participation(Base):
149248
150249
"""
151250
__tablename__ = 'participations'
251+
__table_args__ = (
252+
ForeignKeyConstraint(
253+
("group_id", "contest_id"),
254+
(Group.id, Group.contest_id)),
255+
UniqueConstraint("contest_id", "user_id"),
256+
)
152257

153258
# Auto increment primary key.
154259
id: int = Column(
@@ -229,7 +334,18 @@ class Participation(Base):
229334
user: User = relationship(
230335
User,
231336
back_populates="participations")
232-
__table_args__ = (UniqueConstraint("contest_id", "user_id"),)
337+
338+
# Group this user belongs to
339+
group_id: int = Column(
340+
Integer,
341+
ForeignKey(Group.id,
342+
onupdate="CASCADE", ondelete="CASCADE"),
343+
nullable=False,
344+
index=True)
345+
group: Group = relationship(
346+
Group,
347+
foreign_keys=[group_id],
348+
back_populates="participations")
233349

234350
# Team (id and object) that the user is representing with this
235351
# participation.

cms/server/admin/handlers/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@
101101
AddTeamHandler, \
102102
TeamHandler, \
103103
TeamListHandler, \
104-
RemoveTeamHandler
104+
RemoveTeamHandler, \
105+
GroupListHandler, \
106+
AddGroupHandler, \
107+
GroupHandler
105108
from .usertest import \
106109
UserTestHandler, \
107110
UserTestFileHandler
@@ -136,6 +139,12 @@
136139
(r"/contest/([0-9]+)/user/([0-9]+)/edit", ParticipationHandler),
137140
(r"/contest/([0-9]+)/user/([0-9]+)/message", MessageHandler),
138141

142+
# Contest's groups
143+
144+
(r"/contest/([0-9]+)/groups", GroupListHandler),
145+
(r"/contest/([0-9]+)/groups/add", AddGroupHandler),
146+
(r"/contest/([0-9]+)/group/([0-9]+)/edit", GroupHandler),
147+
139148
# Contest's tasks
140149

141150
(r"/contest/([0-9]+)/tasks", ContestTasksHandler),

cms/server/admin/handlers/base.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
from cms import __version__, config
5454
from cms.db import Admin, Contest, Participation, Question, Submission, \
55-
SubmissionResult, Task, Team, User, UserTest
55+
SubmissionResult, Task, Team, User, UserTest, Group
5656
import cms.db
5757
from cms.grading.scoretypes import get_score_type_class
5858
from cms.grading.tasktypes import get_task_type_class
@@ -659,6 +659,18 @@ def get_login_url(self) -> str:
659659
"""
660660
return self.url("login")
661661

662+
def get_group_settings(self, g: Group):
663+
attrs = dict()
664+
self.get_datetime(attrs, "start")
665+
assert attrs.get("start") is not None, "No main group start time specified."
666+
self.get_datetime(attrs, "stop")
667+
assert attrs.get("stop") is not None, "No main group stop time specified."
668+
self.get_timedelta_sec(attrs, "per_user_time")
669+
670+
self.get_bool(attrs, "analysis_enabled")
671+
self.get_datetime(attrs, "analysis_start")
672+
self.get_datetime(attrs, "analysis_stop")
673+
g.set_attrs(attrs)
662674

663675
class FileHandler(BaseHandler, FileHandlerMixin):
664676
pass

0 commit comments

Comments
 (0)