Joint-Teapot/joint_teapot/utils/joj3.py
姚云翔523370910110 2ac29a6d89
Some checks failed
build / trigger-build-image (push) Has been cancelled
feat: support exporting Canvas group members with group info (#7)
## Summary

Add an optional `--group` argument to `export-users`.

When `--group` is not provided, the command keeps the original behavior and exports all Canvas users as:

`name,sis_id,login_id`

When `--group <group_set_prefix>` is provided, the command looks up the corresponding Canvas group set, exports only users in that group set, and appends the normalized group name as the fourth column:

`name,sis_id,login_id,group_name`

For example, if the group set is `p2team`, the exported group names will be formatted as `p2team01`, `p2team02`, `p2team10`, etc.

## Changes

- Add optional `--group` support to `export-users`
- Look up Canvas group sets by exact name first, then by prefix
- Export only members in the matched group set when `--group` is used
- Append the user's group name as the fourth CSV column
- Normalize Canvas group names like `p1team 1` and `p1team10` into `p1team01` and `p1team10`
- Sort exported rows by group number in ascending order
- Handle invalid input more gracefully:
  - if no matching group set is found, exit cleanly with an error log
  - if a Canvas group name does not follow the expected `group_set_name + number` pattern, exit cleanly with an error log

## Behavior

### Default

`joint-teapot export-users students.csv`

Output format:

`name,sis_id,login_id`

### With group export

`joint-teapot export-users p2.csv --group p2team`

Output format:

`name,sis_id,login_id,p2team01`

## Notes

This change keeps the original export behavior unchanged unless `--group` is explicitly provided.

Reviewed-on: #7
Reviewed-by: 张泊明518370910136 <bomingzh@sjtu.edu.cn>
Co-authored-by: egghead_yao <egghead_yao@sjtu.edu.cn>
Co-committed-by: egghead_yao <egghead_yao@sjtu.edu.cn>
2026-06-22 18:10:04 +08:00

332 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import bisect
import csv
import json
import os
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from pydantic_settings import BaseSettings
from joint_teapot.config import settings
from joint_teapot.utils.logger import logger
class Env(BaseSettings):
github_actor: str = ""
github_repository: str = ""
github_sha: str = ""
github_ref: str = ""
github_workflow: str = ""
github_run_number: str = "0"
joj3_conf_name: str = ""
joj3_groups: str = ""
joj3_run_id: str = ""
joj3_commit_msg: str = ""
joj3_force_quit_stage_name: str = ""
joj3_output_path: str = ""
def get_total_score(score_file_path: str) -> int:
with open(score_file_path) as json_file:
stages: List[Dict[str, Any]] = json.load(json_file)
total_score = 0
for stage in stages:
for result in stage["results"]:
total_score += result["score"]
return total_score
def generate_scoreboard(
score_file_path: str,
submitter: str,
scoreboard_file_path: str,
exercise_name: str,
submitter_repo_name: str,
exercise_total_score: int,
) -> None:
if not scoreboard_file_path.endswith(".csv"):
logger.error(
f"Scoreboard file should be a .csv file, but now it is {scoreboard_file_path}"
)
return
os.makedirs(os.path.dirname(scoreboard_file_path), exist_ok=True)
# Load the csv file if it already exists
fixed_headers = ["", "repo_name", "last_edit", "total"]
fixed_defaults = [submitter, submitter_repo_name, "", "0"]
if os.path.exists(scoreboard_file_path):
with open(scoreboard_file_path, newline="") as file:
reader = csv.reader(file)
rows = list(reader)
columns = rows[0]
data = rows[1:]
def migrate_scoreboard() -> None:
if "repo_name" in columns:
return
columns.insert(1, "repo_name")
for row in data:
row.insert(1, "")
migrate_scoreboard()
else:
columns = fixed_headers
data = []
submitter_found = False
for row in data:
if row[0] == submitter:
submitter_row = row # This is a reference of the original data
submitter_found = True
break
if not submitter_found:
submitter_row = fixed_defaults + [""] * (len(columns) - len(fixed_headers))
data.append(submitter_row)
# Update data
with open(score_file_path) as json_file:
stages: List[Dict[str, Any]] = json.load(json_file)
if exercise_name == "unknown":
for stage in stages:
if stage["name"] != "metadata":
continue
comment = stage["results"][0]["comment"]
exercise_name = comment.split("-")[0]
# Find if exercise in table:
if exercise_name not in columns:
column_tail = columns[len(fixed_headers) :]
bisect.insort(column_tail, exercise_name)
columns[len(fixed_headers) :] = column_tail
index = columns.index(exercise_name)
for row in data:
row.insert(index, "")
submitter_row[columns.index(exercise_name)] = str(exercise_total_score)
total = 0
for col in columns:
if col in fixed_headers:
continue
idx = columns.index(col)
if (submitter_row[idx] is not None) and (submitter_row[idx] != ""):
try:
total += int(submitter_row[idx])
except ValueError:
pass
submitter_row[columns.index("total")] = str(total)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
submitter_row[columns.index("last_edit")] = now
submitter_row[columns.index("repo_name")] = submitter_repo_name
# Sort data by total, from low to high
data.sort(key=lambda x: int(x[columns.index("total")]))
# Write back to the csv file:
with open(scoreboard_file_path, mode="w", newline="") as file:
writer = csv.writer(file)
writer.writerow(columns)
writer.writerows(data)
def get_failed_table_from_file(table_file_path: str) -> List[List[str]]:
data: List[List[str]] = []
if os.path.exists(table_file_path):
with open(table_file_path) as table_file:
for i, line in enumerate(table_file):
if i < 2:
continue
stripped_line = line.strip().strip("|").split("|")
data.append(stripped_line)
return data
def get_failed_stage_from_file(score_file_path: str) -> str:
with open(score_file_path) as json_file:
stages: List[Dict[str, Any]] = json.load(json_file)
failed_stage = ""
for stage in stages:
if stage["force_quit"] == True:
failed_stage = stage["name"]
break
return failed_stage
def update_failed_table_from_score_file(
data: List[List[str]],
score_file_path: str,
repo_name: str,
repo_link: str,
action_link: str,
) -> None:
failed_stage = get_failed_stage_from_file(score_file_path)
# append to failed table
now = datetime.now().strftime("%Y-%m-%d %H:%M")
repo = f"[{repo_name}]({repo_link})"
failure = f"[{failed_stage}]({action_link})"
row_found = False
for i, row in enumerate(data[:]):
if row[1] == repo:
row_found = True
if failed_stage == "":
data.remove(row)
else:
data[i][0] = now
data[i][2] = failure
break
if not row_found and failed_stage != "":
data.append([now, repo, failure])
def write_failed_table_into_file(data: List[List[str]], table_file_path: str) -> None:
data.sort(key=lambda x: datetime.strptime(x[0], "%Y-%m-%d %H:%M"), reverse=True)
text = "|date|repository|failure|\n"
text += "|----|----|----|\n"
for row in data:
text += f"|{row[0]}|{row[1]}|{row[2]}|\n"
os.makedirs(os.path.dirname(table_file_path), exist_ok=True)
with open(table_file_path, "w") as table_file:
table_file.write(text)
def generate_failed_table(
score_file_path: str,
repo_name: str,
repo_link: str,
table_file_path: str,
action_link: str,
) -> None:
if not table_file_path.endswith(".md"):
logger.error(
f"Failed table file should be a .md file, but now it is {table_file_path}"
)
return
data = get_failed_table_from_file(table_file_path)
update_failed_table_from_score_file(
data,
score_file_path,
repo_name,
repo_link,
action_link,
)
write_failed_table_into_file(data, table_file_path)
def generate_title_and_comment(
score_file_path: str,
action_link: str,
run_number: str,
exercise_name: str,
submitter: str,
commit_hash: str,
submitter_in_title: bool = True,
run_id: str = "unknown",
max_total_score: int = -1,
penalty_factor: float = 1.0,
) -> Tuple[str, str]:
with open(score_file_path) as json_file:
stages: List[Dict[str, Any]] = json.load(json_file)
if exercise_name == "unknown":
for stage in stages:
if stage["name"] != "metadata":
continue
comment = stage["results"][0]["comment"]
exercise_name = comment.split("-")[0]
total_score = 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
comment = (
f"Generated at {now} from [Gitea Actions #{run_number}]({action_link}), "
f"commit {commit_hash}, "
f"triggered by @{submitter}, "
f"run ID [`{run_id}`](https://focs.gc.sjtu.edu.cn/joj-mon/d/{settings.gitea_org_name}?var-Filters=RunID%7C%3D%7C{run_id}).\n"
"Powered by [JOJ3](https://github.com/joint-online-judge/JOJ3) and "
"[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n"
)
if penalty_factor != 1.0:
comment += f"## ⚠Total Score Penalty Warning⚠\n**The total score is multiplied by {penalty_factor}.**\n"
for stage in stages:
if all(
result["score"] == 0 and result["comment"].strip() == ""
for result in stage["results"]
):
continue
stage_score = sum(result["score"] for result in stage["results"])
comment += f"## {stage['name']} - Score: {stage_score}"
force_quit = stage["force_quit"]
if force_quit:
comment += " - Fatal Error"
comment += "\n"
for i, result in enumerate(stage["results"]):
comment += "<details>\n"
comment += f"<summary>Case {i} - Score: {result['score']}</summary>\n"
if result["comment"].strip() != "":
comment += f"\n{result['comment']}\n\n"
comment += "</details>\n\n"
total_score += result["score"]
comment += "\n"
if penalty_factor != 1.0:
total_score = round(total_score - abs(total_score) * (1 - penalty_factor))
title = get_title_prefix(exercise_name, submitter, submitter_in_title)
if max_total_score >= 0:
title += f"{total_score} / {max_total_score}"
else:
title += f"{total_score}"
return title, comment
def check_skipped(score_file_path: str, keyword: str) -> bool:
with open(score_file_path) as json_file:
stages: List[Dict[str, Any]] = json.load(json_file)
for stage in stages:
if stage["name"] != "metadata":
continue
comment = stage["results"][0]["comment"]
if keyword in comment or "skip-teapot" in comment:
return True
return False
def get_title_prefix(
exercise_name: str, submitter: str, submitter_in_title: bool
) -> str:
title = f"JOJ3 Result for {exercise_name} by @{submitter} - Score: "
if not submitter_in_title:
title = f"JOJ3 Result for {exercise_name} - Score: "
return title
def parse_penalty_config(penalty_config: str) -> List[Tuple[float, float]]:
res = []
for penalty in penalty_config.split(","):
if "=" not in penalty:
continue
hour, factor = map(float, penalty.split("="))
res.append((hour, factor))
res.sort(key=lambda x: x[0])
return res
def get_penalty_factor(
end_time: Optional[datetime],
penalty_config: str,
) -> float:
if not end_time or not penalty_config:
return 1.0
now = datetime.now()
if now < end_time:
return 1.0
penalties = parse_penalty_config(penalty_config)
res = 0.0
for hour, factor in penalties[::-1]:
if now < end_time + timedelta(hours=hour):
res = factor
else:
break
return res