Skip to content

Commit a9192de

Browse files
committed
feat: export
1 parent 776529c commit a9192de

3 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Submission Export Plugin
2+
3+
CTFd 플러그인으로 사용자별 제출 현황을 확인하고 CSV로 내보낼 수 있는 기능을 제공합니다.
4+
5+
## 기능
6+
7+
- 모든 사용자의 문제별 제출 현황을 한눈에 확인
8+
- 이름, 이메일, 학번, 문제별 점수를 표시
9+
- CSV 파일로 내보내기 지원
10+
- Admin 패널에서 쉽게 접근 가능
11+
12+
## 설치
13+
14+
이 플러그인은 CTFd의 플러그인 디렉토리에 자동으로 설치됩니다:
15+
```
16+
CTFd/plugins/submission_export/
17+
```
18+
19+
CTFd를 재시작하면 플러그인이 자동으로 로드됩니다.
20+
21+
## 사용 방법
22+
23+
1. Admin 패널에 로그인
24+
2. 상단 메뉴에서 "Submission Export" 클릭
25+
3. 제출 현황 테이블 확인
26+
4. "Export to CSV" 버튼을 클릭하여 CSV 파일 다운로드
27+
28+
## CSV 출력 형식
29+
30+
CSV 파일에는 다음 정보가 포함됩니다:
31+
32+
```
33+
Name, Email, Student ID, Challenge1 (ID: 1), Challenge2 (ID: 2), ...
34+
```
35+
36+
- Name: 사용자 이름
37+
- Email: 사용자 이메일
38+
- Student ID: 학번 (Student ID Number 필드)
39+
- 각 챌린지별 획득 점수 (0 또는 문제 점수)
40+
41+
## 의존성
42+
43+
- CTFd
44+
- student_fields 플러그인 (학번 필드를 위해 필요)
45+
46+
## 특징
47+
48+
- **색상 코딩**:
49+
- 녹색 배경: 문제 해결 완료
50+
- 빨간색 배경: 미해결
51+
- **고정 헤더**: 스크롤 시 헤더가 고정되어 편리한 탐색
52+
- **총점 표시**: 각 사용자의 총점이 마지막 열에 표시됨
53+
- **Banned/Hidden 사용자 제외**: 금지되거나 숨겨진 사용자는 표시되지 않음
54+
55+
## 개발자 정보
56+
57+
플러그인 구조:
58+
```
59+
submission_export/
60+
├── __init__.py # 메인 플러그인 로직
61+
├── templates/
62+
│ └── submission_export.html # 제출 현황 페이지 템플릿
63+
└── README.md # 이 문서
64+
```
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Submission Export Plugin for CTFd
3+
Allows admins to view and export submission status for all users
4+
"""
5+
import csv
6+
from io import StringIO
7+
from flask import Blueprint, render_template, Response
8+
from CTFd.models import db, Users, Challenges, Solves, UserFieldEntries, UserFields
9+
from CTFd.utils.decorators import admins_only
10+
from CTFd.plugins import register_admin_plugin_menu_bar
11+
from sqlalchemy import func
12+
13+
14+
def load(app):
15+
"""Load the submission export plugin"""
16+
17+
# Register menu item in admin panel
18+
register_admin_plugin_menu_bar(
19+
title='Submission Export',
20+
route='/admin/submission_export/'
21+
)
22+
23+
# Create blueprint for the plugin
24+
submission_export = Blueprint(
25+
'submission_export',
26+
__name__,
27+
template_folder='templates',
28+
static_folder='static',
29+
url_prefix='/admin/submission_export'
30+
)
31+
32+
@submission_export.route('/')
33+
@admins_only
34+
def index():
35+
"""Display submission status page"""
36+
# Get all challenges ordered by ID
37+
challenges = Challenges.query.order_by(Challenges.id).all()
38+
39+
# Get all users with their information
40+
users = Users.query.filter_by(type='user', banned=False, hidden=False).order_by(Users.id).all()
41+
42+
# Get Student ID Number field
43+
student_id_field = UserFields.query.filter_by(name="Student ID Number").first()
44+
45+
# Build user data with submissions
46+
user_data = []
47+
for user in users:
48+
# Get student ID
49+
student_id = ""
50+
if student_id_field:
51+
student_id_entry = UserFieldEntries.query.filter_by(
52+
user_id=user.id,
53+
field_id=student_id_field.id
54+
).first()
55+
if student_id_entry:
56+
student_id = student_id_entry.value
57+
58+
# Get solves for this user
59+
user_solves = {
60+
solve.challenge_id: solve
61+
for solve in Solves.query.filter_by(user_id=user.id).all()
62+
}
63+
64+
# Build challenge scores
65+
challenge_scores = {}
66+
for challenge in challenges:
67+
if challenge.id in user_solves:
68+
# Get the score (value) for the challenge
69+
challenge_scores[challenge.id] = challenge.value or 0
70+
else:
71+
challenge_scores[challenge.id] = 0
72+
73+
user_data.append({
74+
'id': user.id,
75+
'name': user.name,
76+
'email': user.email,
77+
'student_id': student_id,
78+
'challenge_scores': challenge_scores
79+
})
80+
81+
return render_template(
82+
'submission_export.html',
83+
users=user_data,
84+
challenges=challenges
85+
)
86+
87+
@submission_export.route('/export.csv')
88+
@admins_only
89+
def export_csv():
90+
"""Export submission data as CSV"""
91+
# Get all challenges ordered by ID
92+
challenges = Challenges.query.order_by(Challenges.id).all()
93+
94+
# Get all users
95+
users = Users.query.filter_by(type='user', banned=False, hidden=False).order_by(Users.id).all()
96+
97+
# Get Student ID Number field
98+
student_id_field = UserFields.query.filter_by(name="Student ID Number").first()
99+
100+
# Create CSV in memory with UTF-8 BOM for Excel compatibility
101+
output = StringIO()
102+
# Write UTF-8 BOM for proper Korean encoding in Excel
103+
output.write('\ufeff')
104+
writer = csv.writer(output)
105+
106+
# Write header
107+
header = ['Name', 'Email', 'Student ID']
108+
for challenge in challenges:
109+
header.append(f'{challenge.name} (ID: {challenge.id})')
110+
header.append('Total Score')
111+
writer.writerow(header)
112+
113+
# Write user data
114+
for user in users:
115+
# Get student ID
116+
student_id = ""
117+
if student_id_field:
118+
student_id_entry = UserFieldEntries.query.filter_by(
119+
user_id=user.id,
120+
field_id=student_id_field.id
121+
).first()
122+
if student_id_entry:
123+
student_id = student_id_entry.value
124+
125+
# Get solves for this user
126+
user_solves = {
127+
solve.challenge_id: solve
128+
for solve in Solves.query.filter_by(user_id=user.id).all()
129+
}
130+
131+
# Build row with total score
132+
row = [user.name, user.email, student_id]
133+
total_score = 0
134+
for challenge in challenges:
135+
if challenge.id in user_solves:
136+
score = challenge.value or 0
137+
row.append(score)
138+
total_score += score
139+
else:
140+
row.append(0)
141+
row.append(total_score)
142+
143+
writer.writerow(row)
144+
145+
# Prepare response
146+
output.seek(0)
147+
return Response(
148+
output.getvalue(),
149+
mimetype='text/csv; charset=utf-8',
150+
headers={
151+
'Content-Disposition': 'attachment; filename=submission_export.csv'
152+
}
153+
)
154+
155+
# Register blueprint
156+
app.register_blueprint(submission_export)
157+
158+
print("[Submission Export Plugin] Loaded successfully")
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
{% extends "admin/base.html" %}
2+
3+
{% block stylesheets %}
4+
<style>
5+
.submission-table {
6+
font-size: 0.85rem;
7+
}
8+
.submission-table th {
9+
position: sticky;
10+
top: 0;
11+
background-color: #343a40;
12+
color: white;
13+
z-index: 10;
14+
}
15+
.submission-table td {
16+
text-align: center;
17+
}
18+
.submission-table td.user-info {
19+
text-align: left;
20+
white-space: nowrap;
21+
}
22+
.solved {
23+
background-color: #d4edda;
24+
color: #155724;
25+
font-weight: bold;
26+
}
27+
.unsolved {
28+
background-color: #f8d7da;
29+
color: #721c24;
30+
}
31+
.table-container {
32+
max-height: 70vh;
33+
overflow-y: auto;
34+
border: 1px solid #dee2e6;
35+
}
36+
.export-btn {
37+
margin-bottom: 20px;
38+
}
39+
</style>
40+
{% endblock %}
41+
42+
{% block content %}
43+
44+
<div class="jumbotron">
45+
<div class="container">
46+
<h1>Submission Status Export</h1>
47+
<p class="lead">View and export submission status for all users</p>
48+
</div>
49+
</div>
50+
51+
<div class="container">
52+
<div class="row">
53+
<div class="col-md-12">
54+
<a href="{{ url_for('submission_export.export_csv') }}" class="btn btn-primary export-btn">
55+
<i class="fas fa-download"></i> Export to CSV
56+
</a>
57+
</div>
58+
</div>
59+
60+
<div class="row">
61+
<div class="col-md-12">
62+
<div class="table-container">
63+
<table class="table table-bordered table-striped submission-table">
64+
<thead>
65+
<tr>
66+
<th rowspan="2">Name</th>
67+
<th rowspan="2">Email</th>
68+
<th rowspan="2">Student ID</th>
69+
<th colspan="{{ challenges|length }}" class="text-center">Challenges</th>
70+
<th rowspan="2">Total Score</th>
71+
</tr>
72+
<tr>
73+
{% for challenge in challenges %}
74+
<th class="text-center" title="{{ challenge.name }} (ID: {{ challenge.id }})">
75+
<div style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
76+
{{ challenge.name }}
77+
</div>
78+
<small>(ID:{{ challenge.id }}, {{ challenge.value or 0 }}pts)</small>
79+
</th>
80+
{% endfor %}
81+
</tr>
82+
</thead>
83+
<tbody>
84+
{% for user in users %}
85+
<tr>
86+
<td class="user-info">{{ user.name }}</td>
87+
<td class="user-info">{{ user.email }}</td>
88+
<td class="user-info">{{ user.student_id or 'N/A' }}</td>
89+
{% set ns = namespace(total_score=0) %}
90+
{% for challenge in challenges %}
91+
{% set score = user.challenge_scores[challenge.id] %}
92+
{% if score > 0 %}
93+
<td class="solved">{{ score }}</td>
94+
{% set ns.total_score = ns.total_score + score %}
95+
{% else %}
96+
<td class="unsolved">0</td>
97+
{% endif %}
98+
{% endfor %}
99+
<td class="text-center"><strong>{{ ns.total_score }}</strong></td>
100+
</tr>
101+
{% endfor %}
102+
</tbody>
103+
</table>
104+
</div>
105+
</div>
106+
</div>
107+
108+
{% if users|length == 0 %}
109+
<div class="row mt-3">
110+
<div class="col-md-12">
111+
<div class="alert alert-info text-center">
112+
No users found.
113+
</div>
114+
</div>
115+
</div>
116+
{% endif %}
117+
</div>
118+
119+
{% endblock %}
120+
121+
{% block scripts %}
122+
{% endblock %}

0 commit comments

Comments
 (0)