fix up git
This commit is contained in:
@@ -86,6 +86,7 @@ export interface GitHubStatus {
|
|||||||
githubLogin?: string;
|
githubLogin?: string;
|
||||||
connectedAt?: string;
|
connectedAt?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
canDeleteRepos?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiGitHub = {
|
export const apiGitHub = {
|
||||||
@@ -106,6 +107,37 @@ export const apiGitHub = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GitHubRepoItem {
|
||||||
|
key: string;
|
||||||
|
ownerLogin: string;
|
||||||
|
repoName: string;
|
||||||
|
repoHtmlUrl: string;
|
||||||
|
private?: boolean;
|
||||||
|
examRoomId?: number;
|
||||||
|
examRoomName?: string;
|
||||||
|
studentLabel?: string;
|
||||||
|
publishMode?: 'room' | 'student' | '';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
fromSimpleCare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiGitHubRepos = {
|
||||||
|
list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => {
|
||||||
|
const res = await staffFetch('/auth/github/repos');
|
||||||
|
if (!res.ok) await parseError(res, 'Không tải danh sách repo');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
bulkDelete: async (repos: string[]) => {
|
||||||
|
const res = await staffFetch('/auth/github/repos/bulk-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ repos }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa repo thất bại');
|
||||||
|
return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface EmailDomainItem {
|
export interface EmailDomainItem {
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -658,6 +690,8 @@ export interface ExamSubmission {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
studentCode: string;
|
studentCode: string;
|
||||||
|
gitRepoUrl?: string;
|
||||||
|
gitPublishUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiExam = {
|
export const apiExam = {
|
||||||
@@ -807,4 +841,16 @@ export const apiExam = {
|
|||||||
if (!res.ok) await parseError(res, 'Đẩy lên Git thất bại');
|
if (!res.ok) await parseError(res, 'Đẩy lên Git thất bại');
|
||||||
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
|
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
|
||||||
},
|
},
|
||||||
|
publishSubmissionsGitStudents: async (id: number) => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' });
|
||||||
|
if (!res.ok) await parseError(res, 'Đẩy repo từng SV thất bại');
|
||||||
|
return res.json() as Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
published: number;
|
||||||
|
openUrl?: string;
|
||||||
|
message: string;
|
||||||
|
warnings?: string[];
|
||||||
|
studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[];
|
||||||
|
}>;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||||
|
import { GitHubReposPanel } from './GitHubReposPanel';
|
||||||
|
|
||||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||||
const { changePassword, logout } = useAuth();
|
const { changePassword, logout } = useAuth();
|
||||||
@@ -177,6 +178,11 @@ export function MyAccountTab() {
|
|||||||
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
|
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
|
||||||
Ngắt kết nối
|
Ngắt kết nối
|
||||||
</button>
|
</button>
|
||||||
|
{!gh.canDeleteRepos && (
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={gh.connecting} onClick={gh.connect}>
|
||||||
|
{gh.connecting ? 'Đang mở GitHub...' : 'Cấp quyền xóa repo'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -189,13 +195,24 @@ export function MyAccountTab() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{gh.connected && !gh.canDeleteRepos && (
|
||||||
|
<div className="login-error" style={{ marginTop: '0.75rem' }}>
|
||||||
|
Token hiện tại chưa có quyền <strong>delete_repo</strong> — không xóa được repo. Bấm <strong>Cấp quyền xóa repo</strong> (hoặc ngắt kết nối rồi kết nối lại).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{gh.error && <div className="login-error">{gh.error}</div>}
|
{gh.error && <div className="login-error">{gh.error}</div>}
|
||||||
{gh.message && <div className="login-success">{gh.message}</div>}
|
{gh.message && <div className="login-success">{gh.message}</div>}
|
||||||
{!gh.connected && (
|
{!gh.connected && (
|
||||||
<p className="exam-git-hint">
|
<p className="exam-git-hint">
|
||||||
Sau khi kết nối, vào <strong>Phòng thi</strong> → chọn phòng → tab <strong>Bài nộp</strong> → bấm <strong>Đẩy lên Git</strong>.
|
Sau khi kết nối, vào <strong>Phòng thi</strong> → tab <strong>Bài nộp</strong> → đẩy Git (gộp hoặc từng sinh viên).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{gh.connected && (
|
||||||
|
<>
|
||||||
|
<h3 className="section-subtitle" style={{ marginTop: '1rem' }}>Repo đã tạo</h3>
|
||||||
|
<GitHubReposPanel connected={gh.connected} canDeleteRepos={gh.canDeleteRepos} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [gitPublishUrl, setGitPublishUrl] = useState('');
|
const [gitPublishUrl, setGitPublishUrl] = useState('');
|
||||||
const [bundlingSubs, setBundlingSubs] = useState(false);
|
const [bundlingSubs, setBundlingSubs] = useState(false);
|
||||||
const [publishingGit, setPublishingGit] = useState(false);
|
const [publishingGit, setPublishingGit] = useState(false);
|
||||||
|
const [publishingGitStudents, setPublishingGitStudents] = useState(false);
|
||||||
const gh = useGitHubConnection();
|
const gh = useGitHubConnection();
|
||||||
const [editable, setEditable] = useState(false);
|
const [editable, setEditable] = useState(false);
|
||||||
const [prepEditable, setPrepEditable] = useState(false);
|
const [prepEditable, setPrepEditable] = useState(false);
|
||||||
@@ -269,6 +270,26 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const publishToGitStudents = async () => {
|
||||||
|
if (!gh.connected) {
|
||||||
|
setErr('Kết nối GitHub trong Tài khoản của tôi trước khi đẩy.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setPublishingGitStudents(true);
|
||||||
|
try {
|
||||||
|
const res = await apiExam.publishSubmissionsGitStudents(examId);
|
||||||
|
setMsg(res.message || 'Đã tạo repo từng sinh viên');
|
||||||
|
if (res.openUrl) window.open(res.openUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
const subs = await apiExam.listSubmissions(examId);
|
||||||
|
setSubmissions(subs.data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Đẩy Git thất bại');
|
||||||
|
} finally {
|
||||||
|
setPublishingGitStudents(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
setErr(''); setMsg('');
|
setErr(''); setMsg('');
|
||||||
try {
|
try {
|
||||||
@@ -845,9 +866,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
disabled={publishingGit || !gh.connected}
|
disabled={publishingGit || !gh.connected}
|
||||||
onClick={publishToGit}
|
onClick={publishToGit}
|
||||||
title={gh.connected ? 'Tạo repo + đẩy từng folder sinh viên lên GitHub' : 'Kết nối GitHub trong Tài khoản trước'}
|
title="1 repo — mỗi sinh viên một folder"
|
||||||
>
|
>
|
||||||
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
|
{publishingGit ? 'Đang đẩy...' : '🔗 Git gộp (1 repo)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={publishingGitStudents || !gh.connected}
|
||||||
|
onClick={publishToGitStudents}
|
||||||
|
title="Mỗi sinh viên một repo GitHub riêng"
|
||||||
|
>
|
||||||
|
{publishingGitStudents ? 'Đang tạo repo...' : '👤 Git từng SV'}
|
||||||
</button>
|
</button>
|
||||||
{!gh.connected && (
|
{!gh.connected && (
|
||||||
<span className="exam-submissions-toolbar-hint">
|
<span className="exam-submissions-toolbar-hint">
|
||||||
@@ -858,7 +888,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="exam-submissions-toolbar-hint">
|
<span className="exam-submissions-toolbar-hint">
|
||||||
ZIP gộp: mỗi sinh viên một folder — giải nén là chấm ngay.
|
<strong>Git gộp</strong>: 1 repo, folder từng SV · <strong>Git từng SV</strong>: mỗi người 1 repo.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -875,6 +905,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Sinh viên</th>
|
<th>Sinh viên</th>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
|
<th>Git</th>
|
||||||
<th>Thời gian</th>
|
<th>Thời gian</th>
|
||||||
<th style={{ textAlign: 'right' }}>Tải</th>
|
<th style={{ textAlign: 'right' }}>Tải</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -887,6 +918,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
||||||
|
<td style={{ fontSize: '0.8rem' }}>
|
||||||
|
{s.gitPublishUrl ? (
|
||||||
|
<a href={s.gitPublishUrl} target="_blank" rel="noreferrer">Repo</a>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
||||||
<td style={{ textAlign: 'right' }}>
|
<td style={{ textAlign: 'right' }}>
|
||||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
||||||
|
|||||||
186
management/src/components/GitHubReposPanel.tsx
Normal file
186
management/src/components/GitHubReposPanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiGitHubRepos, type GitHubRepoItem } from '../api';
|
||||||
|
|
||||||
|
export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) {
|
||||||
|
const [repos, setRepos] = useState<GitHubRepoItem[]>([]);
|
||||||
|
const [githubLogin, setGithubLogin] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!connected) {
|
||||||
|
setRepos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.list();
|
||||||
|
setRepos(res.data || []);
|
||||||
|
setGithubLogin(res.githubLogin || '');
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Không tải repo');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
useEffect(() => { load().catch(console.error); }, [load]);
|
||||||
|
|
||||||
|
const toggle = (key: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === repos.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(repos.map((r) => r.key)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
if (!canDeleteRepos) {
|
||||||
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return;
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.bulkDelete([...selected]);
|
||||||
|
if (res.deleted > 0) {
|
||||||
|
setMsg(res.message || `Đã xóa ${res.deleted} repo`);
|
||||||
|
if (res.failures?.length) setErr(res.failures.join('; '));
|
||||||
|
} else {
|
||||||
|
setMsg('');
|
||||||
|
setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Xóa thất bại');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteOne = async (repo: GitHubRepoItem) => {
|
||||||
|
if (!canDeleteRepos) {
|
||||||
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return;
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.bulkDelete([repo.key]);
|
||||||
|
if (res.deleted > 0) {
|
||||||
|
setMsg(res.message || 'Đã xóa repo');
|
||||||
|
} else {
|
||||||
|
setMsg('');
|
||||||
|
setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Xóa thất bại');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!connected) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="github-repos-panel">
|
||||||
|
<p className="exam-git-hint" style={{ marginTop: 0 }}>
|
||||||
|
Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} — có thể chọn và xóa nhiều repo cùng lúc.
|
||||||
|
</p>
|
||||||
|
<div className="github-repos-toolbar">
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" disabled={loading} onClick={() => load()}>
|
||||||
|
{loading ? 'Đang tải từ GitHub...' : 'Làm mới'}
|
||||||
|
</button>
|
||||||
|
{repos.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={toggleAll}>
|
||||||
|
{selected.size === repos.length ? 'Bỏ chọn' : 'Chọn tất cả'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||||
|
disabled={deleting || selected.size === 0 || !canDeleteRepos}
|
||||||
|
onClick={deleteSelected}
|
||||||
|
>
|
||||||
|
{deleting ? 'Đang xóa...' : `Xóa đã chọn (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{err && <div className="login-error">{err}</div>}
|
||||||
|
{msg && <div className="login-success">{msg}</div>}
|
||||||
|
{repos.length === 0 && !loading ? (
|
||||||
|
<p className="exam-git-hint">Không có repo nào trên tài khoản GitHub (hoặc token thiếu quyền đọc repo).</p>
|
||||||
|
) : (
|
||||||
|
<div className="github-repos-table-wrap table-wrapper">
|
||||||
|
<table className="data-table github-repos-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 36 }} />
|
||||||
|
<th>Repo</th>
|
||||||
|
<th>Loại</th>
|
||||||
|
<th>Phòng / SV</th>
|
||||||
|
<th>Cập nhật</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{repos.map((r) => (
|
||||||
|
<tr key={r.key}>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" checked={selected.has(r.key)} onChange={() => toggle(r.key)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={r.repoHtmlUrl} target="_blank" rel="noreferrer" className="github-repo-link">
|
||||||
|
{r.repoName}
|
||||||
|
</a>
|
||||||
|
{r.private && <span className="badge badge-muted" style={{ marginLeft: 6 }}>private</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{r.publishMode === 'student' ? (
|
||||||
|
<span className="badge badge-info">Từng SV</span>
|
||||||
|
) : r.publishMode === 'room' ? (
|
||||||
|
<span className="badge badge-muted">Gộp phòng</span>
|
||||||
|
) : r.fromSimpleCare ? (
|
||||||
|
<span className="badge badge-muted">Simple Care</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted">Khác</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: '0.85rem' }}>
|
||||||
|
{r.examRoomName && <div>{r.examRoomName}</div>}
|
||||||
|
{r.studentLabel && <div className="text-muted">{r.studentLabel}</div>}
|
||||||
|
{!r.examRoomName && !r.studentLabel && '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
{r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" disabled={deleting || !canDeleteRepos} onClick={() => deleteOne(r)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { apiGitHub } from '../api';
|
|||||||
export function useGitHubConnection() {
|
export function useGitHubConnection() {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [githubLogin, setGithubLogin] = useState('');
|
const [githubLogin, setGithubLogin] = useState('');
|
||||||
|
const [canDeleteRepos, setCanDeleteRepos] = useState(false);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@@ -13,10 +14,12 @@ export function useGitHubConnection() {
|
|||||||
const st = await apiGitHub.status();
|
const st = await apiGitHub.status();
|
||||||
setConnected(!!st.connected);
|
setConnected(!!st.connected);
|
||||||
setGithubLogin(st.githubLogin || '');
|
setGithubLogin(st.githubLogin || '');
|
||||||
|
setCanDeleteRepos(!!st.canDeleteRepos);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setGithubLogin('');
|
setGithubLogin('');
|
||||||
|
setCanDeleteRepos(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ export function useGitHubConnection() {
|
|||||||
await apiGitHub.disconnect();
|
await apiGitHub.disconnect();
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setGithubLogin('');
|
setGithubLogin('');
|
||||||
|
setCanDeleteRepos(false);
|
||||||
setMessage('Đã ngắt kết nối GitHub');
|
setMessage('Đã ngắt kết nối GitHub');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Ngắt kết nối thất bại');
|
setError(e?.message || 'Ngắt kết nối thất bại');
|
||||||
@@ -74,6 +78,7 @@ export function useGitHubConnection() {
|
|||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
githubLogin,
|
githubLogin,
|
||||||
|
canDeleteRepos,
|
||||||
connecting,
|
connecting,
|
||||||
error,
|
error,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -4312,6 +4312,34 @@ input:checked + .slider:before {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.github-repos-panel {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-repos-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-repos-table-wrap {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-repo-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.exam-git-branch-row {
|
.exam-git-branch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.ExamRoomStudent{},
|
&models.ExamRoomStudent{},
|
||||||
&models.ExamSubmission{},
|
&models.ExamSubmission{},
|
||||||
&models.StaffGitHubAuth{},
|
&models.StaffGitHubAuth{},
|
||||||
|
&models.StaffGitHubRepo{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) {
|
func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) {
|
||||||
|
return collectSubmissionZipFilesWithPrefix(rows, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectSubmissionZipFilesWithPrefix(rows []submissionRow, useStudentFolder bool) (files []gitPathFile, warnings []string) {
|
||||||
byPath := map[string]gitPathFile{}
|
byPath := map[string]gitPathFile{}
|
||||||
usedFolders := map[string]int{}
|
usedFolders := map[string]int{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
@@ -238,13 +242,17 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni
|
|||||||
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
|
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
prefix := ""
|
||||||
|
if useStudentFolder {
|
||||||
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
|
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
|
||||||
if n := usedFolders[folder]; n > 0 {
|
if n := usedFolders[folder]; n > 0 {
|
||||||
folder = fmt.Sprintf("%s_%d", folder, n+1)
|
folder = fmt.Sprintf("%s_%d", folder, n+1)
|
||||||
}
|
}
|
||||||
usedFolders[folder]++
|
usedFolders[folder]++
|
||||||
|
prefix = folder
|
||||||
|
}
|
||||||
|
|
||||||
skipped, err := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
|
skipped, err := copySubmissionZipEntries(row.FilePath, prefix, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
|
||||||
if size > githubBlobMaxBytes {
|
if size > githubBlobMaxBytes {
|
||||||
warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua")
|
warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua")
|
||||||
return nil
|
return nil
|
||||||
@@ -262,11 +270,11 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warnings = append(warnings, folder+": "+err.Error())
|
warnings = append(warnings, prefix+": "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, s := range skipped {
|
for _, s := range skipped {
|
||||||
warnings = append(warnings, folder+"/"+s)
|
warnings = append(warnings, prefix+"/"+s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files = make([]gitPathFile, 0, len(byPath))
|
files = make([]gitPathFile, 0, len(byPath))
|
||||||
@@ -453,6 +461,8 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
|
|||||||
|
|
||||||
room.GitPublishURL = viewURL
|
room.GitPublishURL = viewURL
|
||||||
_ = db.Save(&room).Error
|
_ = db.Save(&room).Error
|
||||||
|
roomIDCopy := roomID
|
||||||
|
recordStaffGitHubRepo(db, staffID, owner, repo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil)
|
||||||
|
|
||||||
msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows))
|
msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows))
|
||||||
if len(allWarn) > 0 {
|
if len(allWarn) > 0 {
|
||||||
@@ -471,3 +481,112 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/exam-rooms/:id/submissions/publish-git-students — mỗi sinh viên một repo GitHub riêng
|
||||||
|
func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
if staffID == 0 {
|
||||||
|
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
|
||||||
|
}
|
||||||
|
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.Status(401).JSON(fiber.Map{
|
||||||
|
"error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.",
|
||||||
|
"needsGitHubOAuth": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"})
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID, err := parseUintParam(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
||||||
|
}
|
||||||
|
var room models.ExamRoom
|
||||||
|
if err := db.First(&room, roomID).Error; err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := loadExamSubmissionRows(db, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := "main"
|
||||||
|
published := 0
|
||||||
|
var allWarn []string
|
||||||
|
var repoURLs []string
|
||||||
|
type studentRepo struct {
|
||||||
|
StudentRkID int64 `json:"studentRkId"`
|
||||||
|
StudentCode string `json:"studentCode"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
studentRepos := make([]studentRepo, 0, len(rows))
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
files, unpackWarn := collectSubmissionZipFilesWithPrefix([]submissionRow{row}, false)
|
||||||
|
allWarn = append(allWarn, unpackWarn...)
|
||||||
|
if len(files) == 0 {
|
||||||
|
allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repoName, repoHTML, err := githubEnsureRepoForStudent(token, ghLogin, &room, &row)
|
||||||
|
if err != nil {
|
||||||
|
allWarn = append(allWarn, row.StudentCode+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := studentSubmissionFolder(row.StudentCode, row.FullName)
|
||||||
|
commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name)
|
||||||
|
viewURL, pushWarn, err := githubPushFiles(token, ghLogin, repoName, branch, files, commitMsg)
|
||||||
|
allWarn = append(allWarn, pushWarn...)
|
||||||
|
if err != nil {
|
||||||
|
allWarn = append(allWarn, label+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID).
|
||||||
|
Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error
|
||||||
|
subID := row.ID
|
||||||
|
roomIDCopy := roomID
|
||||||
|
recordStaffGitHubRepo(db, staffID, ghLogin, repoName, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID)
|
||||||
|
published++
|
||||||
|
repoURLs = append(repoURLs, viewURL)
|
||||||
|
studentRepos = append(studentRepos, studentRepo{
|
||||||
|
StudentRkID: row.StudentRkID,
|
||||||
|
StudentCode: row.StudentCode,
|
||||||
|
FullName: row.FullName,
|
||||||
|
URL: viewURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if published == 0 {
|
||||||
|
return c.Status(502).JSON(fiber.Map{
|
||||||
|
"error": "Không đẩy được repo nào",
|
||||||
|
"warnings": allWarn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Đã tạo %d repo GitHub — mỗi sinh viên một repo.", published)
|
||||||
|
if len(allWarn) > 0 {
|
||||||
|
msg += fmt.Sprintf(" (%d cảnh báo)", len(allWarn))
|
||||||
|
}
|
||||||
|
openURL := ""
|
||||||
|
if len(repoURLs) > 0 {
|
||||||
|
openURL = repoURLs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"ok": true,
|
||||||
|
"published": published,
|
||||||
|
"studentRepos": studentRepos,
|
||||||
|
"openUrl": openURL,
|
||||||
|
"warnings": allWarn,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler {
|
|||||||
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s",
|
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s",
|
||||||
url.QueryEscape(clientID),
|
url.QueryEscape(clientID),
|
||||||
url.QueryEscape(redirectURI),
|
url.QueryEscape(redirectURI),
|
||||||
url.QueryEscape("repo"),
|
url.QueryEscape("repo delete_repo"),
|
||||||
url.QueryEscape(state),
|
url.QueryEscape(state),
|
||||||
)
|
)
|
||||||
return c.JSON(fiber.Map{"authorizeUrl": authorizeURL})
|
return c.JSON(fiber.Map{"authorizeUrl": authorizeURL})
|
||||||
@@ -196,6 +196,7 @@ func GitHubStatusHandler(db *gorm.DB) fiber.Handler {
|
|||||||
"githubLogin": row.GitHubLogin,
|
"githubLogin": row.GitHubLogin,
|
||||||
"connectedAt": row.ConnectedAt,
|
"connectedAt": row.ConnectedAt,
|
||||||
"scope": row.Scope,
|
"scope": row.Scope,
|
||||||
|
"canDeleteRepos": githubScopeAllowsDelete(row.Scope),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +210,21 @@ func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func githubScopeAllowsDelete(scope string) bool {
|
||||||
|
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||||
|
if scope == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range strings.FieldsFunc(scope, func(r rune) bool {
|
||||||
|
return r == ' ' || r == ','
|
||||||
|
}) {
|
||||||
|
if part == "delete_repo" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func githubGetUser(token string) (login string, err error) {
|
func githubGetUser(token string) (login string, err error) {
|
||||||
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
|
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
@@ -399,3 +415,61 @@ func resolvePublishRepo(token, ghLogin string, room *models.ExamRoom) (owner, re
|
|||||||
}
|
}
|
||||||
return owner, repo, branch, htmlURL, nil
|
return owner, repo, branch, htmlURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []string {
|
||||||
|
base := trimGitHubRepoName(strings.ToLower(studentSubmissionFolder(row.StudentCode, row.FullName)))
|
||||||
|
candidates := []string{
|
||||||
|
base,
|
||||||
|
fmt.Sprintf("%s-exam-%d", base, room.ID),
|
||||||
|
fmt.Sprintf("%s-%d", base, row.StudentRkID),
|
||||||
|
fmt.Sprintf("exam-%d-%d", room.ID, row.StudentRkID),
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
c = trimGitHubRepoName(c)
|
||||||
|
if seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
for i := 2; i <= 10; i++ {
|
||||||
|
c := trimGitHubRepoName(fmt.Sprintf("%s-%d", base, i))
|
||||||
|
if !seen[c] {
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubEnsureRepoForStudent(token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) {
|
||||||
|
if u := strings.TrimSpace(row.GitRepoURL); u != "" {
|
||||||
|
o, r, parseErr := parseGitHubRepo(u)
|
||||||
|
if parseErr == nil && strings.EqualFold(o, owner) {
|
||||||
|
if url, ok := githubGetRepo(token, owner, r); ok {
|
||||||
|
return r, url, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, candidate := range studentGitRepoNameCandidates(*row, room) {
|
||||||
|
if url, ok := githubGetRepo(token, owner, candidate); ok {
|
||||||
|
if u := strings.TrimSpace(row.GitRepoURL); u != "" {
|
||||||
|
if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) {
|
||||||
|
return candidate, url, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, createErr := githubCreateRepo(token, owner, candidate, true)
|
||||||
|
if createErr == nil {
|
||||||
|
return candidate, url, nil
|
||||||
|
}
|
||||||
|
if isGitHubRepoNameTaken(createErr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", "", createErr
|
||||||
|
}
|
||||||
|
return "", "", errors.New("không tạo được repo GitHub cho sinh viên")
|
||||||
|
}
|
||||||
|
|||||||
322
server/internal/handlers/handlers_github_repos.go
Normal file
322
server/internal/handlers/handlers_github_repos.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/internal/middleware"
|
||||||
|
"server/internal/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type githubRepoListItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
OwnerLogin string `json:"ownerLogin"`
|
||||||
|
RepoName string `json:"repoName"`
|
||||||
|
RepoHTMLURL string `json:"repoHtmlUrl"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
PublishMode string `json:"publishMode,omitempty"`
|
||||||
|
ExamRoomID *uint `json:"examRoomId,omitempty"`
|
||||||
|
ExamRoomName string `json:"examRoomName,omitempty"`
|
||||||
|
StudentLabel string `json:"studentLabel,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
FromSimpleCare bool `json:"fromSimpleCare"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubDeleteRepo(token, owner, repo string) error {
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
msg := strings.TrimSpace(string(raw))
|
||||||
|
var errBody struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &errBody)
|
||||||
|
if m := strings.TrimSpace(errBody.Message); m != "" {
|
||||||
|
msg = m
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return fmt.Errorf("%s — token thiếu quyền delete_repo; ngắt kết nối rồi kết nối lại GitHub", msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func githubListUserRepos(token string) ([]struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}, error) {
|
||||||
|
var all []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
for page := 1; page <= 10; page++ {
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&sort=updated&direction=desc", page)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var batch []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
all = append(all, batch...)
|
||||||
|
if len(batch) < 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoMeta struct {
|
||||||
|
PublishMode string
|
||||||
|
ExamRoomID *uint
|
||||||
|
ExamRoomName string
|
||||||
|
StudentLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoMetaKey(owner, name string) string {
|
||||||
|
return strings.ToLower(owner) + "/" + strings.ToLower(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRepoMetaMap(db *gorm.DB, staffID uint) map[string]repoMeta {
|
||||||
|
out := map[string]repoMeta{}
|
||||||
|
var tracked []models.StaffGitHubRepo
|
||||||
|
_ = db.Where("staff_id = ?", staffID).Find(&tracked).Error
|
||||||
|
roomNames := map[uint]string{}
|
||||||
|
for _, t := range tracked {
|
||||||
|
key := repoMetaKey(t.OwnerLogin, t.RepoName)
|
||||||
|
meta := repoMeta{
|
||||||
|
PublishMode: t.PublishMode,
|
||||||
|
ExamRoomID: t.ExamRoomID,
|
||||||
|
StudentLabel: t.StudentLabel,
|
||||||
|
}
|
||||||
|
if t.ExamRoomID != nil {
|
||||||
|
rid := *t.ExamRoomID
|
||||||
|
if name, ok := roomNames[rid]; ok {
|
||||||
|
meta.ExamRoomName = name
|
||||||
|
} else {
|
||||||
|
var room models.ExamRoom
|
||||||
|
if err := db.Select("name").First(&room, rid).Error; err == nil {
|
||||||
|
roomNames[rid] = room.Name
|
||||||
|
meta.ExamRoomName = room.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[key] = meta
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordStaffGitHubRepo(db *gorm.DB, staffID uint, owner, repoName, htmlURL, publishMode, studentLabel string, examRoomID, submissionID *uint) {
|
||||||
|
owner = strings.TrimSpace(owner)
|
||||||
|
repoName = strings.TrimSpace(repoName)
|
||||||
|
if staffID == 0 || owner == "" || repoName == "" || htmlURL == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, owner, repoName).
|
||||||
|
Delete(&models.StaffGitHubRepo{}).Error
|
||||||
|
row := models.StaffGitHubRepo{
|
||||||
|
StaffID: staffID,
|
||||||
|
OwnerLogin: owner,
|
||||||
|
RepoName: repoName,
|
||||||
|
RepoHTMLURL: htmlURL,
|
||||||
|
ExamRoomID: examRoomID,
|
||||||
|
SubmissionID: submissionID,
|
||||||
|
StudentLabel: studentLabel,
|
||||||
|
PublishMode: publishMode,
|
||||||
|
}
|
||||||
|
_ = db.Create(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/auth/github/repos — toàn bộ repo trên tài khoản GitHub đã kết nối
|
||||||
|
func GitHubListReposHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
|
||||||
|
}
|
||||||
|
ghRepos, err := githubListUserRepos(token)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(502).JSON(fiber.Map{"error": "Không tải repo từ GitHub: " + err.Error()})
|
||||||
|
}
|
||||||
|
metaMap := buildRepoMetaMap(db, staffID)
|
||||||
|
out := make([]githubRepoListItem, 0, len(ghRepos))
|
||||||
|
for _, r := range ghRepos {
|
||||||
|
parts := strings.SplitN(r.FullName, "/", 2)
|
||||||
|
owner := ghLogin
|
||||||
|
if len(parts) == 2 {
|
||||||
|
owner = parts[0]
|
||||||
|
}
|
||||||
|
key := owner + "/" + r.Name
|
||||||
|
item := githubRepoListItem{
|
||||||
|
Key: key,
|
||||||
|
OwnerLogin: owner,
|
||||||
|
RepoName: r.Name,
|
||||||
|
RepoHTMLURL: r.HTMLURL,
|
||||||
|
Private: r.Private,
|
||||||
|
CreatedAt: r.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
|
||||||
|
FromSimpleCare: strings.Contains(strings.ToLower(r.Description), "simple care"),
|
||||||
|
}
|
||||||
|
if meta, ok := metaMap[repoMetaKey(owner, r.Name)]; ok {
|
||||||
|
item.PublishMode = meta.PublishMode
|
||||||
|
item.ExamRoomID = meta.ExamRoomID
|
||||||
|
item.ExamRoomName = meta.ExamRoomName
|
||||||
|
item.StudentLabel = meta.StudentLabel
|
||||||
|
item.FromSimpleCare = true
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": out, "total": len(out), "githubLogin": ghLogin})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/github/repos/bulk-delete — repos: ["owner/name", ...]
|
||||||
|
func GitHubBulkDeleteReposHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
token, _, err := loadStaffGitHubToken(db, staffID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
|
||||||
|
}
|
||||||
|
var authRow models.StaffGitHubAuth
|
||||||
|
_ = db.First(&authRow, "staff_id = ?", staffID).Error
|
||||||
|
if !githubScopeAllowsDelete(authRow.Scope) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{
|
||||||
|
"error": "Token GitHub chưa có quyền xóa repo (delete_repo). Ngắt kết nối rồi bấm Kết nối lại GitHub.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
IDs []uint `json:"ids"` // legacy
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
targets := make([]struct{ owner, name string }, 0)
|
||||||
|
for _, raw := range req.Repos {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, "/", 2)
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, struct{ owner, name string }{owner: parts[0], name: parts[1]})
|
||||||
|
}
|
||||||
|
if len(targets) == 0 && len(req.IDs) > 0 {
|
||||||
|
var rows []models.StaffGitHubRepo
|
||||||
|
_ = db.Where("staff_id = ? AND id IN ?", staffID, req.IDs).Find(&rows).Error
|
||||||
|
for _, row := range rows {
|
||||||
|
targets = append(targets, struct{ owner, name string }{owner: row.OwnerLogin, name: row.RepoName})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Chọn ít nhất một repo"})
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
var failures []string
|
||||||
|
for _, t := range targets {
|
||||||
|
if err := githubDeleteRepo(token, t.owner, t.name); err != nil {
|
||||||
|
failures = append(failures, t.name+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var row models.StaffGitHubRepo
|
||||||
|
if err := db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, t.owner, t.name).First(&row).Error; err == nil {
|
||||||
|
_ = db.Delete(&row).Error
|
||||||
|
clearLinkedGitURLs(db, row)
|
||||||
|
} else {
|
||||||
|
clearLinkedGitURLsByName(db, t.owner, t.name)
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"ok": deleted > 0,
|
||||||
|
"deleted": deleted,
|
||||||
|
"failures": failures,
|
||||||
|
"message": fmt.Sprintf("Đã xóa %d repo trên GitHub", deleted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLinkedGitURLs(db *gorm.DB, row models.StaffGitHubRepo) {
|
||||||
|
url := strings.TrimSpace(row.RepoHTMLURL)
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExamRoomID != nil && row.PublishMode == models.GitPublishModeRoom {
|
||||||
|
_ = db.Model(&models.ExamRoom{}).Where("id = ? AND git_repo_url = ?", *row.ExamRoomID, url).
|
||||||
|
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
|
||||||
|
}
|
||||||
|
if row.SubmissionID != nil {
|
||||||
|
_ = db.Model(&models.ExamSubmission{}).Where("id = ? AND git_repo_url = ?", *row.SubmissionID, url).
|
||||||
|
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLinkedGitURLsByName(db *gorm.DB, owner, repoName string) {
|
||||||
|
prefix := fmt.Sprintf("https://github.com/%s/%s", owner, repoName)
|
||||||
|
_ = db.Model(&models.ExamRoom{}).Where("git_repo_url LIKE ?", prefix+"%").
|
||||||
|
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
|
||||||
|
_ = db.Model(&models.ExamSubmission{}).Where("git_repo_url LIKE ?", prefix+"%").
|
||||||
|
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ type ExamSubmission struct {
|
|||||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||||
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
|
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
|
||||||
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
|
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
|
||||||
|
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl,omitempty"`
|
||||||
|
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ExamSubmission) TableName() string { return "exam_submissions" }
|
func (ExamSubmission) TableName() string { return "exam_submissions" }
|
||||||
|
|||||||
26
server/internal/models/staff_github_repo.go
Normal file
26
server/internal/models/staff_github_repo.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
GitPublishModeRoom = "room"
|
||||||
|
GitPublishModeStudent = "student"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaffGitHubRepo — repo GitHub do giáo viên tạo qua Simple Care
|
||||||
|
type StaffGitHubRepo struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
|
||||||
|
OwnerLogin string `gorm:"column:owner_login;size:128;not null" json:"ownerLogin"`
|
||||||
|
RepoName string `gorm:"column:repo_name;size:128;not null" json:"repoName"`
|
||||||
|
RepoHTMLURL string `gorm:"column:repo_html_url;size:512;not null" json:"repoHtmlUrl"`
|
||||||
|
ExamRoomID *uint `gorm:"column:exam_room_id;index" json:"examRoomId,omitempty"`
|
||||||
|
ExamRoomName string `gorm:"-" json:"examRoomName,omitempty"`
|
||||||
|
SubmissionID *uint `gorm:"column:submission_id;index" json:"submissionId,omitempty"`
|
||||||
|
StudentLabel string `gorm:"column:student_label;size:255" json:"studentLabel,omitempty"`
|
||||||
|
PublishMode string `gorm:"column:publish_mode;size:16;not null;default:room" json:"publishMode"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (StaffGitHubRepo) TableName() string { return "staff_github_repos" }
|
||||||
@@ -111,6 +111,8 @@ func main() {
|
|||||||
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
|
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
|
||||||
staff.Get("/auth/github/authorize", handlers.GitHubAuthorizeURLHandler(gormDB))
|
staff.Get("/auth/github/authorize", handlers.GitHubAuthorizeURLHandler(gormDB))
|
||||||
staff.Get("/auth/github/status", handlers.GitHubStatusHandler(gormDB))
|
staff.Get("/auth/github/status", handlers.GitHubStatusHandler(gormDB))
|
||||||
|
staff.Get("/auth/github/repos", handlers.GitHubListReposHandler(gormDB))
|
||||||
|
staff.Post("/auth/github/repos/bulk-delete", handlers.GitHubBulkDeleteReposHandler(gormDB))
|
||||||
staff.Delete("/auth/github", handlers.GitHubDisconnectHandler(gormDB))
|
staff.Delete("/auth/github", handlers.GitHubDisconnectHandler(gormDB))
|
||||||
|
|
||||||
staff.Get("/stats", handlers.GetStatsHandler(gormDB))
|
staff.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||||
@@ -185,6 +187,7 @@ func main() {
|
|||||||
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
|
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/submissions/publish-git", handlers.PublishExamSubmissionsGitHandler(gormDB))
|
staff.Post("/exam-rooms/:id/submissions/publish-git", handlers.PublishExamSubmissionsGitHandler(gormDB))
|
||||||
|
staff.Post("/exam-rooms/:id/submissions/publish-git-students", handlers.PublishExamSubmissionsGitPerStudentHandler(gormDB))
|
||||||
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
|
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
|
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user