Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for freezing submissions at the start of a Session #19

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions code_submitter/extract_archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__':
Expand Down
69 changes: 62 additions & 7 deletions code_submitter/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import io
import zipfile
import datetime
import itertools
from typing import cast

import databases
from sqlalchemy.sql import select
Expand All @@ -16,7 +17,7 @@

from . import auth, utils, config
from .auth import User, BLUESHIRT_SCOPE
from .tables import Archive, ChoiceHistory
from .tables import Archive, Session, ChoiceHistory, ChoiceForSession

database = databases.Database(config.DATABASE_URL, force_rollback=config.TESTING)
templates = Jinja2Templates(directory='templates')
Expand Down Expand Up @@ -49,10 +50,34 @@ async def homepage(request: Request) -> Response:
Archive.c.created.desc(),
),
)
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,
})

Expand Down Expand Up @@ -123,14 +148,39 @@ 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])
@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(),
Expand All @@ -142,7 +192,12 @@ async def download_submissions(request: Request) -> Response:
routes = [
Route('/', endpoint=homepage, methods=['GET']),
Route('/upload', endpoint=upload, methods=['POST']),
Route('/download-submissions', endpoint=download_submissions, methods=['GET']),
Route('/create-session', endpoint=create_session, methods=['POST']),
Route(
'/download-submissions/{session_id:int}',
endpoint=download_submissions,
methods=['GET'],
),
]

middleware = [
Expand Down
36 changes: 36 additions & 0 deletions code_submitter/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,39 @@
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('name', sqlalchemy.String, unique=True, nullable=False),

sqlalchemy.Column('username', sqlalchemy.String, nullable=False),

sqlalchemy.Column(
'created',
sqlalchemy.DateTime(timezone=True),
nullable=False,
server_default=sqlalchemy.func.now(),
),
)

# TODO: constrain such that each team can only have one choice per session?
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,
),
)
63 changes: 51 additions & 12 deletions code_submitter/utils.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,78 @@
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(
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}


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_)
Expand All @@ -45,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)
Expand Down
49 changes: 49 additions & 0 deletions migrations/versions/27f63e48c6c4_create_sessions.py
Original file line number Diff line number Diff line change
@@ -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 ###
Loading