Skip to content
Merged
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
184 changes: 184 additions & 0 deletions scripts/hackathon-platform/rehearsal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Run inside the deployed API container; writes only the explicit QA event.

Seed output contains secrets: redirect to a mode-0600 file, never to CI logs.
"""
from __future__ import annotations

import argparse
import io
import json
import zipfile
from datetime import UTC, datetime, timedelta
from contextlib import contextmanager
from uuid import UUID, uuid4

from sqlalchemy import text

from hackathon_platform_api import file_submissions as files
from hackathon_platform_api import judging_operations as prep
from hackathon_platform_api import judging_scores as scores
from hackathon_platform_api import team_registration as registration
from hackathon_platform_api.auth.accounts import create_account_record
from hackathon_platform_api.auth.models import AuthenticatedUser
from hackathon_platform_api.database import get_engine
from hackathon_platform_api.event_profiles import DEVDAY_REHEARSAL_SLUG, DEVDAY_SLUG
from hackathon_platform_api.settings import get_settings

SLUG = DEVDAY_REHEARSAL_SLUG


def slides(number: int) -> bytes:
objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 400] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>',
'',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 400] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>',
'',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
for index, label in [(3, 'QA REHEARSAL'), (5, 'Synthetic demo slide')]:
content = f'BT /F1 26 Tf 50 300 Td ({label} - Team {number:02d}) Tj ET\n'
objects[index] = f'<< /Length {len(content.encode())} >>\nstream\n{content}endstream'
pdf = '%PDF-1.7\n'
offsets = [0]
for i, value in enumerate(objects, 1):
offsets.append(len(pdf.encode()))
pdf += f'{i} 0 obj\n{value}\nendobj\n'
xref = len(pdf.encode())
pdf += 'xref\n0 8\n0000000000 65535 f \n'
pdf += ''.join(f'{offset:010d} 00000 n \n' for offset in offsets[1:])
pdf += f'trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'
return pdf.encode()


class TransactionEngine:
def __init__(self, connection):
self.connection = connection

@contextmanager
def begin(self):
yield self.connection

@contextmanager
def connect(self):
yield self.connection


def seed() -> None:
with get_engine().begin() as connection:
_seed(TransactionEngine(connection))


def _seed(engine) -> None:
settings = get_settings()
now = datetime.now(UTC)
expiry = now + timedelta(days=7)
with engine.connect() as c:
original = c.execute(text('SELECT * FROM events WHERE slug=:s'), {'s': DEVDAY_SLUG}).mappings().one()
original_id = original['id']
fields = original['submission_fields']
original_rubrics = {(r['track'], r['stage']): r['rubric'] for r in c.execute(
text('SELECT track,stage,rubric FROM judging_score_sessions WHERE event_id=:e'),
{'e': original_id},
).mappings()}
credentials = []
with engine.begin() as c:
assert not c.execute(text('SELECT 1 FROM events WHERE slug=:s'), {'s': SLUG}).first(), 'QA event exists; refusing to overwrite'
eid = c.execute(text('''
INSERT INTO events(slug,name,timezone,starts_at,ends_at,status,submission_fields)
VALUES (:s,'DevDay QA — 전체 리허설 (40팀)',:tz,:start,:end,'active',CAST(:f AS jsonb))
RETURNING id
'''), {'s': SLUG, 'tz': original['timezone'], 'start': now-timedelta(hours=1),
'end': expiry, 'f': json.dumps(fields)}).scalar_one()
url = str(settings.public_app_url).rstrip('/') + '/events/' + SLUG
c.execute(text('''
INSERT INTO event_live_settings(event_id,submission_deadline,submission_short_url,submission_url_is_placeholder)
VALUES (:e,:d,:url,false)
'''), {'e': eid, 'd': now+timedelta(days=3), 'url': url+'/submit'})
for username, label, role in [
('qa-rehearsal-admin', 'QA 리허설 관리자', 'admin'),
*[(f'qa-rehearsal-t{track}-{i:02d}', f'QA Track {track} 1차 심사위원 {i}', 'operator')
for track in (1,2) for i in range(1,4)],
*[(f'qa-rehearsal-final-{i:02d}', f'QA 공통 결선 심사위원 {i}', 'operator') for i in range(1,7)],
]:
uid, _, password = create_account_record(c, event_id=eid, display_name=label,
role=role, expires_at=expiry)
c.execute(text('UPDATE platform_users SET username=:name WHERE id=:id'), {'name': username, 'id': uid})
credentials.append({'username': username, 'display_name': label, 'user_id': str(uid),
'password': password, 'role': role})
c.execute(text('''
INSERT INTO audit_logs(event_id,actor_user_id,action,entity_type,entity_id,metadata)
VALUES (:e,:u,'operations.rehearsal.created','event',:id,
CAST(:meta AS jsonb))
'''), {'e':eid, 'u':credentials[0]['user_id'], 'id':str(eid), 'meta':json.dumps({'synthetic':True,'teams':40,'tracks':2,'source':'authorized_ops'})})
actor = AuthenticatedUser(UUID(credentials[0]['user_id']), credentials[0]['username'],
credentials[0]['display_name'], eid, SLUG, ('admin',))
pool = registration.generate_codes(engine, SLUG, actor.user_id, 40)
teams = []
for i, code in enumerate(pool):
receipt = registration.register_team(engine, SLUG, registration.RegisterTeam(
submission_code=code.submission_code, team_name=f'QA Track {i//20+1} Team {i%20+1:02d}',
track=prep.TRACKS[i//20],
))
auth = {'team_number':receipt.team_number, 'submission_code':code.submission_code}
pdf = slides(receipt.team_number)
archive = io.BytesIO()
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('codex_log/session.jsonl', json.dumps({'qa':True,'team':receipt.team_number,'note':'Synthetic rehearsal only'}))
for kind, data in [('pdf',pdf),('log',archive.getvalue())]:
item = files.start_file(engine, settings, SLUG, files.StartFileRequest(
**auth, id=uuid4(), kind=kind, filename=f'qa-team-{receipt.team_number:02d}.'+('pdf' if kind=='pdf' else 'zip'), size_bytes=len(data),
))
files.write_chunk(engine, settings, SLUG, item_auth(auth), item.id, 0, data)
files.complete_file(engine, settings, SLUG, item_auth(auth), item.id)
files.save_item(engine, settings, SLUG, kind, files.SaveItemRequest(**auth, settings_version=1, file_id=item.id))
files.save_item(engine, settings, SLUG, 'repo', files.SaveItemRequest(
**auth, settings_version=1, url=f'https://example.test/qa/team-{receipt.team_number:02d}'
))
teams.append({**auth, 'team_name':receipt.team_name, 'track':receipt.track})
config = prep.Configuration(**{
track: prep.TrackSettings(
first_judge_ids=[x['user_id'] for x in credentials if x['username'].startswith(f'qa-rehearsal-t{i+1}-')],
final_judge_ids=[x['user_id'] for x in credentials if x['username'].startswith('qa-rehearsal-final-')],
finalist_count=3,
) for i,track in enumerate(prep.TRACKS)
})
prep.save_config(engine, SLUG, actor, prep.SaveConfiguration(version=0, config=config, reason='QA 전용 가상 심사 배정'))
for track in prep.TRACKS:
for stage in ('first','final'):
rubric = original_rubrics.get((track,stage)) or scores.RUBRIC_TEMPLATE['rubric']
scores.configure(engine, SLUG, actor, track, stage, scores.Configure(
version=0, rubric=rubric, reason='QA 평가표: 운영 저장본 또는 앱 기본 DevDay 평가표'
))
print(json.dumps({'slug':SLUG,'event_id':str(eid),'url':url,'expires_at':expiry.isoformat(),
'accounts':credentials,'teams':teams,'deadline':(now+timedelta(days=3)).isoformat(),
'scoring_started':False,'finalist_count':3},ensure_ascii=False))


def item_auth(auth: dict):
from hackathon_platform_api.schemas import TeamCredentialRequest
return TeamCredentialRequest(**auth)


def set_deadline(seconds: int) -> None:
engine = get_engine()
with engine.begin() as c:
e = c.execute(text('SELECT id,starts_at,ends_at FROM events WHERE slug=:s FOR UPDATE'), {'s':SLUG}).mappings().one()
target = datetime.now(UTC)+timedelta(seconds=seconds)
assert e['starts_at'] < target < e['ends_at'], 'Deadline must lie within the QA event schedule'
c.execute(text('UPDATE event_live_settings SET submission_deadline=:d WHERE event_id=:e'), {'d':target,'e':e['id']})
c.execute(text('''
INSERT INTO audit_logs(event_id,action,entity_type,entity_id,reason,after_values)
VALUES (:e,'operations.rehearsal.deadline','event',:id,'QA 마감 전후 리허설',CAST(:a AS jsonb))
'''), {'e':e['id'],'id':str(e['id']),'a':json.dumps({'deadline':target.isoformat()})})
print(json.dumps({'slug':SLUG,'deadline':target.isoformat()}))


parser=argparse.ArgumentParser(description=__doc__)
sub=parser.add_subparsers(dest='command', required=True)
sub.add_parser('seed')
deadline=sub.add_parser('deadline');deadline.add_argument('--seconds',type=int,required=True)
args=parser.parse_args()
if args.command=='seed':seed()
else:set_deadline(args.seconds)
91 changes: 91 additions & 0 deletions services/hackathon-platform/rehearsal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# DevDay full rehearsal

Use a separate event `devday-rehearsal-2026` on the existing platform. This
replaces the earlier separate-infrastructure proposal: API, DB, upload mount
and authentication cookie origin are shared. Only the event's synthetic data
and event-exclusive accounts are separate. Do not claim network/DB isolation.

The application must include plan 0037 and migration `0016_devday_rehearsal`
before seeding; the old version rejects team numbers 26–40 and DevDay judging
for this slug. Its main CI publishes API/web images and updates this repository's
production image tags. Let Argo perform the migration/rollout; do not patch live
Deployments. Verify actual images, successful migration and API/web health.

Before an authorized schema rollout, take and verify a database backup per
`services/hackathon-platform/README.md`. Record fingerprints of existing event,
account, team, submission and judging rows and uploaded files; compare afterward.
The additive migration only extends the range function's explicit slug allowlist.
Never seed or reset the actual `devday-seoul-2026` event.

## Prepare credentials and fixtures

The seed script reads actual event settings and creates only the rehearsal
slug. It refuses an existing rehearsal event, uses one DB transaction, and
creates synthetic teams, individual passwords, independent PDF/ZIP files and
code bindings. Passwords and submission codes must never appear in Git or CI logs.
Run from the Ops checkout only after the application rollout is authorized:

```bash
install -d -m 700 "$HOME/.local/share/hackathon-ops/devday-rehearsal-2026"
umask 077
kubectl -n hackathon-platform exec -i deploy/hackathon-platform-api -- \
python - seed < scripts/hackathon-platform/rehearsal.py \
> "$HOME/.local/share/hackathon-ops/devday-rehearsal-2026/credentials.json"
chmod 600 "$HOME/.local/share/hackathon-ops/devday-rehearsal-2026/credentials.json"
```

Deliver individual credentials via the team's existing private password manager
or a private SSH/SCP session to the server. The mode-0600 file is an administrator
bundle: distribute only each judge's own credentials; submission codes go to
assigned test participants. Do not place the bundle on a public download URL.
The thirteen QA accounts are event-exclusive and expire seven days after seeding.

Rehearsal pages:

- `/events/devday-rehearsal-2026/submit`: participant submissions/status.
- `/events/devday-rehearsal-2026/login`: QA administrator and judges.
- `/events/devday-rehearsal-2026/operations/teams`: submissions and PDF preview.
- `/events/devday-rehearsal-2026/judging`: preparation, order, scoring and results.
- `/events/devday-rehearsal-2026/presentations`: redacted public presentation order.

The event name and all its routes show QA status. The seed prepares 40 named
teams, 20 per track, all with synthetic complete repo/PDF/ZIP submissions.
Repo addresses under `example.test` are deliberately synthetic; do not expect
live source repositories. PDF slides and ZIP entries clearly identify QA data.

Three judges are assigned per first-round track; six final judges are common to
both tracks. For testing finalist selection, the QA finalist count is three per
track. Actual first-round assignments/finalist counts remain unchanged. Rubrics
copy saved actual rubrics when available; otherwise the current application's
DevDay workbook template is saved into QA draft sessions. No QA scoring opens at
seed time. No actual event session opens as a side effect.

## Rehearsal sequence

1. Claim/access assigned codes; verify all 40 teams and both track filters.
2. Resubmit repo, PDF and ZIP independently. Retry identical upload IDs/bytes;
verify authorized downloads and SHA-256, including a replaced PDF's preview.
3. Move only the QA deadline forward/backward. Before presentation freezing,
validate late-new-bytes rejection and recovery of files received in time.
4. Close the QA submission window, freeze the forty-team presentation order,
preview PDFs, download track PDF archives and move/exclude/restore a team.
5. With each first-round judge, check only the assigned track is accessible.
With a common final judge, check both final tracks. Check unrelated accounts
cannot access the rehearsal event or its scores.
6. Open only QA first-round sessions, score teams and inspect aggregates. Select
three finalists in each track explicitly, open final sessions and score with
six common judges. Finalists are never inferred from result sorting.

Change the QA deadline, including relative seconds for boundary tests:

```bash
kubectl -n hackathon-platform exec -i deploy/hackathon-platform-api -- \
python - deadline --seconds 3600 < scripts/hackathon-platform/rehearsal.py
kubectl -n hackathon-platform exec -i deploy/hackathon-platform-api -- \
python - deadline --seconds -1 < scripts/hackathon-platform/rehearsal.py
```

The command is hard-bound to the QA slug and records an audit event. Freeze and
open scoring are separate explicit UI actions; preserve snapshots/audit history.
Do not overwrite the rehearsal with the seed script after manual testing starts.
The old two-team QA event and its manual submission fixture remain untouched.
Loading