diff --git a/cms/db/__init__.py b/cms/db/__init__.py index 5af0da3945..74f0191d67 100644 --- a/cms/db/__init__.py +++ b/cms/db/__init__.py @@ -10,6 +10,7 @@ # Copyright © 2016 Masaki Hara # Copyright © 2016 Amir Keivan Mohtashami # Copyright © 2018 William Di Luigi +# Copyright © 2026 Tobias Lenz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -55,7 +56,7 @@ # contest "Contest", "Announcement", # user - "User", "Team", "Participation", "Message", "Question", + "Group", "User", "Team", "Participation", "Message", "Question", # admin "Admin", # task @@ -95,7 +96,7 @@ from .fsobject import FSObject, LargeObject from .admin import Admin from .contest import Contest, Announcement -from .user import User, Team, Participation, Message, Question +from .user import Group, User, Team, Participation, Message, Question from .task import Task, Statement, Attachment, Dataset, Manager, Testcase from .submission import Submission, File, Token, SubmissionResult, \ Executable, Evaluation diff --git a/cms/db/contest.py b/cms/db/contest.py index 13c84c263c..2b971d4a0a 100644 --- a/cms/db/contest.py +++ b/cms/db/contest.py @@ -6,8 +6,10 @@ # Copyright © 2010-2012 Matteo Boscariol # Copyright © 2012-2018 Luca Wehrstedt # Copyright © 2013 Bernard Blackham +# Copyright © 2015 Fabian Gundlach <320pointsguy@gmail.com> # Copyright © 2016 Myungwoo Chun # Copyright © 2016 Amir Keivan Mohtashami +# Copyright © 2017-2026 Tobias Lenz # Copyright © 2018 William Di Luigi # # This program is free software: you can redistribute it and/or modify @@ -50,9 +52,6 @@ class Contest(Base): """ __tablename__ = 'contests' __table_args__ = ( - CheckConstraint("start <= stop"), - CheckConstraint("stop <= analysis_start"), - CheckConstraint("analysis_start <= analysis_stop"), CheckConstraint("token_gen_initial <= token_gen_max"), ) @@ -197,30 +196,6 @@ class Contest(Base): CheckConstraint("token_gen_max > 0"), nullable=True) - # Beginning and ending of the contest. - start: datetime = Column( - DateTime, - nullable=False, - default=datetime(2000, 1, 1)) - stop: datetime = Column( - DateTime, - nullable=False, - default=datetime(2030, 1, 1)) - - # Beginning and ending of the contest anaylsis mode. - analysis_enabled: bool = Column( - Boolean, - nullable=False, - default=False) - analysis_start: datetime = Column( - DateTime, - nullable=False, - default=datetime(2030, 1, 1)) - analysis_stop: datetime = Column( - DateTime, - nullable=False, - default=datetime(2030, 1, 1)) - # Timezone for the contest. All timestamps in CWS will be shown # using the timezone associated to the logged-in user or (if it's # None or an invalid string) the timezone associated to the @@ -271,6 +246,20 @@ class Contest(Base): nullable=False, default=0) + # Main group (id and Group object) of this contest + main_group_id: int = Column( + Integer, + ForeignKey("group.id", use_alter=True, name="fk_contest_main_group_id", + onupdate="CASCADE", ondelete="SET NULL"), + index=True) + main_group = relationship( + "Group", + primaryjoin="Group.id==Contest.main_group_id", + post_update=True) + + # Follows the description of the fields automatically added by + # SQLAlchemy. + # groups (list of Group objects) # These one-to-many relationships are the reversed directions of # the ones defined in the "child" classes using foreign keys. @@ -295,29 +284,6 @@ class Contest(Base): passive_deletes=True, back_populates="contest") - def phase(self, timestamp: datetime) -> int: - """Return: -1 if contest isn't started yet at time timestamp, - 0 if the contest is active at time timestamp, - 1 if the contest has ended but analysis mode - hasn't started yet - 2 if the contest has ended and analysis mode is active - 3 if the contest has ended and analysis mode is disabled or - has ended - - timestamp: the time we are iterested in. - """ - # NOTE: this logic is duplicated in aws_utils.js. - if timestamp < self.start: - return -1 - if timestamp <= self.stop: - return 0 - if self.analysis_enabled: - if timestamp < self.analysis_start: - return 1 - elif timestamp <= self.analysis_stop: - return 2 - return 3 - class Announcement(Base): """Class to store a messages sent by the contest managers to all diff --git a/cms/db/user.py b/cms/db/user.py index e60de859a8..59189347c4 100644 --- a/cms/db/user.py +++ b/cms/db/user.py @@ -6,7 +6,10 @@ # Copyright © 2010-2012 Matteo Boscariol # Copyright © 2012-2018 Luca Wehrstedt # Copyright © 2015 William Di Luigi +# Copyright © 2015 Fabian Gundlach <320pointsguy@gmail.com> # Copyright © 2016 Myungwoo Chun +# Copyright © 2017-2026 Tobias Lenz +# Copyright © 2021 Manuel Gundlach # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -29,7 +32,7 @@ from ipaddress import IPv4Network, IPv6Network from sqlalchemy.dialects.postgresql import ARRAY, CIDR -from sqlalchemy.orm import relationship +from sqlalchemy.orm import backref, relationship from sqlalchemy.schema import Column, ForeignKey, CheckConstraint, \ UniqueConstraint from sqlalchemy.types import Boolean, Integer, String, Unicode, DateTime, \ @@ -41,6 +44,101 @@ if typing.TYPE_CHECKING: from . import Submission, UserTest + +class Group(Base): + """Class to store a group of users (for timing, etc.). + + """ + __tablename__ = 'group' + __table_args__ = ( + UniqueConstraint('contest_id', 'name'), + CheckConstraint("start <= stop"), + CheckConstraint("stop <= analysis_start"), + CheckConstraint("analysis_start <= analysis_stop"), + ) + + # Auto increment primary key. + id: int = Column( + Integer, + primary_key=True) + + name: str = Column( + Unicode, + nullable=False) + + # Beginning and ending of the contest. + start: datetime = Column( + DateTime, + nullable=False, + default=datetime(2000, 1, 1)) + stop: datetime = Column( + DateTime, + nullable=False, + default=datetime(2100, 1, 1)) + + # Beginning and ending of the analysis mode for this group. + analysis_enabled: bool = Column( + Boolean, + nullable=False, + default=False) + analysis_start: datetime = Column( + DateTime, + nullable=False, + default=datetime(2100, 1, 1)) + analysis_stop: datetime = Column( + DateTime, + nullable=False, + default=datetime(2100, 1, 1)) + + # Max contest time for each user in seconds. + per_user_time: timedelta | None = Column( + Interval, + CheckConstraint("per_user_time >= '0 seconds'"), + nullable=True) + + # Contest (id and object) to which this user group belongs. + contest_id: int = Column( + Integer, + ForeignKey(Contest.id, + onupdate="CASCADE", ondelete="CASCADE"), + # nullable=False, + index=True) + contest: Contest = relationship( + Contest, + backref=backref('groups', + cascade="all, delete-orphan", + passive_deletes=True), + primaryjoin="Contest.id==Group.contest_id") + + def phase(self, timestamp: datetime) -> int: + """Return: -1 if contest isn't started yet at time timestamp, + 0 if the contest is active at time timestamp, + 1 if the contest has ended but analysis mode + hasn't started yet + 2 if the contest has ended and analysis mode is active + 3 if the contest has ended and analysis mode is disabled or + has ended + + timestamp (datetime): the time we are iterested in. + return (int): contest phase as above. + + """ + if timestamp < self.start: + return -1 + if timestamp <= self.stop: + return 0 + if self.analysis_enabled: + if timestamp < self.analysis_start: + return 1 + elif timestamp <= self.analysis_stop: + return 2 + return 3 + + # Follows the description of the fields automatically added by + # SQLAlchemy. + # participations (list of Participation objects) + + class User(Base): """Class to store a user. @@ -231,6 +329,19 @@ class Participation(Base): back_populates="participations") __table_args__ = (UniqueConstraint("contest_id", "user_id"),) + # Group this user belongs to + group_id = Column( + Integer, + ForeignKey(Group.id, + onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True) + group = relationship( + Group, + backref=backref("participations", + cascade="all, delete-orphan", + passive_deletes=True)) + # Team (id and object) that the user is representing with this # participation. team_id: int | None = Column( diff --git a/cms/server/admin/handlers/__init__.py b/cms/server/admin/handlers/__init__.py index 86c02f22c0..61c188f9b4 100644 --- a/cms/server/admin/handlers/__init__.py +++ b/cms/server/admin/handlers/__init__.py @@ -101,7 +101,10 @@ AddTeamHandler, \ TeamHandler, \ TeamListHandler, \ - RemoveTeamHandler + RemoveTeamHandler, \ + GroupListHandler, \ + AddGroupHandler, \ + GroupHandler from .usertest import \ UserTestHandler, \ UserTestFileHandler @@ -136,6 +139,12 @@ (r"/contest/([0-9]+)/user/([0-9]+)/edit", ParticipationHandler), (r"/contest/([0-9]+)/user/([0-9]+)/message", MessageHandler), + # Contest's groups + + (r"/contest/([0-9]+)/groups", GroupListHandler), + (r"/contest/([0-9]+)/groups/add", AddGroupHandler), + (r"/contest/([0-9]+)/group/([0-9]+)/edit", GroupHandler), + # Contest's tasks (r"/contest/([0-9]+)/tasks", ContestTasksHandler), diff --git a/cms/server/admin/handlers/contest.py b/cms/server/admin/handlers/contest.py index 1a4c8e8ea6..c78839ff93 100644 --- a/cms/server/admin/handlers/contest.py +++ b/cms/server/admin/handlers/contest.py @@ -10,6 +10,8 @@ # Copyright © 2016 Myungwoo Chun # Copyright © 2016 Amir Keivan Mohtashami # Copyright © 2018 William Di Luigi +# Copyright © 2026 Tobias Lenz +# Copyright © 2026 Chuyang Wang # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -29,7 +31,7 @@ """ from cms import ServiceCoord, get_service_shards, get_service_address -from cms.db import Contest, Participation, Submission +from cms.db import Contest, Participation, Group, Submission from cmscommon.datetime import make_datetime from .base import BaseHandler, SimpleContestHandler, SimpleHandler, \ @@ -54,6 +56,12 @@ def post(self): # Create the contest. contest = Contest(**attrs) + + # Add the default group + group = Group(name="default") + contest.groups.append(group) + contest.main_group = group + self.sql_session.add(contest) except Exception as error: @@ -117,16 +125,16 @@ def post(self, contest_id: str): self.get_timedelta_sec(attrs, "min_submission_interval_grace_period") self.get_timedelta_sec(attrs, "min_user_test_interval") - self.get_datetime(attrs, "start") - self.get_datetime(attrs, "stop") - self.get_string(attrs, "timezone", empty=None) - self.get_timedelta_sec(attrs, "per_user_time") self.get_int(attrs, "score_precision") - self.get_bool(attrs, "analysis_enabled") - self.get_datetime(attrs, "analysis_start") - self.get_datetime(attrs, "analysis_stop") + main_group_attrs = dict() + self.get_datetime(main_group_attrs, "main_group_start") + assert main_group_attrs.get("main_group_start") is not None, "No main group start time specified." + self.get_datetime(main_group_attrs, "main_group_stop") + assert main_group_attrs.get("main_group_stop") is not None, "No main group stop time specified." + contest.main_group.start = main_group_attrs.get("main_group_start") + contest.main_group.stop = main_group_attrs.get("main_group_stop") # Update the contest. contest.set_attrs(attrs) diff --git a/cms/server/admin/handlers/contestuser.py b/cms/server/admin/handlers/contestuser.py index d3d0e3493a..1c588c0e66 100644 --- a/cms/server/admin/handlers/contestuser.py +++ b/cms/server/admin/handlers/contestuser.py @@ -11,6 +11,9 @@ # Copyright © 2016 Myungwoo Chun # Copyright © 2016 Peyman Jabbarzade Ganje # Copyright © 2017 Valentin Rosca +# Copyright © 2021 Manuel Gundlach +# Copyright © 2026 Tobias Lenz +# Copyright © 2026 Chuyang Wang # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -40,7 +43,8 @@ import tornado.web -from cms.db import Contest, Message, Participation, Submission, User, Team +from cms.db import Contest, Group, Message, Participation, Submission, User, \ + Team from cmscommon.datetime import make_datetime from .base import BaseHandler, require_permission @@ -153,6 +157,8 @@ def post(self, contest_id): try: user_id: str = self.get_argument("user_id") assert user_id != "null", "Please select a valid user" + group_id: str = self.get_argument("group_id") + assert group_id != "null", "Please select a valid group" except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) @@ -160,9 +166,11 @@ def post(self, contest_id): return user = self.safe_get_item(User, user_id) + group = self.safe_get_item(Group, group_id) # Create the participation. - participation = Participation(contest=self.contest, user=user) + participation = Participation(contest=self.contest, user=user, + group=group) self.sql_session.add(participation) if self.try_commit(): @@ -234,6 +242,10 @@ def post(self, contest_id, user_id): # Update the participation. participation.set_attrs(attrs) + # Update the group of the participant + group_id = self.get_argument("group_id") + participation.group = self.safe_get_item(Group, group_id) + # Update the team self.get_string(attrs, "team") team_code = attrs["team"] diff --git a/cms/server/admin/handlers/user.py b/cms/server/admin/handlers/user.py index 8b61246fc0..c5a573a818 100644 --- a/cms/server/admin/handlers/user.py +++ b/cms/server/admin/handlers/user.py @@ -9,6 +9,9 @@ # Copyright © 2014 Fabian Gundlach <320pointsguy@gmail.com> # Copyright © 2016 Myungwoo Chun # Copyright © 2017 Valentin Rosca +# Copyright © 2021 Manuel Gundlach +# Copyright © 2026 Tobias Lenz +# Copyright © 2026 Chuyang Wang # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -27,10 +30,11 @@ """ -from cms.db import Contest, Participation, Submission, Team, User +from cms.db import Contest, Participation, Submission, Team, Group, User from cmscommon.datetime import make_datetime -from .base import BaseHandler, SimpleHandler, require_permission +from .base import BaseHandler, SimpleContestHandler, SimpleHandler, \ + require_permission class UserHandler(BaseHandler): @@ -331,15 +335,16 @@ def post(self, user_id): user = self.safe_get_item(User, user_id) try: - contest_id: str = self.get_argument("contest_id") - assert contest_id != "null", "Please select a valid contest" + group_id: str = self.get_argument("group_id") + assert group_id != "null", "Please select a valid group" except Exception as error: self.service.add_notification( make_datetime(), "Invalid field(s)", repr(error)) self.redirect(fallback_page) return - self.contest = self.safe_get_item(Contest, contest_id) + group = self.safe_get_item(Group, group_id) + self.contest = group.contest attrs = {} self.get_bool(attrs, "hidden") @@ -396,3 +401,149 @@ def post(self, user_id): # Maybe they'll want to do this again (for another contest). self.redirect(fallback_page) + + +class GroupListHandler(SimpleContestHandler("groups.html")): + """Get returns the list of all groups. + + """ + + DELETE_GROUP = "Delete selected group" + MAKE_MAIN = "Make main group" + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id): + fallback_page = self.url("contest", contest_id, "groups") + + try: + group_id = self.get_argument("group_id") + operation = self.get_argument("operation") + + contest = self.safe_get_item(Contest, contest_id) + group = self.safe_get_item(Group, group_id) + + if operation == self.DELETE_GROUP: + self._handle_delete_group(contest, group) + self.redirect(fallback_page) + return + elif operation == self.MAKE_MAIN: + contest.main_group_id = group.id + self.try_commit() + self.redirect(fallback_page) + return + else: + self.service.add_notification( + make_datetime(), "Invalid operation", + f"I do not understand the operation `{operation}'") + self.redirect(fallback_page) + return + + except Exception as error: + self.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + def _handle_delete_group(self, contest, group): + # disallow deleting non-empty groups + if len(group.participations) != 0: + self.application.service.add_notification( + make_datetime(), "Cannot delete group containing users", + f"The group `{group.name}' contains " + f"{len(group.participations)} users") + return + + # disallow deleting the main_group of the contest + if contest.main_group_id == group.id: + self.application.service.add_notification( + make_datetime(), f"Cannot delete a contest's main group.", + f"To delete '{group.name}', change the main group of " + f"the contest first to another group.") + return + + self.sql_session.delete(group) + self.try_commit() + + +class AddGroupHandler(BaseHandler): + """Adds a new group. + + """ + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id): + fallback_page = self.url("contest", contest_id, "group", "add") + + try: + attrs = dict() + + self.get_string(attrs, "name") + + assert attrs.get("name") is not None, \ + "No name specified." + + attrs["contest"] = self.safe_get_item(Contest, contest_id) + + # Create the group. + group = Group(**attrs) + self.sql_session.add(group) + + except Exception as error: + self.application.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.try_commit() + self.redirect(self.url("contest", contest_id, + "group", group.id, "edit")) + + +class GroupHandler(BaseHandler): + @require_permission(BaseHandler.PERMISSION_ALL) + def get(self, contest_id, group_id): + self.contest = self.safe_get_item(Contest, contest_id) + + self.r_params = self.render_params() + + group = self.safe_get_item(Group, group_id) + assert group.contest_id == self.contest.id + + self.r_params["group"] = group + self.render("group.html", **self.r_params) + + @require_permission(BaseHandler.PERMISSION_ALL) + def post(self, contest_id, group_id): + fallback_page = self.url("contest", contest_id, + "group", group_id, "edit") + + self.contest = self.safe_get_item(Contest, contest_id) + group = self.safe_get_item(Group, group_id) + assert group.contest_id == self.contest.id + + try: + attrs = group.get_attrs() + + self.get_string(attrs, "name", empty=None) + + self.get_datetime(attrs, "start") + self.get_datetime(attrs, "stop") + self.get_timedelta_sec(attrs, "per_user_time") + + self.get_bool(attrs, "analysis_enabled") + self.get_datetime(attrs, "analysis_start") + self.get_datetime(attrs, "analysis_stop") + + assert attrs.get("name") is not None, \ + "No group name specified." + + # Update the group. + group.set_attrs(attrs) + + except Exception as error: + self.application.service.add_notification( + make_datetime(), "Invalid field(s)", repr(error)) + self.redirect(fallback_page) + return + + self.try_commit() + self.redirect(fallback_page) diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index ff6b099509..1e7e81b46d 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -29,9 +29,11 @@ utils = new CMS.AWSUtils("{{ url() }}", {{ timestamp|make_timestamp }}, 0, 0, 0, 0, 0); {% else %} utils = new CMS.AWSUtils("{{ url() }}", {{ timestamp|make_timestamp }}, - {{ contest.start|make_timestamp }}, {{ contest.stop|make_timestamp }}, - {{ contest.analysis_start|make_timestamp }}, {{ contest.analysis_stop|make_timestamp }}, - {{ contest.analysis_enabled | int }}); + {{ contest.main_group.start|make_timestamp }}, + {{ contest.main_group.stop|make_timestamp }}, + {{ contest.main_group.analysis_start|make_timestamp }}, + {{ contest.main_group.analysis_stop|make_timestamp }}, + {{ contest.main_group.analysis_enabled | int }}); utils.update_remaining_time(); setInterval(function() { utils.update_remaining_time(); }, 1000); @@ -223,6 +225,7 @@

Teams

  • Submissions
  • {% if contest.allow_user_tests %}
  • User tests
  • {% endif %}
  • Users
  • +
  • Groups
  • Tasks
  • Announcements
  • diff --git a/cms/server/admin/templates/contest.html b/cms/server/admin/templates/contest.html index 43f57c0b60..bdb52174b8 100644 --- a/cms/server/admin/templates/contest.html +++ b/cms/server/admin/templates/contest.html @@ -205,36 +205,48 @@

    Contest configuration

    Times

    - - Start time (in UTC) + + Main group + + + {{ contest.main_group.name }} - + + Main Group Start Time (UTC) + - - End time (in UTC) + - + + Main Group End Time (UTC) + - - Timezone + - - - Length of the contest + + + To change the start and end times of other groups or change the main + group, please use the groups section. + + + + + + + Timezone - +

    Limits

    @@ -277,32 +289,6 @@

    Contest configuration

    -

    Analysis mode

    - - - - Enabled - - - - - - - - - Analysis mode start time (in UTC) - - - - - - - Analysis mode end time (in UTC) - - - Users list {{ u.username }} {% endfor %} + + Users list Username First name Last name + Group - {% for u in contest.participations|map(attribute="user")|sort(attribute="username") %} + {% for u in contest.participations|sort(attribute="user.username") %} - + - {{ u.username }} - {{ u.first_name }} - {{ u.last_name }} + {{ u.user.username }} + {{ u.user.first_name }} + {{ u.user.last_name }} + {{ u.group.name }} {% endfor %} diff --git a/cms/server/admin/templates/group.html b/cms/server/admin/templates/group.html new file mode 100644 index 0000000000..61ae5a5fc4 --- /dev/null +++ b/cms/server/admin/templates/group.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block core %} + +

    + Group {{ group.name }} in {{ contest.name }} +

    + +

    Users

    +
    + {% if group.participations|length < 1 %} +

    No users found.

    + + {% else %} + + + + + + + + + + + {% for p in group.participations %} + + + + + + {% endfor %} + +
    UsernameFirst nameLast name
    {{ p.user.username }}{{ p.user.first_name }}{{ p.user.last_name }}
    + {% endif %} + +
    +
    + + +

    Group information

    +
    + +
    + {{ xsrf_form_html|safe }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + Name + + +
    + + Start time (in UTC) + + +
    + + End time (in UTC) + + +
    + + Length of the contest +

    Analysis mode

    + + Enabled + + +
    + + Analysis mode start time (in UTC) +
    + + Analysis mode end time (in UTC) +
    + + +
    +
    +
    + +{% endblock core %} diff --git a/cms/server/admin/templates/groups.html b/cms/server/admin/templates/groups.html new file mode 100644 index 0000000000..fb53f5d90c --- /dev/null +++ b/cms/server/admin/templates/groups.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block core %} +
    +

    Groups list

    +
    + +
    + {{ xsrf_form_html|safe }} + Add a new group named + + +
    + +
    + {{ xsrf_form_html|safe }} + Edit selected group: + + + + + + + + + + + + + + + + + {% for g in contest.groups %} + + + + + + + + + + + {% endfor %} + +
    NameStart (in UTC)End (in UTC)Max. contest length (in seconds)AnalysisAnalysis startAnalysis end
    + + {{ g.name }} {% if g.id == g.contest.main_group_id %}(main group){% endif %}{{ g.start }}{{ g.stop }} + {% if g.per_user_time is not none %} + {{ g.per_user_time.total_seconds() }} + {% endif %} + {{ g.analysis_start }}{{ g.analysis_stop }}
    +
    + +{% endblock core %} diff --git a/cms/server/admin/templates/participation.html b/cms/server/admin/templates/participation.html index 9abb3b5e8a..5a14184ac5 100644 --- a/cms/server/admin/templates/participation.html +++ b/cms/server/admin/templates/participation.html @@ -38,6 +38,21 @@

    Participation information<
    {{ xsrf_form_html|safe }} + + + +
    + + Group + + +
    diff --git a/cms/server/contest/handlers/contest.py b/cms/server/contest/handlers/contest.py index 7ea473d2c9..728711251f 100644 --- a/cms/server/contest/handlers/contest.py +++ b/cms/server/contest/handlers/contest.py @@ -201,23 +201,26 @@ def render_params(self): if self.contest_url is not None: ret["contest_url"] = self.contest_url - ret["phase"] = self.contest.phase(self.timestamp) + if self.current_user is None: + ret["phase"] = self.contest.main_group.phase(self.timestamp) + else: + ret["phase"] = self.current_user.group.phase(self.timestamp) ret["questions_enabled"] = self.contest.allow_questions ret["testing_enabled"] = self.contest.allow_user_tests if self.current_user is not None: participation = self.current_user + group = participation.group + ret["group"] = group ret["participation"] = participation ret["user"] = participation.user res = compute_actual_phase( - self.timestamp, self.contest.start, self.contest.stop, - self.contest.analysis_start if self.contest.analysis_enabled - else None, - self.contest.analysis_stop if self.contest.analysis_enabled - else None, - self.contest.per_user_time, participation.starting_time, + self.timestamp, group.start, group.stop, + group.analysis_start if group.analysis_enabled else None, + group.analysis_stop if group.analysis_enabled else None, + group.per_user_time, participation.starting_time, participation.delay_time, participation.extra_time) ret["actual_phase"], ret["current_phase_begin"], \ diff --git a/cms/server/contest/templates/contest.html b/cms/server/contest/templates/contest.html index ba514771ba..c9bf4b4870 100644 --- a/cms/server/contest/templates/contest.html +++ b/cms/server/contest/templates/contest.html @@ -31,9 +31,9 @@ {{ current_phase_end|make_timestamp }}, {{ actual_phase }}); $(document).ready(function () { - utils.update_time({% if contest.per_user_time is not none %}true{% else %}false{% endif %}); + utils.update_time({% if participation.group.per_user_time is not none %}true{% else %}false{% endif %}); var timer = setInterval(function() { - utils.update_time({% if contest.per_user_time is not none %}true{% else %}false{% endif %}, timer); + utils.update_time({% if participation.group.per_user_time is not none %}true{% else %}false{% endif %}, timer); }, 1000); utils.update_unread_count(0{% if page == "communication" %}, 0{% endif %}); utils.update_notifications(true); diff --git a/cms/server/contest/templates/overview.html b/cms/server/contest/templates/overview.html index e086e16fb6..2630009bae 100644 --- a/cms/server/contest/templates/overview.html +++ b/cms/server/contest/templates/overview.html @@ -13,58 +13,58 @@

    {% trans %}Overview{% endtrans %}

    {% trans %}General information{% endtrans %}

    -
    +

    {% if phase == -1 %} {% trans %}The contest hasn't started yet.{% endtrans %}

    - {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + {% trans start_time=(participation.group.start + participation.delay_time)|format_datetime_smart, + stop_time=(participation.group.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} The contest will start at {{ start_time }} and will end at {{ stop_time }}. {% endtrans %} {% elif phase == 0 %} {% trans %}The contest is currently running.{% endtrans %}

    - {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + {% trans start_time=(participation.group.start + participation.delay_time)|format_datetime_smart, + stop_time=(participation.group.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} The contest started at {{ start_time }} and will end at {{ stop_time }}. {% endtrans %} {% elif phase >= +1 %} {% trans %}The contest has already ended.{% endtrans %}

    - {% trans start_time=(contest.start + participation.delay_time)|format_datetime_smart, - stop_time=(contest.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} + {% trans start_time=(participation.group.start + participation.delay_time)|format_datetime_smart, + stop_time=(participation.group.stop + participation.delay_time + participation.extra_time)|format_datetime_smart %} The contest started at {{ start_time }} and ended at {{ stop_time }}. {% endtrans %} {% endif %}

    -{% if contest.analysis_enabled %} +{% if participation.group.analysis_enabled %}

    {% if phase == +1 %} {% trans %}The analysis mode hasn't started yet.{% endtrans %}

    - {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} + {% trans start_time=participation.group.analysis_start|format_datetime_smart, + stop_time=participation.group.analysis_stop|format_datetime_smart %} The analysis mode will start at {{ start_time }} and will end at {{ stop_time }}. {% endtrans %} {% elif phase == +2 %} {% trans %}The analysis mode is currently running.{% endtrans %}

    - {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} + {% trans start_time=participation.group.analysis_start|format_datetime_smart, + stop_time=participation.group.analysis_stop|format_datetime_smart %} The analysis mode started at {{ start_time }} and will end at {{ stop_time }}. {% endtrans %} {% elif phase == +3 %} {% trans %}The analysis mode has already ended.{% endtrans %}

    - {% trans start_time=contest.analysis_start|format_datetime_smart, - stop_time=contest.analysis_stop|format_datetime_smart %} + {% trans start_time=participation.group.analysis_start|format_datetime_smart, + stop_time=participation.group.analysis_stop|format_datetime_smart %} The analysis mode started at {{ start_time }} and ended at {{ stop_time }}. {% endtrans %} {% endif %} @@ -131,12 +131,12 @@

    {% trans %}General information{% endtrans %}

    {% endif %}
    -{% if contest.per_user_time is not none %} +{% if participation.group.per_user_time is not none %}

    {# TODO would be very nice to write something like "just for 3 consecutive hours"... #} - {% trans per_user_time=contest.per_user_time|format_timedelta %}Every user is allowed to compete (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %} + {% trans per_user_time=participation.group.per_user_time|format_timedelta %}Every user is allowed to compete (i.e. submit solutions) for a uninterrupted time frame of {{ per_user_time }}.{% endtrans %}

    diff --git a/cms/server/contest/tokening.py b/cms/server/contest/tokening.py index cd15a6a6ec..0250ddabba 100644 --- a/cms/server/contest/tokening.py +++ b/cms/server/contest/tokening.py @@ -11,6 +11,7 @@ # Copyright © 2015-2016 William Di Luigi # Copyright © 2016 Myungwoo Chun # Copyright © 2016 Amir Keivan Mohtashami +# Copyright © 2026 Tobias Lenz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -251,10 +252,10 @@ def tokens_available( # If the contest is USACO-style (i.e., each user starts when they # decide so), then the tokens start being generated at the user's # starting time; otherwise, at the start of the contest. - if contest.per_user_time is not None: + if participation.group.per_user_time is not None: start = participation.starting_time else: - start = contest.start + start = participation.group.start # Compute separately for contest and task. res_contest = _tokens_available( diff --git a/cmscontrib/AddParticipation.py b/cmscontrib/AddParticipation.py index b10ea1a740..b618687022 100755 --- a/cmscontrib/AddParticipation.py +++ b/cmscontrib/AddParticipation.py @@ -4,6 +4,8 @@ # Copyright © 2017 Stefano Maggiolo # Copyright © 2016 Myungwoo Chun # Copyright © 2017 Luca Wehrstedt +# Copyright © 2021 Manuel Gundlach +# Copyright © 2026 Tobias Lenz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -36,8 +38,8 @@ from sqlalchemy.exc import IntegrityError from cms import utf8_decoder -from cms.db import Contest, Participation, SessionGen, Team, User, \ - ask_for_contest +from cms.db import Contest, Participation, SessionGen, Team, Group, \ + User, ask_for_contest from cmscommon.crypto import build_password, hash_password @@ -56,6 +58,7 @@ def add_participation( team_code: str | None, hidden: bool, unrestricted: bool, + groupname: str ): logger.info("Creating the user's participation in the database.") delay_time = delay_time if delay_time is not None else 0 @@ -78,6 +81,17 @@ def add_participation( if contest is None: logger.error("No contest with id `%s' found.", contest_id) return False + if groupname is None: + logger.error("No group name provided.") + return False + else: + group: Group | None = \ + session.query(Group) \ + .filter(Group.contest_id == contest_id, + Group.name == groupname).first() + if group is None: + logger.error("No group with name `%s' found.", groupname) + return False team: Team | None = None if team_code is not None: team = \ @@ -94,6 +108,7 @@ def add_participation( participation = Participation( user=user, contest=contest, + group=group, ip=[ipaddress.ip_network(ip)] if ip is not None else None, delay_time=datetime.timedelta(seconds=delay_time), extra_time=datetime.timedelta(seconds=extra_time), @@ -162,7 +177,8 @@ def main(): args.plaintext_password or args.hashed_password, args.method or "plaintext", args.hashed_password is not None, args.team, - args.hidden, args.unrestricted) + args.hidden, args.unrestricted, + args.group) return 0 if success is True else 1 diff --git a/cmscontrib/ImportContest.py b/cmscontrib/ImportContest.py index bc855ecc3b..c81d9b9762 100755 --- a/cmscontrib/ImportContest.py +++ b/cmscontrib/ImportContest.py @@ -7,6 +7,9 @@ # Copyright © 2013 Luca Wehrstedt # Copyright © 2014-2015 William Di Luigi # Copyright © 2015-2016 Luca Chiodini +# Copyright © 2021 Manuel Gundlach +# Copyright © 2026 Tobias Lenz +# Copyright © 2026 Chuyang Wang # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -43,9 +46,10 @@ from cms import utf8_decoder from cms.db.session import Session -from cms.db import SessionGen, User, Team, Participation, Task, Contest +from cms.db import SessionGen, User, Team, Participation, Task, Contest, Group from cms.db.filecacher import FileCacher -from cmscontrib.importing import ImportDataError, update_contest, update_task +from cmscontrib.importing import ImportDataError, update_contest, \ + update_group, update_task from cmscontrib.loaders import choose_loader, build_epilog from cmscontrib.loaders.base_loader import BaseLoader, ContestLoader @@ -106,8 +110,9 @@ def do_import(self): # Apply the modification flags if self.zero_time: - contest.start = datetime.datetime(1970, 1, 1) - contest.stop = datetime.datetime(1970, 1, 1) + for g in contest.groups: + g.start = datetime.datetime(1970, 1, 1) + g.stop = datetime.datetime(1970, 1, 1) with SessionGen() as session: try: @@ -118,6 +123,9 @@ def do_import(self): t.contest = None for tasknum, taskname in enumerate(tasks): self._task_to_db(session, contest, tasknum, taskname) + # Update/create groups + for g in contest.groups: + self._group_to_db(session, contest, g) # Delete stale participations if asked to, then import all # others. if self.delete_stale_participations: @@ -163,7 +171,7 @@ def _contest_to_db( logger.info("Creating contest on the database.") contest = new_contest session.add(contest) - + session.flush() # To get the contest.id assigned else: if not (self.update_contest or self.update_tasks): # Contest already present, but user did not ask to update any @@ -178,7 +186,11 @@ def _contest_to_db( # if it has changed. if contest_has_changed: logger.info("Contest data has changed, updating it.") + update_contest(contest, new_contest) + contest.main_group = [ + g for g in contest.groups + if g.name == new_contest.main_group.name][0] else: logger.info("Contest data has not changed.") @@ -312,7 +324,11 @@ def _participation_to_db( args["password"] = new_p["password"] if "delay" in new_p: args["delay_time"] = datetime.timedelta(seconds=new_p["delay"]) - + if "group" in new_p: + args["group"] = [g for g in contest.groups + if g.name == new_p["group"]][0] + else: + args["group"] = contest.main_group if p is not None: for k, v in args.items(): setattr(p, k, v) @@ -322,6 +338,48 @@ def _participation_to_db( session.add(new_p) return new_p + @staticmethod + def _group_to_db( + session: Session, contest: Contest, new_g: Group + ) -> Group: + """Add the group to the DB and attach it to the contest + + session: session to use. + contest: the contest in the DB. + new_g: the group object + + return: the group in the DB. + """ + # Check whether a group of this name already exists for + # the given contest + g: Group | None = ( + session.query(Group) + .filter(Group.name == new_g.name) + .filter(Group.contest_id == contest.id) + .first() + ) + + if g is not None: + update_group(g, new_g) + return g + + # Create new group and attach it to the contest + args = { + "name": new_g.name, + "start": new_g.start, + "stop": new_g.stop, + "analysis_enabled": new_g.analysis_enabled, + "analysis_start": new_g.analysis_start, + "analysis_stop": new_g.analysis_stop, + } + if new_g.per_user_time is not None: + args["per_user_time"] = new_g.per_user_time + + new_group = Group(**args) + new_group.contest = contest + session.add(new_group) + return new_group + def _delete_stale_participations( self, session: Session, contest: Contest, usernames_to_keep: set[str] ): diff --git a/cmscontrib/ImportUser.py b/cmscontrib/ImportUser.py index d040bbda38..a7477cc47d 100755 --- a/cmscontrib/ImportUser.py +++ b/cmscontrib/ImportUser.py @@ -85,7 +85,8 @@ def do_import(self): if contest is not None: logger.info("Creating participation of user %s in contest %s.", user.username, contest.name) - session.add(Participation(user=user, contest=contest)) + session.add(Participation(user=user, contest=contest, + group=contest.main_group)) session.commit() user_id = user.id diff --git a/cmscontrib/importing.py b/cmscontrib/importing.py index d426a19d67..b1de224685 100644 --- a/cmscontrib/importing.py +++ b/cmscontrib/importing.py @@ -4,6 +4,7 @@ # Copyright © 2010-2013 Giovanni Mascellani # Copyright © 2010-2018 Stefano Maggiolo # Copyright © 2010-2012 Matteo Boscariol +# Copyright © 2026 Tobias Lenz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -24,14 +25,14 @@ import functools import typing -from cms.db import Contest, Dataset, Task +from cms.db import Contest, Dataset, Task, Group from cms.db.base import Base from cms.db.session import Session __all__ = [ "contest_from_db", "task_from_db", - "update_contest", "update_task" + "update_contest", "update_task", "update_group" ] @@ -307,6 +308,11 @@ def update_datasets_fn(o, n, parent=None): def update_contest(old_contest: Contest, new_contest: Contest, parent=None): """Update old_contest with information from new_contest""" + def update_groups_fn(o, n, parent=None): + _update_list_with_key( + o, n, key=lambda g: g.name, preserve_old=True, + update_value_fn=functools.partial(update_group, parent=parent)) + _update_object(old_contest, new_contest, { # Announcements are not provided by the loader, we should keep # those we have. @@ -315,4 +321,16 @@ def update_contest(old_contest: Contest, new_contest: Contest, parent=None): # must be handled differently. Contest.tasks: False, Contest.participations: False, + Contest.main_group: False, + Contest.groups: update_groups_fn }, parent=parent) + + +def update_group(old_group: Group, new_group: Group, parent=None): + """ + Update old_group with information from new_group + """ + _update_object(old_group, new_group, { + Group.contest: False, + Group.participations: False, + }, parent=parent) \ No newline at end of file diff --git a/cmscontrib/loaders/italy_yaml.py b/cmscontrib/loaders/italy_yaml.py index 769fa96eb9..55b5e9c8a4 100644 --- a/cmscontrib/loaders/italy_yaml.py +++ b/cmscontrib/loaders/italy_yaml.py @@ -9,6 +9,7 @@ # Copyright © 2015-2019 Luca Chiodini # Copyright © 2016 Andrea Cracco # Copyright © 2018 Edoardo Morassutto +# Copyright © 2026 Tobias Lenz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -35,7 +36,7 @@ from cms import TOKEN_MODE_DISABLED, TOKEN_MODE_FINITE, TOKEN_MODE_INFINITE, \ FEEDBACK_LEVEL_FULL, FEEDBACK_LEVEL_RESTRICTED, FEEDBACK_LEVEL_OI_RESTRICTED from cms.db import Contest, User, Task, Statement, Attachment, Team, Dataset, \ - Manager, Testcase + Manager, Testcase, Group from cms.grading.languagemanager import LANGUAGES, HEADER_EXTS from cmscommon.constants import \ SCORE_MODE_MAX, SCORE_MODE_MAX_SUBTASK, SCORE_MODE_MAX_TOKENED_LAST @@ -237,10 +238,11 @@ def get_contest(self): args["token_gen_interval"] = timedelta(minutes=1) # Times - load(conf, args, ["start", "inizio"], conv=parse_datetime) - load(conf, args, ["stop", "fine"], conv=parse_datetime) + main_group = {} + load(conf, main_group, ["start", "inizio"], conv=parse_datetime) + load(conf, main_group, ["stop", "fine"], conv=parse_datetime) load(conf, args, ["timezone"]) - load(conf, args, ["per_user_time"], conv=make_timedelta) + load(conf, main_group, ["per_user_time"], conv=make_timedelta) # Limits load(conf, args, "max_submission_number") @@ -249,9 +251,33 @@ def get_contest(self): load(conf, args, "min_user_test_interval", conv=make_timedelta) # Analysis mode - load(conf, args, "analysis_enabled") - load(conf, args, "analysis_start", conv=parse_datetime) - load(conf, args, "analysis_stop", conv=parse_datetime) + load(conf, main_group, "analysis_enabled") + load(conf, main_group, "analysis_start", conv=parse_datetime) + load(conf, main_group, "analysis_stop", conv=parse_datetime) + + # Groups + main_group_name: str | None = load(conf, None, "main_group") + groups: Group | None = load(conf, None, "groups") + + if groups is None: + args["groups"] = [self.make_group(main_group)] + args["main_group"] = args["groups"][0] + else: + if main_group: + logger.warning("You should not specify `start', `stop', " + "`analysis_start', `analysis_end', or " + "`analysis_enabled' when using groups; I'm " + "going to ignore them") + + if main_group_name is None: + if len(groups) == 1: + main_group_name = groups[0]["name"] + else: + main_group_name = "main" + + args["groups"] = [self.make_group(g) for g in groups] + args["main_group"] = [g for g in args["groups"] + if g.name == main_group_name][0] tasks: list[str] | None = load(conf, None, ["tasks", "problemi"]) participations: list[dict] | None = load(conf, None, ["users", "utenti"]) @@ -266,6 +292,20 @@ def get_contest(self): return Contest(**args), tasks, participations + @staticmethod + def make_group(g_dict: dict) -> Group | None: + """Build a Group object from a dict""" + args = {} + for key in ["start", "stop", "analysis_stop", "analysis_end"]: + if key in g_dict: + args[key] = parse_datetime(g_dict[key]) + for key in ["name", "analysis_enabled"]: + if key in g_dict: + args[key] = g_dict[key] + if "per_user_time" in g_dict: + args["per_user_time"] = make_timedelta(g_dict["per_user_time"]) + return Group(**args) + def get_user(self): """See docstring in class UserLoader.""" diff --git a/cmstestsuite/functionaltestframework.py b/cmstestsuite/functionaltestframework.py index 714e59b3b6..b98234a21e 100644 --- a/cmstestsuite/functionaltestframework.py +++ b/cmstestsuite/functionaltestframework.py @@ -207,15 +207,20 @@ def add_contest(self, **kwargs): resp = self.admin_req('contests/add', args=add_args) # Contest ID is returned as HTTP response. page = resp.text - match = re.search( + match_contest = re.search( r'', page) - if match is not None: - contest_id = int(match.groups()[0]) + match_group = re.search( + r'