From 3b0a3d3f536da2bdf0e70e6384043e1464ea3b2a Mon Sep 17 00:00:00 2001 From: Peter Law Date: Fri, 8 Jan 2021 23:16:10 +0000 Subject: [PATCH 1/8] Initial introduction of models for sessions This introduces the concept of a Session, which may or not map exactly to an actual session of matches, that a given collection of archives is used for. With thanks to James for design discussion. Co-Authored-By: James Seden Smith --- code_submitter/tables.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/code_submitter/tables.py b/code_submitter/tables.py index 4c8e5e0..9449e44 100644 --- a/code_submitter/tables.py +++ b/code_submitter/tables.py @@ -38,3 +38,38 @@ server_default=sqlalchemy.func.now(), ), ) + + +# At the point of downloading the archives in order to run matches, you create a +# Session. The act of doing that will also create the required ChoiceForSession +# rows to record which items will be contained in the download. +Session = sqlalchemy.Table( + 'session', + metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('description', sqlalchemy.String, nullable=False), + + sqlalchemy.Column('username', sqlalchemy.String, nullable=False), + + sqlalchemy.Column( + 'created', + sqlalchemy.DateTime(timezone=True), + nullable=False, + server_default=sqlalchemy.func.now(), + ), +) + +ChoiceForSession = sqlalchemy.Table( + 'choice_for_session', + metadata, + sqlalchemy.Column( + 'choice_id', + sqlalchemy.ForeignKey('choice_history.id'), + primary_key=True, + ), + sqlalchemy.Column( + 'session_id', + sqlalchemy.ForeignKey('session.id'), + primary_key=True, + ), +) From 27d6da71fe3de6f5dafb9e55091eb0d17c3abc29 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 11:56:20 +0000 Subject: [PATCH 2/8] This is a name really --- code_submitter/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_submitter/tables.py b/code_submitter/tables.py index 9449e44..eb1cc05 100644 --- a/code_submitter/tables.py +++ b/code_submitter/tables.py @@ -47,7 +47,7 @@ 'session', metadata, sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), - sqlalchemy.Column('description', sqlalchemy.String, nullable=False), + sqlalchemy.Column('name', sqlalchemy.String, unique=True, nullable=False), sqlalchemy.Column('username', sqlalchemy.String, nullable=False), From d765313985cdf7b71390728c7e1f7c45e45315b1 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 12:14:55 +0000 Subject: [PATCH 3/8] Add migration for new session tables --- .../versions/27f63e48c6c4_create_sessions.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 migrations/versions/27f63e48c6c4_create_sessions.py diff --git a/migrations/versions/27f63e48c6c4_create_sessions.py b/migrations/versions/27f63e48c6c4_create_sessions.py new file mode 100644 index 0000000..ed85ec2 --- /dev/null +++ b/migrations/versions/27f63e48c6c4_create_sessions.py @@ -0,0 +1,49 @@ +"""Create Sessions + +Revision ID: 27f63e48c6c4 +Revises: d4e3b890e3d7 +Create Date: 2021-01-09 11:57:18.916146 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '27f63e48c6c4' +down_revision = 'd4e3b890e3d7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'session', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column( + 'created', + sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), + nullable=False, + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.create_table( + 'choice_for_session', + sa.Column('choice_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['choice_id'], ['choice_history.id']), + sa.ForeignKeyConstraint(['session_id'], ['session.id']), + sa.PrimaryKeyConstraint('choice_id', 'session_id'), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('choice_for_session') + op.drop_table('session') + # ### end Alembic commands ### From f56b4278f959c54176f121db7f86d614a9c115c3 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 12:15:37 +0000 Subject: [PATCH 4/8] Add a util to create the session and freeze the choices --- code_submitter/utils.py | 47 +++++++++++++++++++++++++++++++++++++++-- tests/tests_utils.py | 43 ++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/code_submitter/utils.py b/code_submitter/utils.py index 8d79bd7..a943a20 100644 --- a/code_submitter/utils.py +++ b/code_submitter/utils.py @@ -1,10 +1,10 @@ -from typing import Dict, Tuple +from typing import cast, Dict, Tuple from zipfile import ZipFile import databases from sqlalchemy.sql import select -from .tables import Archive, ChoiceHistory +from .tables import Archive, Session, ChoiceHistory, ChoiceForSession async def get_chosen_submissions( @@ -35,6 +35,49 @@ async def get_chosen_submissions( return {x['team']: (x['id'], x['content']) for x in rows} +async def create_session( + database: databases.Database, + name: str, + *, + by_username: str, +) -> int: + """ + Return a mapping of teams to their the chosen archive. + """ + + # Note: Ideally we'd group by team in SQL, however that doesn't seem to work + # properly -- we don't get the ordering applied before the grouping. + + async with database.transaction(): + rows = await database.fetch_all( + select([ + ChoiceHistory.c.id, + Archive.c.team, + ]).select_from( + Archive.join(ChoiceHistory), + ).order_by( + Archive.c.team, + ChoiceHistory.c.created.asc(), + ), + ) + + session_id = cast(int, await database.execute( + Session.insert().values(name=name, username=by_username), + )) + + # Rely on later keys replacing earlier occurrences of the same key. + choice_by_team = {x['team']: x['id'] for x in rows} + await database.execute_many( + ChoiceForSession.insert(), + [ + {'choice_id': x, 'session_id': session_id} + for x in choice_by_team.values() + ], + ) + + return session_id + + def summarise(submissions: Dict[str, Tuple[int, bytes]]) -> str: return "".join( "{}: {}\n".format(team, id_) diff --git a/tests/tests_utils.py b/tests/tests_utils.py index 2a4bd48..b341912 100644 --- a/tests/tests_utils.py +++ b/tests/tests_utils.py @@ -5,7 +5,7 @@ import test_utils from code_submitter import utils -from code_submitter.tables import Archive, ChoiceHistory +from code_submitter.tables import Archive, ChoiceHistory, ChoiceForSession class UtilsTests(test_utils.InTransactionTestCase): @@ -76,6 +76,47 @@ def test_get_chosen_submissions_multiple_chosen(self) -> None: result, ) + def test_create_session(self) -> None: + choice_id_1 = self.await_(self.database.execute( + ChoiceHistory.insert().values( + archive_id=8888888888, + username='someone_else', + created=datetime.datetime(2020, 8, 8, 12, 0), + ), + )) + choice_id_2 = self.await_(self.database.execute( + ChoiceHistory.insert().values( + archive_id=1111111111, + username='test_user', + created=datetime.datetime(2020, 3, 3, 12, 0), + ), + )) + self.await_(self.database.execute( + ChoiceHistory.insert().values( + archive_id=2222222222, + username='test_user', + created=datetime.datetime(2020, 2, 2, 12, 0), + ), + )) + + session_id = self.await_(utils.create_session( + self.database, + "Test Session", + by_username='the-user', + )) + + choices = self.await_(self.database.fetch_all( + ChoiceForSession.select(), + )) + + self.assertEqual( + [ + {'choice_id': choice_id_1, 'session_id': session_id}, + {'choice_id': choice_id_2, 'session_id': session_id}, + ], + [dict(x) for x in choices], + ) + def test_collect_submissions(self) -> None: self.await_(self.database.execute( ChoiceHistory.insert().values( From c9718ad63022a3c8485e6ce9832d731cf6318211 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 20:40:47 +0000 Subject: [PATCH 5/8] Add mechanism to create a Session --- code_submitter/server.py | 15 +++++++++++++++ templates/index.html | 16 ++++++++++++++++ tests/tests_app.py | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/code_submitter/server.py b/code_submitter/server.py index c382620..c4acee1 100644 --- a/code_submitter/server.py +++ b/code_submitter/server.py @@ -122,6 +122,20 @@ async def upload(request: Request) -> Response: ) +@requires(['authenticated', BLUESHIRT_SCOPE]) +async def create_session(request: Request) -> Response: + user: User = request.user + form = await request.form() + + await utils.create_session(database, form['name'], by_username=user.username) + + return RedirectResponse( + request.url_for('homepage'), + # 302 so that the browser switches to GET + status_code=302, + ) + + @requires(['authenticated', BLUESHIRT_SCOPE]) async def download_submissions(request: Request) -> Response: buffer = io.BytesIO() @@ -142,6 +156,7 @@ async def download_submissions(request: Request) -> Response: routes = [ Route('/', endpoint=homepage, methods=['GET']), Route('/upload', endpoint=upload, methods=['POST']), + Route('/create-session', endpoint=create_session, methods=['POST']), Route('/download-submissions', endpoint=download_submissions, methods=['GET']), ] diff --git a/templates/index.html b/templates/index.html index fb4ec22..e63fdfc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -39,6 +39,22 @@

Virtual Competition Code Submission

+
+
+
+

Create a new session

+
+ + +
+ +
+
+
{% endif %}
{% if request.user.team %} diff --git a/tests/tests_app.py b/tests/tests_app.py index ad91d12..d5f16ce 100644 --- a/tests/tests_app.py +++ b/tests/tests_app.py @@ -4,9 +4,10 @@ from unittest import mock import test_utils +from sqlalchemy.sql import select from starlette.testclient import TestClient -from code_submitter.tables import Archive, ChoiceHistory +from code_submitter.tables import Archive, Session, ChoiceHistory class AppTests(test_utils.DatabaseTestCase): @@ -277,6 +278,42 @@ def test_upload_archive_without_robot_py(self) -> None: ) self.assertEqual([], choices, "Should not have created a choice") + def test_create_session_requires_blueshirt(self) -> None: + response = self.session.post( + self.url_for('create_session'), + data={'name': "Test session"}, + ) + self.assertEqual(403, response.status_code) + + def test_create_session(self) -> None: + self.session.auth = ('blueshirt', 'blueshirt') + + response = self.session.post( + self.url_for('create_session'), + data={'name': "Test session"}, + ) + self.assertEqual(302, response.status_code) + self.assertEqual( + self.url_for('homepage'), + response.headers['location'], + ) + + session, = self.await_( + self.database.fetch_all(select([ + Session.c.name, + Session.c.username, + ])), + ) + + self.assertEqual( + { + 'name': 'Test session', + 'username': 'blueshirt', + }, + dict(session), + "Should have created a session", + ) + def test_no_download_link_for_non_blueshirt(self) -> None: download_url = self.url_for('download_submissions') From 9932c1535a959afdd511e36f2c2117a434723c42 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 21:37:11 +0000 Subject: [PATCH 6/8] Download archives by session This replaces the previous download of only the latest archives with a means to download the archives which were chosen for a given session. --- code_submitter/extract_archives.py | 18 ++++++-- code_submitter/server.py | 33 ++++++++++--- code_submitter/utils.py | 16 +++---- templates/index.html | 34 ++++++++++++-- tests/tests_app.py | 74 ++++++++++++++++++++++++++---- tests/tests_utils.py | 61 ++++++++++++++++++++---- 6 files changed, 195 insertions(+), 41 deletions(-) diff --git a/code_submitter/extract_archives.py b/code_submitter/extract_archives.py index 60ea3f9..4f77826 100644 --- a/code_submitter/extract_archives.py +++ b/code_submitter/extract_archives.py @@ -3,31 +3,43 @@ import asyncio import zipfile import argparse +from typing import cast from pathlib import Path import databases +from sqlalchemy.sql import select from . import utils, config +from .tables import Session -async def async_main(output_archive: Path) -> None: +async def async_main(output_archive: Path, session_name: str) -> None: output_archive.parent.mkdir(parents=True, exist_ok=True) database = databases.Database(config.DATABASE_URL) + session_id = cast(int, await database.fetch_one(select([ + Session.c.id, + ]).where( + Session.c.name == session_name, + ))) + with zipfile.ZipFile(output_archive) as zf: async with database.transaction(): - utils.collect_submissions(database, zf) + utils.collect_submissions(database, zf, session_id) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() + parser.add_argument('session_name', type=str) parser.add_argument('output_archive', type=Path) return parser.parse_args() def main(args: argparse.Namespace) -> None: - asyncio.get_event_loop().run_until_complete(async_main(args.output_archive)) + asyncio.get_event_loop().run_until_complete( + async_main(args.output_archive, args.session_name), + ) if __name__ == '__main__': diff --git a/code_submitter/server.py b/code_submitter/server.py index c4acee1..8ad46a8 100644 --- a/code_submitter/server.py +++ b/code_submitter/server.py @@ -1,6 +1,6 @@ import io import zipfile -import datetime +from typing import cast import databases from sqlalchemy.sql import select @@ -16,7 +16,7 @@ from . import auth, utils, config from .auth import User, BLUESHIRT_SCOPE -from .tables import Archive, ChoiceHistory +from .tables import Archive, Session, ChoiceHistory database = databases.Database(config.DATABASE_URL, force_rollback=config.TESTING) templates = Jinja2Templates(directory='templates') @@ -49,10 +49,14 @@ async def homepage(request: Request) -> Response: Archive.c.created.desc(), ), ) + sessions = await database.fetch_all( + Session.select().order_by(Session.c.created.desc()), + ) return templates.TemplateResponse('index.html', { 'request': request, 'chosen': chosen, 'uploads': uploads, + 'sessions': sessions, 'BLUESHIRT_SCOPE': BLUESHIRT_SCOPE, }) @@ -137,14 +141,25 @@ async def create_session(request: Request) -> Response: @requires(['authenticated', BLUESHIRT_SCOPE]) +@database.transaction() async def download_submissions(request: Request) -> Response: + session_id = cast(int, request.path_params['session_id']) + + session = await database.fetch_one( + Session.select().where(Session.c.id == session_id), + ) + + if session is None: + return Response( + f"{session_id!r} is not a valid session id", + status_code=404, + ) + buffer = io.BytesIO() with zipfile.ZipFile(buffer, mode='w') as zf: - await utils.collect_submissions(database, zf) + await utils.collect_submissions(database, zf, session_id) - filename = 'submissions-{now}.zip'.format( - now=datetime.datetime.now(datetime.timezone.utc), - ) + filename = f"submissions-{session['name']}.zip" return Response( buffer.getvalue(), @@ -157,7 +172,11 @@ async def download_submissions(request: Request) -> Response: Route('/', endpoint=homepage, methods=['GET']), Route('/upload', endpoint=upload, methods=['POST']), Route('/create-session', endpoint=create_session, methods=['POST']), - Route('/download-submissions', endpoint=download_submissions, methods=['GET']), + Route( + '/download-submissions/{session_id:int}', + endpoint=download_submissions, + methods=['GET'], + ), ] middleware = [ diff --git a/code_submitter/utils.py b/code_submitter/utils.py index a943a20..27e1cbc 100644 --- a/code_submitter/utils.py +++ b/code_submitter/utils.py @@ -9,29 +9,24 @@ async def get_chosen_submissions( database: databases.Database, + session_id: int, ) -> Dict[str, Tuple[int, bytes]]: """ Return a mapping of teams to their the chosen archive. """ - # Note: Ideally we'd group by team in SQL, however that doesn't seem to work - # properly -- we don't get the ordering applied before the grouping. - rows = await database.fetch_all( select([ Archive.c.id, Archive.c.team, Archive.c.content, - ChoiceHistory.c.created, ]).select_from( - Archive.join(ChoiceHistory), - ).order_by( - Archive.c.team, - ChoiceHistory.c.created.asc(), + Archive.join(ChoiceHistory).join(ChoiceForSession), + ).where( + Session.c.id == session_id, ), ) - # Rely on later keys replacing earlier occurrences of the same key. return {x['team']: (x['id'], x['content']) for x in rows} @@ -88,8 +83,9 @@ def summarise(submissions: Dict[str, Tuple[int, bytes]]) -> str: async def collect_submissions( database: databases.Database, zipfile: ZipFile, + session_id: int, ) -> None: - submissions = await get_chosen_submissions(database) + submissions = await get_chosen_submissions(database, session_id) for team, (_, content) in submissions.items(): zipfile.writestr(f'{team.upper()}.zip', content) diff --git a/templates/index.html b/templates/index.html index e63fdfc..16d8336 100644 --- a/templates/index.html +++ b/templates/index.html @@ -31,14 +31,40 @@

Virtual Competition Code Submission

- {% if BLUESHIRT_SCOPE in request.auth.scopes %}
- - Download current chosen submissions - +

Sessions

+ + + + + + {% if BLUESHIRT_SCOPE in request.auth.scopes %} + + {% endif %} + + {% for session in sessions %} + + + + + + {% if BLUESHIRT_SCOPE in request.auth.scopes %} + + {% endif %} + + {% endfor %} +
NameCreatedByDownload
{{ session.name }}{{ session.created }}{{ session.username }} + + ▼ + +
+ {% if BLUESHIRT_SCOPE in request.auth.scopes %}
None: # App import must happen after TESTING environment setup from code_submitter.server import app - def url_for(name: str) -> str: + def url_for(name: str, **path_params: str) -> str: # While it makes for uglier tests, we do need to use more absolute # paths here so that the urls emitted contain the root_path from the # ASGI server and in turn work correctly under proxy. - return 'http://testserver{}'.format(app.url_path_for(name)) + return 'http://testserver{}'.format(app.url_path_for(name, **path_params)) test_client = TestClient(app) self.session = test_client.__enter__() @@ -315,7 +320,13 @@ def test_create_session(self) -> None: ) def test_no_download_link_for_non_blueshirt(self) -> None: - download_url = self.url_for('download_submissions') + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + download_url = self.url_for('download_submissions', session_id=session_id) response = self.session.get(self.url_for('homepage')) @@ -325,20 +336,50 @@ def test_no_download_link_for_non_blueshirt(self) -> None: def test_shows_download_link_for_blueshirt(self) -> None: self.session.auth = ('blueshirt', 'blueshirt') - download_url = self.url_for('download_submissions') + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + download_url = self.url_for('download_submissions', session_id=session_id) response = self.session.get(self.url_for('homepage')) html = response.text self.assertIn(download_url, html) def test_download_submissions_requires_blueshirt(self) -> None: - response = self.session.get(self.url_for('download_submissions')) + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + response = self.session.get( + self.url_for('download_submissions', session_id=session_id), + ) self.assertEqual(403, response.status_code) + def test_download_submissions_when_invalid_session(self) -> None: + self.session.auth = ('blueshirt', 'blueshirt') + response = self.session.get( + self.url_for('download_submissions', session_id='4'), + ) + self.assertEqual(404, response.status_code) + def test_download_submissions_when_none(self) -> None: self.session.auth = ('blueshirt', 'blueshirt') - response = self.session.get(self.url_for('download_submissions')) + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + + response = self.session.get( + self.url_for('download_submissions', session_id=session_id), + ) self.assertEqual(200, response.status_code) with zipfile.ZipFile(io.BytesIO(response.content)) as zf: @@ -359,7 +400,7 @@ def test_download_submissions(self) -> None: created=datetime.datetime(2020, 8, 8, 12, 0), ), )) - self.await_(self.database.execute( + choice_id = self.await_(self.database.execute( ChoiceHistory.insert().values( archive_id=8888888888, username='test_user', @@ -367,7 +408,22 @@ def test_download_submissions(self) -> None: ), )) - response = self.session.get(self.url_for('download_submissions')) + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + self.await_(self.database.execute( + ChoiceForSession.insert().values( + choice_id=choice_id, + session_id=session_id, + ), + )) + + response = self.session.get( + self.url_for('download_submissions', session_id=session_id), + ) self.assertEqual(200, response.status_code) with zipfile.ZipFile(io.BytesIO(response.content)) as zf: diff --git a/tests/tests_utils.py b/tests/tests_utils.py index b341912..ffac35a 100644 --- a/tests/tests_utils.py +++ b/tests/tests_utils.py @@ -5,7 +5,12 @@ import test_utils from code_submitter import utils -from code_submitter.tables import Archive, ChoiceHistory, ChoiceForSession +from code_submitter.tables import ( + Archive, + Session, + ChoiceHistory, + ChoiceForSession, +) class UtilsTests(test_utils.InTransactionTestCase): @@ -41,18 +46,20 @@ def setUp(self) -> None: )) def test_get_chosen_submissions_nothing_chosen(self) -> None: - result = self.await_(utils.get_chosen_submissions(self.database)) + result = self.await_( + utils.get_chosen_submissions(self.database, session_id=0), + ) self.assertEqual({}, result) def test_get_chosen_submissions_multiple_chosen(self) -> None: - self.await_(self.database.execute( + choice_id_1 = self.await_(self.database.execute( ChoiceHistory.insert().values( archive_id=8888888888, username='someone_else', created=datetime.datetime(2020, 8, 8, 12, 0), ), )) - self.await_(self.database.execute( + choice_id_2 = self.await_(self.database.execute( ChoiceHistory.insert().values( archive_id=1111111111, username='test_user', @@ -66,8 +73,28 @@ def test_get_chosen_submissions_multiple_chosen(self) -> None: created=datetime.datetime(2020, 2, 2, 12, 0), ), )) + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + self.await_(self.database.execute( + ChoiceForSession.insert().values( + choice_id=choice_id_1, + session_id=session_id, + ), + )) + self.await_(self.database.execute( + ChoiceForSession.insert().values( + choice_id=choice_id_2, + session_id=session_id, + ), + )) - result = self.await_(utils.get_chosen_submissions(self.database)) + result = self.await_( + utils.get_chosen_submissions(self.database, session_id), + ) self.assertEqual( { 'SRZ2': (1111111111, b'1111111111'), @@ -118,14 +145,14 @@ def test_create_session(self) -> None: ) def test_collect_submissions(self) -> None: - self.await_(self.database.execute( + choice_id_1 = self.await_(self.database.execute( ChoiceHistory.insert().values( archive_id=8888888888, username='someone_else', created=datetime.datetime(2020, 8, 8, 12, 0), ), )) - self.await_(self.database.execute( + choice_id_2 = self.await_(self.database.execute( ChoiceHistory.insert().values( archive_id=1111111111, username='test_user', @@ -139,9 +166,27 @@ def test_collect_submissions(self) -> None: created=datetime.datetime(2020, 2, 2, 12, 0), ), )) + session_id = self.await_(self.database.execute( + Session.insert().values( + name="Test session", + username='blueshirt', + ), + )) + self.await_(self.database.execute( + ChoiceForSession.insert().values( + choice_id=choice_id_1, + session_id=session_id, + ), + )) + self.await_(self.database.execute( + ChoiceForSession.insert().values( + choice_id=choice_id_2, + session_id=session_id, + ), + )) with zipfile.ZipFile(io.BytesIO(), mode='w') as zf: - self.await_(utils.collect_submissions(self.database, zf)) + self.await_(utils.collect_submissions(self.database, zf, session_id)) self.assertEqual( { From da0737504873575265b5022658cd7182b7f7a60a Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 22:46:28 +0000 Subject: [PATCH 7/8] Show teams when their code was used --- code_submitter/server.py | 23 ++++++++++++++++++++++- templates/index.html | 4 +++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/code_submitter/server.py b/code_submitter/server.py index 8ad46a8..361d311 100644 --- a/code_submitter/server.py +++ b/code_submitter/server.py @@ -1,5 +1,6 @@ import io import zipfile +import itertools from typing import cast import databases @@ -16,7 +17,7 @@ from . import auth, utils, config from .auth import User, BLUESHIRT_SCOPE -from .tables import Archive, Session, ChoiceHistory +from .tables import Archive, Session, ChoiceHistory, ChoiceForSession database = databases.Database(config.DATABASE_URL, force_rollback=config.TESTING) templates = Jinja2Templates(directory='templates') @@ -52,11 +53,31 @@ async def homepage(request: Request) -> Response: sessions = await database.fetch_all( Session.select().order_by(Session.c.created.desc()), ) + sessions_and_archives = await database.fetch_all( + select([ + Archive.c.id, + Session.c.name, + ]).select_from( + Archive.join(ChoiceHistory).join(ChoiceForSession).join(Session), + ).where( + Archive.c.id.in_(x['id'] for x in uploads), + ).order_by( + Archive.c.id, + ), + ) + sessions_by_upload = { + grouper: [x['name'] for x in items] + for grouper, items in itertools.groupby( + sessions_and_archives, + key=lambda y: cast(int, y['id']), + ) + } return templates.TemplateResponse('index.html', { 'request': request, 'chosen': chosen, 'uploads': uploads, 'sessions': sessions, + 'sessions_by_upload': sessions_by_upload, 'BLUESHIRT_SCOPE': BLUESHIRT_SCOPE, }) diff --git a/templates/index.html b/templates/index.html index 16d8336..16bf280 100644 --- a/templates/index.html +++ b/templates/index.html @@ -125,7 +125,7 @@

Upload a new submission for team {{ request.user.team }}

{% endif %}
-
+

Your team's uploads

@@ -133,6 +133,7 @@

Your team's uploads

+ {% for upload in uploads %} Your team's uploads {% endif %} + {% endfor %}
Uploaded By SelectedSessions
{{ ', '.join(sessions_by_upload.get(upload.id, ())) }}
From 97dde60e5366dfac019f95ce79d63d78d4aacf91 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 9 Jan 2021 22:15:41 +0000 Subject: [PATCH 8/8] Add a TODO --- code_submitter/tables.py | 1 + 1 file changed, 1 insertion(+) diff --git a/code_submitter/tables.py b/code_submitter/tables.py index eb1cc05..783f27c 100644 --- a/code_submitter/tables.py +++ b/code_submitter/tables.py @@ -59,6 +59,7 @@ ), ) +# TODO: constrain such that each team can only have one choice per session? ChoiceForSession = sqlalchemy.Table( 'choice_for_session', metadata,