feat: improve report submission form
This commit is contained in:
parent
3c7b7096dd
commit
5434d1b283
@ -41,6 +41,7 @@ class Database:
|
||||
feishu_user_id TEXT NOT NULL,
|
||||
employee_name TEXT NOT NULL,
|
||||
report_date TEXT NOT NULL,
|
||||
report_status TEXT NOT NULL DEFAULT 'normal',
|
||||
today_done TEXT NOT NULL,
|
||||
tomorrow_plan TEXT NOT NULL,
|
||||
blockers TEXT NOT NULL DEFAULT '',
|
||||
@ -56,6 +57,13 @@ class Database:
|
||||
except sqlite3.OperationalError as error:
|
||||
if "duplicate column name" not in str(error).lower():
|
||||
raise
|
||||
try:
|
||||
self.connection.execute(
|
||||
"ALTER TABLE daily_reports ADD COLUMN report_status TEXT NOT NULL DEFAULT 'normal'"
|
||||
)
|
||||
except sqlite3.OperationalError as error:
|
||||
if "duplicate column name" not in str(error).lower():
|
||||
raise
|
||||
self.connection.commit()
|
||||
|
||||
def load_employees(self, employee_seed_path: Path) -> None:
|
||||
@ -129,15 +137,16 @@ class Database:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO daily_reports (
|
||||
feishu_user_id, employee_name, report_date, today_done, tomorrow_plan,
|
||||
feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan,
|
||||
blockers, help_needed, submitted_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
:feishu_user_id, :employee_name, :report_date, :today_done, :tomorrow_plan,
|
||||
:feishu_user_id, :employee_name, :report_date, :report_status, :today_done, :tomorrow_plan,
|
||||
:blockers, :help_needed, :submitted_at, :updated_at
|
||||
)
|
||||
ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET
|
||||
employee_name = excluded.employee_name,
|
||||
report_status = excluded.report_status,
|
||||
today_done = excluded.today_done,
|
||||
tomorrow_plan = excluded.tomorrow_plan,
|
||||
blockers = excluded.blockers,
|
||||
@ -151,7 +160,7 @@ class Database:
|
||||
with self._lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT id, feishu_user_id, employee_name, report_date, today_done,
|
||||
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
|
||||
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
|
||||
FROM daily_reports
|
||||
WHERE report_date = ?
|
||||
@ -165,7 +174,7 @@ class Database:
|
||||
with self._lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT id, feishu_user_id, employee_name, report_date, today_done,
|
||||
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
|
||||
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
|
||||
FROM daily_reports
|
||||
WHERE feishu_user_id = ?
|
||||
@ -175,3 +184,18 @@ class Database:
|
||||
(feishu_user_id, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def find_previous_report(self, feishu_user_id: str, before_date: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done,
|
||||
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
|
||||
FROM daily_reports
|
||||
WHERE feishu_user_id = ? AND report_date < ?
|
||||
ORDER BY report_date DESC, updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(feishu_user_id, before_date),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@ -22,6 +22,14 @@ def _optional_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _report_status(value: Any) -> str:
|
||||
status = str(value or "normal").strip()
|
||||
allowed = {"normal", "risk", "need_help"}
|
||||
if status not in allowed:
|
||||
raise ValueError("report_status is invalid")
|
||||
return status
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(self, database: Database, clock: Clock | None = None):
|
||||
self.database = database
|
||||
@ -42,6 +50,7 @@ class ReportService:
|
||||
"feishu_user_id": feishu_user_id,
|
||||
"employee_name": employee["name"],
|
||||
"report_date": report_date,
|
||||
"report_status": _report_status(data.get("report_status")),
|
||||
"today_done": today_done,
|
||||
"tomorrow_plan": tomorrow_plan,
|
||||
"blockers": _optional_text(data.get("blockers")),
|
||||
@ -81,6 +90,7 @@ class ReportService:
|
||||
"tomorrow_plan",
|
||||
"blockers",
|
||||
"help_needed",
|
||||
"report_status",
|
||||
"submitted_at",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
@ -99,6 +109,17 @@ class ReportService:
|
||||
"reports": self.database.list_reports_for_employee(user_id, limit),
|
||||
}
|
||||
|
||||
def previous_report_reference(self, feishu_user_id: str, before_date: str) -> dict[str, Any]:
|
||||
user_id = _required_text(feishu_user_id, "feishu_user_id")
|
||||
date = _required_text(before_date, "report_date")
|
||||
employee = self.database.find_employee(user_id)
|
||||
if not employee or employee.get("active") != 1:
|
||||
raise ValueError("employee is not active")
|
||||
return {
|
||||
"employee": employee,
|
||||
"report": self.database.find_previous_report(user_id, date),
|
||||
}
|
||||
|
||||
def is_admin(self, feishu_user_id: str) -> bool:
|
||||
user_id = _required_text(feishu_user_id, "feishu_user_id")
|
||||
employee = self.database.find_employee(user_id)
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
const state = { data: null };
|
||||
|
||||
const statusLabels = {
|
||||
normal: "正常",
|
||||
risk: "有风险",
|
||||
need_help: "需要支持"
|
||||
};
|
||||
|
||||
function text(value) {
|
||||
return String(value || "");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return text(value).replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase();
|
||||
const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase();
|
||||
@ -18,22 +34,33 @@ function render() {
|
||||
|
||||
const reports = data.reports.filter((report) => {
|
||||
const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery);
|
||||
const searchable = [report.today_done, report.tomorrow_plan, report.blockers, report.help_needed].join(" ").toLowerCase();
|
||||
const searchable = [
|
||||
report.today_done,
|
||||
report.tomorrow_plan,
|
||||
report.blockers,
|
||||
report.help_needed,
|
||||
statusLabels[report.report_status]
|
||||
].join(" ").toLowerCase();
|
||||
const keywordMatch = searchable.includes(keywordQuery);
|
||||
const blockerMatch = !onlyBlockers || report.blockers || report.help_needed;
|
||||
const blockerMatch = !onlyBlockers || report.blockers || report.help_needed || report.report_status === "need_help";
|
||||
return employeeMatch && keywordMatch && blockerMatch;
|
||||
});
|
||||
|
||||
document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => `
|
||||
<article class="report-card">
|
||||
<h3>${report.employee_name}</h3>
|
||||
<div class="report-grid">
|
||||
<div><div class="field-title">今日完成</div><div>${text(report.today_done)}</div></div>
|
||||
<div><div class="field-title">明日计划</div><div>${text(report.tomorrow_plan)}</div></div>
|
||||
<div><div class="field-title">遇到的问题</div><div>${text(report.blockers) || "无"}</div></div>
|
||||
<div><div class="field-title">需要协助</div><div>${text(report.help_needed) || "无"}</div></div>
|
||||
<div class="report-head">
|
||||
<h3>${escapeHtml(report.employee_name)}</h3>
|
||||
<span class="status-badge status-${escapeHtml(report.report_status || "normal")}">
|
||||
${statusLabels[report.report_status] || "正常"}
|
||||
</span>
|
||||
</div>
|
||||
<p>提交时间:${report.updated_at}</p>
|
||||
<div class="report-grid">
|
||||
<div><div class="field-title">今日完成</div><pre>${escapeHtml(report.today_done)}</pre></div>
|
||||
<div><div class="field-title">明日计划</div><pre>${escapeHtml(report.tomorrow_plan)}</pre></div>
|
||||
<div><div class="field-title">遇到的问题</div><pre>${escapeHtml(report.blockers || "无")}</pre></div>
|
||||
<div><div class="field-title">需要协助</div><pre>${escapeHtml(report.help_needed || "无")}</pre></div>
|
||||
</div>
|
||||
<p>提交时间:${escapeHtml(report.updated_at)}</p>
|
||||
</article>
|
||||
`).join("") : "<p>当前筛选下没有日报。</p>";
|
||||
|
||||
|
||||
@ -32,6 +32,66 @@ button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.identity-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 6px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.status-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.status-options legend { font-weight: 700; }
|
||||
.status-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.item-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.item-group h2 { margin: 0; }
|
||||
.item-group [data-list] {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.previous-plan {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.previous-plan[hidden] { display: none; }
|
||||
.previous-plan h2, .previous-plan p { margin: 0; }
|
||||
.previous-plan button { justify-self: start; }
|
||||
.report-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status-normal { background: #dcfce7; color: #166534; }
|
||||
.status-risk { background: #fef3c7; color: #92400e; }
|
||||
.status-need_help { background: #fee2e2; color: #991b1b; }
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@ -68,6 +128,6 @@ pre {
|
||||
.message.success { color: #047857; }
|
||||
.message.error { color: #b91c1c; }
|
||||
@media (max-width: 760px) {
|
||||
.topbar, .stats, .history-head { display: grid; }
|
||||
.topbar, .stats, .history-head, .report-head { display: grid; }
|
||||
.filters, .report-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@ -43,7 +43,6 @@ def page(title: str, body: str, scripts: str = "") -> bytes:
|
||||
|
||||
|
||||
def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes:
|
||||
numbered_template = "1. \n2. \n3. \n4. "
|
||||
if session:
|
||||
identity_html = f"""
|
||||
<div class="identity-box">
|
||||
@ -52,38 +51,102 @@ def submit_page(current_date: str, session: dict[str, str] | None = None) -> byt
|
||||
</div>
|
||||
<input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}">
|
||||
"""
|
||||
history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录。"
|
||||
history_hint = "系统已自动识别你的飞书身份,这里会显示你最近提交过的日报。"
|
||||
else:
|
||||
identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>'
|
||||
history_hint = "输入员工 ID 后可以查看自己最近提交的记录。"
|
||||
history_hint = "输入员工 ID 后,可以查看自己最近提交过的日报。"
|
||||
|
||||
return page(
|
||||
"每日报告",
|
||||
f"""
|
||||
<main class="shell narrow">
|
||||
<h1>每日报告</h1>
|
||||
<h1>每日工作汇报</h1>
|
||||
<form id="report-form" class="panel">
|
||||
{identity_html}
|
||||
<label>日期<input name="report_date" type="date" required value="{escape(current_date)}"></label>
|
||||
<label>今日完成<textarea name="today_done" required rows="5">{numbered_template}</textarea></label>
|
||||
<label>明日计划<textarea name="tomorrow_plan" required rows="4">{numbered_template}</textarea></label>
|
||||
<label>遇到的问题<textarea name="blockers" rows="3"></textarea></label>
|
||||
<label>需要协助<textarea name="help_needed" rows="3"></textarea></label>
|
||||
<label>日期<input id="report-date" name="report_date" type="date" required value="{escape(current_date)}"></label>
|
||||
<fieldset class="status-options">
|
||||
<legend>今日状态</legend>
|
||||
<label><input type="radio" name="report_status" value="normal" checked> 正常</label>
|
||||
<label><input type="radio" name="report_status" value="risk"> 有风险</label>
|
||||
<label><input type="radio" name="report_status" value="need_help"> 需要支持</label>
|
||||
</fieldset>
|
||||
<section class="item-group">
|
||||
<h2>今日完成</h2>
|
||||
<div data-list="today_done">
|
||||
<input placeholder="1.">
|
||||
<input placeholder="2.">
|
||||
<input placeholder="3.">
|
||||
<input placeholder="4.">
|
||||
</div>
|
||||
</section>
|
||||
<section class="item-group">
|
||||
<h2>明日计划</h2>
|
||||
<div data-list="tomorrow_plan">
|
||||
<input placeholder="1.">
|
||||
<input placeholder="2.">
|
||||
<input placeholder="3.">
|
||||
<input placeholder="4.">
|
||||
</div>
|
||||
</section>
|
||||
<label>遇到的问题<textarea name="blockers" rows="3" placeholder="没有可以不填"></textarea></label>
|
||||
<label>需要协助<textarea name="help_needed" rows="3" placeholder="没有可以不填"></textarea></label>
|
||||
<button type="submit">提交日报</button>
|
||||
<p id="form-message" class="message"></p>
|
||||
</form>
|
||||
<section id="previous-plan" class="panel previous-plan" hidden>
|
||||
<div>
|
||||
<h2>上次“明日计划”参考</h2>
|
||||
<p id="previous-plan-date"></p>
|
||||
</div>
|
||||
<pre id="previous-plan-content"></pre>
|
||||
<button id="use-previous-plan" type="button">填入今日完成</button>
|
||||
</section>
|
||||
<section class="panel history-panel">
|
||||
<div class="history-head">
|
||||
<div>
|
||||
<h2>我的历史日报</h2>
|
||||
<p>{history_hint}</p>
|
||||
</div>
|
||||
<button id="load-history" type="button">查看历史</button>
|
||||
<button id="load-history" type="button">刷新历史</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list">暂无历史记录。</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const statusLabels = {{
|
||||
normal: "正常",
|
||||
risk: "有风险",
|
||||
need_help: "需要支持"
|
||||
}};
|
||||
|
||||
function escapeHtml(value) {{
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}}[char]));
|
||||
}}
|
||||
|
||||
function collectItems(name) {{
|
||||
const values = Array.from(document.querySelectorAll(`[data-list="${{name}}"] input`))
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
return values.map((value, index) => `${{index + 1}}. ${{value}}`).join("\\n");
|
||||
}}
|
||||
|
||||
function fillItemInputs(name, text) {{
|
||||
const inputs = Array.from(document.querySelectorAll(`[data-list="${{name}}"] input`));
|
||||
const lines = String(text || "")
|
||||
.split(/\\n+/)
|
||||
.map((line) => line.replace(/^\\s*\\d+[\\.、)]\\s*/, "").trim())
|
||||
.filter(Boolean);
|
||||
inputs.forEach((input, index) => {{
|
||||
input.value = lines[index] || "";
|
||||
}});
|
||||
}}
|
||||
|
||||
function renderHistory(data) {{
|
||||
const container = document.querySelector("#history-list");
|
||||
if (!data.reports.length) {{
|
||||
@ -92,16 +155,16 @@ function renderHistory(data) {{
|
||||
}}
|
||||
container.innerHTML = data.reports.map((report) => `
|
||||
<article class="history-card">
|
||||
<h3>${{report.report_date}}</h3>
|
||||
<div><strong>今日完成</strong><pre>${{report.today_done}}</pre></div>
|
||||
<div><strong>明日计划</strong><pre>${{report.tomorrow_plan}}</pre></div>
|
||||
<div><strong>问题</strong><pre>${{report.blockers || "无"}}</pre></div>
|
||||
<div><strong>协助</strong><pre>${{report.help_needed || "无"}}</pre></div>
|
||||
<h3>${{escapeHtml(report.report_date)}} · ${{statusLabels[report.report_status] || "正常"}}</h3>
|
||||
<div><strong>今日完成</strong><pre>${{escapeHtml(report.today_done)}}</pre></div>
|
||||
<div><strong>明日计划</strong><pre>${{escapeHtml(report.tomorrow_plan)}}</pre></div>
|
||||
<div><strong>问题</strong><pre>${{escapeHtml(report.blockers || "无")}}</pre></div>
|
||||
<div><strong>协助</strong><pre>${{escapeHtml(report.help_needed || "无")}}</pre></div>
|
||||
</article>
|
||||
`).join("");
|
||||
}}
|
||||
|
||||
document.querySelector("#load-history").addEventListener("click", async () => {{
|
||||
async function loadHistory() {{
|
||||
const userId = document.querySelector("#feishu-user-id").value.trim();
|
||||
const container = document.querySelector("#history-list");
|
||||
if (!userId) {{
|
||||
@ -115,11 +178,42 @@ document.querySelector("#load-history").addEventListener("click", async () => {{
|
||||
const result = await response.json();
|
||||
container.textContent = result.error || "历史记录加载失败。";
|
||||
}}
|
||||
}}
|
||||
|
||||
async function loadPreviousPlan() {{
|
||||
const userId = document.querySelector("#feishu-user-id").value.trim();
|
||||
const reportDate = document.querySelector("#report-date").value;
|
||||
const panel = document.querySelector("#previous-plan");
|
||||
if (!userId || !reportDate) {{
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}}
|
||||
const response = await fetch(`/api/reports/previous?feishu_user_id=${{encodeURIComponent(userId)}}&date=${{encodeURIComponent(reportDate)}}`);
|
||||
if (!response.ok) {{
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}}
|
||||
const result = await response.json();
|
||||
if (!result.report) {{
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}}
|
||||
document.querySelector("#previous-plan-date").textContent = `${{result.report.report_date}} 提交`;
|
||||
document.querySelector("#previous-plan-content").textContent = result.report.tomorrow_plan;
|
||||
panel.hidden = false;
|
||||
}}
|
||||
|
||||
document.querySelector("#load-history").addEventListener("click", loadHistory);
|
||||
document.querySelector("#report-date").addEventListener("change", loadPreviousPlan);
|
||||
document.querySelector("#use-previous-plan").addEventListener("click", () => {{
|
||||
fillItemInputs("today_done", document.querySelector("#previous-plan-content").textContent);
|
||||
}});
|
||||
|
||||
document.querySelector("#report-form").addEventListener("submit", async (event) => {{
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
data.today_done = collectItems("today_done");
|
||||
data.tomorrow_plan = collectItems("tomorrow_plan");
|
||||
const response = await fetch("/api/reports", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
@ -129,13 +223,17 @@ document.querySelector("#report-form").addEventListener("submit", async (event)
|
||||
if (response.ok) {{
|
||||
message.textContent = "提交成功。";
|
||||
message.className = "message success";
|
||||
document.querySelector("#load-history").click();
|
||||
loadHistory();
|
||||
loadPreviousPlan();
|
||||
}} else {{
|
||||
const result = await response.json();
|
||||
message.textContent = result.error || "提交失败。";
|
||||
message.className = "message error";
|
||||
}}
|
||||
}});
|
||||
|
||||
loadHistory();
|
||||
loadPreviousPlan();
|
||||
</script>""",
|
||||
)
|
||||
|
||||
@ -177,7 +275,7 @@ def forbidden_page() -> bytes:
|
||||
<main class="shell narrow">
|
||||
<section class="panel">
|
||||
<h1>无权限</h1>
|
||||
<p>你没有查看日报汇总的权限。请联系管理员开通。</p>
|
||||
<p>你没有查看日报汇总的权限,请联系管理员开通。</p>
|
||||
<p><a href="/submit">返回日报填写页</a></p>
|
||||
</section>
|
||||
</main>""",
|
||||
@ -300,6 +398,14 @@ class DailyReportHandler(BaseHTTPRequestHandler):
|
||||
except ValueError as error:
|
||||
self._json(400, {"error": str(error)})
|
||||
return
|
||||
if parsed.path == "/api/reports/previous":
|
||||
user_id = query.get("feishu_user_id", [""])[0]
|
||||
report_date = query.get("date", [today_string()])[0]
|
||||
try:
|
||||
self._json(200, self.report_service.previous_report_reference(user_id, report_date))
|
||||
except ValueError as error:
|
||||
self._json(400, {"error": str(error)})
|
||||
return
|
||||
if parsed.path == "/api/reports/export":
|
||||
if not self._require_admin():
|
||||
return
|
||||
|
||||
@ -42,7 +42,7 @@ class ReportServiceTest(unittest.TestCase):
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "",
|
||||
"tomorrow_plan": "完成管理页",
|
||||
"tomorrow_plan": "完善管理页",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
@ -56,29 +56,32 @@ class ReportServiceTest(unittest.TestCase):
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成原型初稿",
|
||||
"tomorrow_plan": "和主管评审",
|
||||
"report_status": "risk",
|
||||
"today_done": "1. 完成原型初稿",
|
||||
"tomorrow_plan": "1. 和主管评审",
|
||||
}
|
||||
)
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成原型初稿并调整文案",
|
||||
"tomorrow_plan": "和主管评审",
|
||||
"report_status": "need_help",
|
||||
"today_done": "1. 完成原型初稿\n2. 调整文案",
|
||||
"tomorrow_plan": "1. 和主管评审",
|
||||
"blockers": "需要确认 logo",
|
||||
"help_needed": "确认视觉方向",
|
||||
}
|
||||
)
|
||||
result = service.list_reports_for_date("2026-05-07")
|
||||
self.assertEqual(len(result["reports"]), 1)
|
||||
self.assertEqual(result["reports"][0]["today_done"], "完成原型初稿并调整文案")
|
||||
self.assertEqual(result["reports"][0]["report_status"], "need_help")
|
||||
self.assertEqual(result["reports"][0]["today_done"], "1. 完成原型初稿\n2. 调整文案")
|
||||
self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo")
|
||||
self.assertEqual(len(result["missing"]), 0)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_returns_missing_active_employees_and_csv_export(self) -> None:
|
||||
def test_returns_missing_active_employees_csv_and_previous_reference(self) -> None:
|
||||
db = make_database(
|
||||
[
|
||||
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True},
|
||||
@ -87,12 +90,20 @@ class ReportServiceTest(unittest.TestCase):
|
||||
)
|
||||
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
|
||||
try:
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-06",
|
||||
"today_done": "1. 完成上线检查清单",
|
||||
"tomorrow_plan": "1. 准备发布",
|
||||
}
|
||||
)
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成上线检查清单",
|
||||
"tomorrow_plan": "准备发布",
|
||||
"today_done": "1. 准备发布",
|
||||
"tomorrow_plan": "1. 复盘问题",
|
||||
}
|
||||
)
|
||||
result = service.list_reports_for_date("2026-05-07")
|
||||
@ -100,8 +111,12 @@ class ReportServiceTest(unittest.TestCase):
|
||||
self.assertEqual(result["missing"][0]["name"], "Chen")
|
||||
|
||||
csv_text = service.export_reports_csv("2026-05-07")
|
||||
self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,submitted_at", csv_text)
|
||||
self.assertIn("Lin,2026-05-07,完成上线检查清单,准备发布", csv_text)
|
||||
self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,report_status,submitted_at", csv_text)
|
||||
self.assertIn("Lin,2026-05-07,1. 准备发布,1. 复盘问题,,,normal,", csv_text)
|
||||
|
||||
reference = service.previous_report_reference("u_1", "2026-05-07")
|
||||
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
|
||||
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 准备发布")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@ -52,19 +53,23 @@ def get_with_cookie(url: str, cookie: str) -> tuple[int, str]:
|
||||
return response.status, response.read().decode("utf-8")
|
||||
|
||||
|
||||
def admin_cookie(user_id: str = "u_1", name: str = "Lin") -> str:
|
||||
return "daily_report_session=" + create_session_cookie(
|
||||
{"feishu_user_id": user_id, "name": name}, "session-secret"
|
||||
)
|
||||
|
||||
|
||||
class WebTest(unittest.TestCase):
|
||||
def test_serves_submit_and_manager_pages(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
status, submit = get(f"{base_url}/submit")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("每日报告", submit)
|
||||
self.assertIn("1. \n2. \n3. \n4.", submit)
|
||||
self.assertIn("每日工作汇报", submit)
|
||||
self.assertIn("今日状态", submit)
|
||||
self.assertIn('data-list="today_done"', submit)
|
||||
self.assertIn("我的历史日报", submit)
|
||||
|
||||
admin_cookie = "daily_report_session=" + create_session_cookie(
|
||||
{"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"
|
||||
)
|
||||
db.upsert_employee(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
@ -75,7 +80,7 @@ class WebTest(unittest.TestCase):
|
||||
"role": "admin",
|
||||
}
|
||||
)
|
||||
status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie)
|
||||
status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("日报浏览", manager)
|
||||
finally:
|
||||
@ -83,24 +88,42 @@ class WebTest(unittest.TestCase):
|
||||
server.server_close()
|
||||
db.close()
|
||||
|
||||
def test_accepts_report_submission_and_returns_summary(self) -> None:
|
||||
def test_accepts_report_submission_and_returns_summary_and_previous_reference(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
first_request = urllib.request.Request(
|
||||
f"{base_url}/api/reports",
|
||||
data=json.dumps(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成 API",
|
||||
"tomorrow_plan": "完善界面",
|
||||
"report_date": "2026-05-06",
|
||||
"today_done": "1. 完成 API",
|
||||
"tomorrow_plan": "1. 完善界面",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
with urllib.request.urlopen(first_request, timeout=5) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
second_request = urllib.request.Request(
|
||||
f"{base_url}/api/reports",
|
||||
data=json.dumps(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"report_status": "need_help",
|
||||
"today_done": "1. 完善界面",
|
||||
"tomorrow_plan": "1. 上线测试",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(second_request, timeout=5) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
db.upsert_employee(
|
||||
@ -113,20 +136,24 @@ class WebTest(unittest.TestCase):
|
||||
"role": "admin",
|
||||
}
|
||||
)
|
||||
admin_cookie = "daily_report_session=" + create_session_cookie(
|
||||
{"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"
|
||||
)
|
||||
status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie)
|
||||
status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie())
|
||||
summary = json.loads(body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(summary["submittedCount"], 1)
|
||||
self.assertEqual(summary["reports"][0]["today_done"], "完成 API")
|
||||
self.assertEqual(summary["reports"][0]["today_done"], "1. 完善界面")
|
||||
self.assertEqual(summary["reports"][0]["report_status"], "need_help")
|
||||
|
||||
status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1")
|
||||
history = json.loads(history_body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(history["employee"]["name"], "Lin")
|
||||
self.assertEqual(history["reports"][0]["tomorrow_plan"], "完善界面")
|
||||
self.assertEqual(history["reports"][0]["tomorrow_plan"], "1. 上线测试")
|
||||
|
||||
status, reference_body = get(f"{base_url}/api/reports/previous?feishu_user_id=u_1&date=2026-05-07")
|
||||
reference = json.loads(reference_body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(reference["report"]["report_date"], "2026-05-06")
|
||||
self.assertEqual(reference["report"]["tomorrow_plan"], "1. 完善界面")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
@ -135,18 +162,8 @@ class WebTest(unittest.TestCase):
|
||||
def test_submit_page_uses_logged_in_feishu_identity(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [
|
||||
(
|
||||
"Cookie",
|
||||
"daily_report_session="
|
||||
+ create_session_cookie({"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"),
|
||||
)
|
||||
]
|
||||
with opener.open(f"{base_url}/submit", timeout=5) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
status, body = get_with_cookie(f"{base_url}/submit", admin_cookie())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("Lin", body)
|
||||
self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body)
|
||||
self.assertNotIn("员工 ID<input", body)
|
||||
@ -163,17 +180,12 @@ class WebTest(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
try:
|
||||
staff_cookie = "daily_report_session=" + create_session_cookie(
|
||||
{"feishu_user_id": "u_staff", "name": "Staff"}, "session-secret"
|
||||
)
|
||||
staff_cookie = admin_cookie("u_staff", "Staff")
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
get_with_cookie(f"{base_url}/manager", staff_cookie)
|
||||
self.assertEqual(error.exception.code, 403)
|
||||
|
||||
admin_cookie = "daily_report_session=" + create_session_cookie(
|
||||
{"feishu_user_id": "u_admin", "name": "Admin"}, "session-secret"
|
||||
)
|
||||
status, body = get_with_cookie(f"{base_url}/manager", admin_cookie)
|
||||
status, body = get_with_cookie(f"{base_url}/manager", admin_cookie("u_admin", "Admin"))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("日报浏览", body)
|
||||
finally:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user