diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index 3735b3f..fe82aa3 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -4,3 +4,6 @@ pip >= 20 wheel >= 0.36 setuptools >= 50 + +# Temporarily include SRComp development branch +git+https://github.com/PeterJCLaw/srcomp@9dfcea54f073101da73e8fb62c8e4b108c118f1b # match-release diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 7fca75f..522e46d 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -140,9 +140,13 @@ the current time. "time": "..." } -The ``delay`` value is the amount of delay in seconds currently active. -Note that this value is only useful during match periods (it will otherwise -be ``0``). +The ``delay`` value is the amount of committed delay in seconds currently +active. This does not account for matches which have not yet been released but +which have passed their release threshold. Delays from belated match releases +will appear only when the match is eventually released (and the corresponding +delay is committed into the state). +Note that this value is only useful during match periods (it will otherwise be +``0``). The ``matches`` key is a list of the matches which are currently being played, as measured by the current time falling between the start and end @@ -159,7 +163,13 @@ being shepherded for, as measured by the current time falling between the earliest shepherding signal value and time when staging closes. They are presented in the same format as the `/matches`_ endpoint uses. -The ``time`` key is the current time on the server. +Each of ``matches``, ``staging_matches``, ``shepherding_matches`` accounts for +the match releasing mechanism. In the case of a match not being released "on +time" then it and subsequent matches are held back and will remain in their +corresponding keys as if time had stopped at the release threshold. + +The ``time`` key is the current time on the server. This value progresses +regardless of match holds. /state ------ @@ -261,12 +271,14 @@ limits start from the last match and work backwards. .. code-block:: json { + "last_released": "...", "last_scored": "...", "matches": [ { "arena": "...", "display_name": "Match ...", "num": "...", + "is_released": "bool", "scores": { "game": { "...": "...", @@ -301,6 +313,9 @@ limits start from the last match and work backwards. "end": "...", "start": "..." }, + "operations": { + "release_threshold": "...", + }, "slot": { "end": "...", "start": "..." @@ -319,7 +334,8 @@ limits start from the last match and work backwards. ] } -``last_scored`` contains the same value as in the following endpoint. +``last_released`` contains the same value as in its endpoint below. +``last_scored`` contains the same value as in its endpoint below. Any dates are in ISO 8601 format. Only one of the ``league`` or ``normalised`` sub-keys of ``scores`` will be @@ -333,6 +349,18 @@ The staging deadline is available in ``times.staging.closes`` while the ``times.staging.signal_shepherds`` value is when shepherds should start looking for teams although this isn't a strict value. +/matches/last_released +---------------------- + +.. code-block:: json + + { + "last_released": "..." + } + +``last_released`` contains the highest match number which has been released, +but may be ``null`` if no matches have yet been released. + /matches/last_scored -------------------- diff --git a/setup.py b/setup.py index f13fa87..4db4843 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ author="Student Robotics Competition Software SIG", author_email="srobo-devel@googlegroups.com", install_requires=[ + # TODO(PR): bump srcomp version once we know what version will include match-release 'sr.comp >=1.5, <2', 'Flask >=2.2', 'Werkzeug >= 2, <4', diff --git a/sr/comp/http/json_provider.py b/sr/comp/http/json_provider.py index 2f64628..72a7738 100644 --- a/sr/comp/http/json_provider.py +++ b/sr/comp/http/json_provider.py @@ -34,7 +34,7 @@ def default(self, obj: object) -> Any: return obj.value elif isinstance(obj, Match): comp: SRComp = g.comp_man.get_comp() - return match_json_info(comp, obj) + return match_json_info(comp, obj, comp.schedule.datetime_now) elif isinstance(obj, datetime.datetime): return http_date(obj.utctimetuple()) elif isinstance(obj, datetime.date): diff --git a/sr/comp/http/query_utils.py b/sr/comp/http/query_utils.py index b6a1d3a..5ee7b01 100644 --- a/sr/comp/http/query_utils.py +++ b/sr/comp/http/query_utils.py @@ -10,6 +10,7 @@ from league_ranker import LeaguePoints, RankedPosition from sr.comp.comp import SRComp +from sr.comp.match_operations import MatchState from sr.comp.match_period import Match, MatchType from sr.comp.types import ArenaName, GamePoints, MatchNumber, ShepherdName, TLA @@ -34,6 +35,12 @@ class Times(TypedDict): end: str +class OpsTimes(TypedDict): + # TODO: I don't like this name here. It fits for consistency with its other + # uses, however it doesn't fit so well alongside e.g: start/end, opens/closes. + release_threshold: str + + class StagingTimes(TypedDict): opens: str closes: str @@ -43,6 +50,7 @@ class StagingTimes(TypedDict): class MatchTimings(TypedDict): slot: Times + operations: OpsTimes game: Times staging: StagingTimes @@ -53,6 +61,7 @@ class _MatchInfo(TypedDict): arena: ArenaName teams: list[TLA | None] type: str # noqa:A003 + is_released: bool times: MatchTimings @@ -63,7 +72,7 @@ class MatchInfo(_MatchInfo, total=False): TParseable = TypeVar('TParseable', int, str, datetime.datetime) -def match_json_info(comp: SRComp, match: Match) -> MatchInfo: +def match_json_info(comp: SRComp, match: Match, when: datetime.datetime) -> MatchInfo: """ Get match JSON information. @@ -73,13 +82,16 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: A competition instance. match : sr.comp.match_periods.Match A match. + when : datetime.datetime + The current time. Returns ------- dict A :class:`dict` containing JSON suitable output. """ - match_slot_lengths = comp.schedule.match_slot_lengths + arena_times = comp.operations.get_arena_times(match) + state = comp.operations.get_match_state(match, when) staging_times = comp.schedule.get_staging_times(match) info = MatchInfo({ @@ -88,21 +100,18 @@ def match_json_info(comp: SRComp, match: Match) -> MatchInfo: 'arena': match.arena, 'teams': match.teams, 'type': match.type.value, + 'is_released': state == MatchState.RELEASED, 'times': { 'slot': { 'start': match.start_time.isoformat(), 'end': match.end_time.isoformat(), }, + 'operations': { + 'release_threshold': arena_times.release_threshold.isoformat(), + }, 'game': { - 'start': ( - match.start_time + - match_slot_lengths['pre'] - ).isoformat(), - 'end': ( - match.start_time + - match_slot_lengths['pre'] + - match_slot_lengths['match'] - ).isoformat(), + 'start': arena_times.start.isoformat(), + 'end': arena_times.end.isoformat(), }, 'staging': { 'opens': staging_times['opens'].isoformat(), diff --git a/sr/comp/http/server.py b/sr/comp/http/server.py index b0623b8..b0971cc 100644 --- a/sr/comp/http/server.py +++ b/sr/comp/http/server.py @@ -263,6 +263,12 @@ def config() -> Response: return jsonify(config=get_config_dict(comp)) +@app.route("/matches/last_released") +def last_released_match() -> Response: + comp: SRComp = g.comp_man.get_comp() + return jsonify(last_released=comp.operations.last_released_match) + + @app.route("/matches/last_scored") def last_scored_match() -> Response: comp: SRComp = g.comp_man.get_comp() @@ -272,10 +278,11 @@ def last_scored_match() -> Response: @app.route("/matches") def matches() -> Response: comp: SRComp = g.comp_man.get_comp() + now = comp.schedule.datetime_now matches: list[MatchInfo] = [] for slots in comp.schedule.matches: matches.extend( - match_json_info(comp, match) + match_json_info(comp, match, now) for match in slots.values() ) @@ -338,7 +345,11 @@ def parse_date(string: str) -> datetime.datetime: else: raise AssertionError("Limit isn't a number?") - return jsonify(matches=matches, last_scored=comp.scores.last_scored_match) + return jsonify( + matches=matches, + last_released=comp.operations.last_released_match, + last_scored=comp.scores.last_scored_match, + ) @app.route("/periods") @@ -372,36 +383,23 @@ def current_state() -> Response: delay = comp.schedule.delay_at(time) delay_seconds = int(delay.total_seconds()) - matches = [ - match_json_info(comp, x) - for x in comp.schedule.matches_at(time) - ] - - staging_matches = [] - shepherding_matches = [] - for slot in comp.schedule.matches: - for match in slot.values(): - staging_times = comp.schedule.get_staging_times(match) - - if time > staging_times['closes']: - # Already done staging - continue - - if staging_times['opens'] <= time: - staging_matches.append(match_json_info(comp, match)) - - signal_shepherds = staging_times['signal_shepherds'] - if signal_shepherds: - first_signal = min(signal_shepherds.values()) - if first_signal <= time: - shepherding_matches.append(match_json_info(comp, match)) + current_matches = comp.operations.get_matches_at(time) return jsonify( delay=delay_seconds, time=time.isoformat(), - matches=matches, - staging_matches=staging_matches, - shepherding_matches=shepherding_matches, + matches=[ + match_json_info(comp, x, time) + for x in current_matches.matches + ], + staging_matches=[ + match_json_info(comp, x, time) + for x in current_matches.staging_matches + ], + shepherding_matches=[ + match_json_info(comp, x, time) + for x in current_matches.shepherding_matches + ], ) diff --git a/tests/test_api.py b/tests/test_api.py index 149f767..876e551 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,6 +28,7 @@ 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -37,6 +38,9 @@ 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -59,6 +63,7 @@ 'league': {'QMC': 6, 'GRS': 8}, 'ranking': {'QMC': 2, 'GRS': 1}, }, + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -68,6 +73,9 @@ 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -144,6 +152,7 @@ def test_endpoints(self) -> None: '/matches?arena=B&num=1', '/matches?type=knockout', '/matches?type=league&limit=10', + '/matches/last_released', '/matches/last_scored', '/periods', '/state', @@ -338,6 +347,7 @@ def test_matches(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -347,6 +357,9 @@ def test_matches(self) -> None: 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -359,6 +372,7 @@ def test_matches(self) -> None: }, }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -380,6 +394,7 @@ def test_match_forwards_limit(self) -> None: 'league': {'CLY': 8, 'TTN': 6}, 'ranking': {'CLY': 1, 'TTN': 2}, }, + 'is_released': True, 'times': { 'slot': { 'start': '2014-04-26T13:00:00+01:00', @@ -389,6 +404,9 @@ def test_match_forwards_limit(self) -> None: 'start': '2014-04-26T13:01:30+01:00', 'end': '2014-04-26T13:04:30+01:00', }, + 'operations': { + 'release_threshold': '2014-04-26T13:01:30+01:00', + }, 'staging': { 'opens': '2014-04-26T12:56:30+01:00', 'closes': '2014-04-26T12:59:30+01:00', @@ -401,6 +419,7 @@ def test_match_forwards_limit(self) -> None: }, }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -416,6 +435,7 @@ def test_match_backwards_limit(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -425,6 +445,9 @@ def test_match_backwards_limit(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -438,6 +461,7 @@ def test_match_backwards_limit(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -453,6 +477,7 @@ def test_match_filter_naive_datetime(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -462,6 +487,9 @@ def test_match_filter_naive_datetime(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -475,6 +503,7 @@ def test_match_filter_naive_datetime(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -498,6 +527,7 @@ def test_match_filter_time(self) -> None: 'type': 'knockout', 'num': 129, 'arena': 'A', + 'is_released': True, 'times': { 'game': { 'end': '2014-04-27T17:29:30+01:00', @@ -507,6 +537,9 @@ def test_match_filter_time(self) -> None: 'end': '2014-04-27T17:30:00+01:00', 'start': '2014-04-27T17:25:00+01:00', }, + 'operations': { + 'release_threshold': '2014-04-27T17:26:30+01:00', + }, 'staging': { 'opens': '2014-04-27T17:21:30+01:00', 'closes': '2014-04-27T17:24:30+01:00', @@ -520,6 +553,7 @@ def test_match_filter_time(self) -> None: 'teams': ['???', '???', '???', '???'], }, ], + 'last_released': 129, 'last_scored': 99, } self.assertEqual( @@ -545,6 +579,12 @@ def test_last_scored(self) -> None: self.server_get('/matches/last_scored'), ) + def test_last_released(self) -> None: + self.assertEqual( + {'last_released': 129}, + self.server_get('/matches/last_released'), + ) + def test_invalid_match_type(self) -> None: with self.assertRaisesApiError('BadRequest', 400): self.server_get('/matches?type=bees') @@ -661,6 +701,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -669,6 +710,7 @@ def test_knockouts(self) -> None: 'num': 111, 'display_name': 'Match 111', 'teams': [None, '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -677,6 +719,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -685,6 +728,7 @@ def test_knockouts(self) -> None: 'num': 112, 'display_name': 'Match 112', 'teams': ['???', '???', '???', None], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -693,6 +737,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -701,6 +746,7 @@ def test_knockouts(self) -> None: 'num': 113, 'display_name': 'Match 113', 'teams': ['???', '???', '???', None], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -709,6 +755,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', '???', None, '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -717,6 +764,7 @@ def test_knockouts(self) -> None: 'num': 114, 'display_name': 'Match 114', 'teams': ['???', None, '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -725,6 +773,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': ['???', None, '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -733,6 +782,7 @@ def test_knockouts(self) -> None: 'num': 115, 'display_name': 'Match 115', 'teams': [None, '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -741,6 +791,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': [None, '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -749,6 +800,7 @@ def test_knockouts(self) -> None: 'num': 116, 'display_name': 'Match 116', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -757,6 +809,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': [None, '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -765,6 +818,7 @@ def test_knockouts(self) -> None: 'num': 117, 'display_name': 'Match 117', 'teams': ['???', '???', '???', None], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -773,6 +827,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': [None, '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -781,6 +836,7 @@ def test_knockouts(self) -> None: 'num': 118, 'display_name': 'Match 118', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -791,6 +847,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -799,6 +856,7 @@ def test_knockouts(self) -> None: 'num': 119, 'display_name': 'Match 119', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -807,6 +865,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -815,6 +874,7 @@ def test_knockouts(self) -> None: 'num': 120, 'display_name': 'Match 120', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -823,6 +883,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -831,6 +892,7 @@ def test_knockouts(self) -> None: 'num': 121, 'display_name': 'Match 121', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -839,6 +901,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -847,6 +910,7 @@ def test_knockouts(self) -> None: 'num': 122, 'display_name': 'Match 122', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -857,6 +921,7 @@ def test_knockouts(self) -> None: 'num': 123, 'display_name': 'Quarter 1 (#123)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -865,6 +930,7 @@ def test_knockouts(self) -> None: 'num': 124, 'display_name': 'Quarter 2 (#124)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -873,6 +939,7 @@ def test_knockouts(self) -> None: 'num': 125, 'display_name': 'Quarter 3 (#125)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -881,6 +948,7 @@ def test_knockouts(self) -> None: 'num': 126, 'display_name': 'Quarter 4 (#126)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -891,6 +959,7 @@ def test_knockouts(self) -> None: 'num': 127, 'display_name': 'Semi 1 (#127)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -899,6 +968,7 @@ def test_knockouts(self) -> None: 'num': 128, 'display_name': 'Semi 2 (#128)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -909,6 +979,7 @@ def test_knockouts(self) -> None: 'num': 129, 'display_name': 'Final (#129)', 'teams': ['???', '???', '???', '???'], + 'is_released': True, 'times': None, 'type': 'knockout', }, @@ -916,7 +987,7 @@ def test_knockouts(self) -> None: ] actual_rounds = self.server_get('knockout')['rounds'] - times_keys = ['game', 'slot', 'staging'] + times_keys = ['game', 'operations', 'slot', 'staging'] for r in actual_rounds: for m in r: with self.subTest(match=m):