Compare commits

...

11 Commits

Author SHA1 Message Date
14154fb59b
fix: typo
All checks were successful
build / trigger-build-image (push) Successful in 21s
2025-08-03 14:40:41 -07:00
cb5735ac40
feat: remove checkout dest lock
All checks were successful
build / trigger-build-image (push) Successful in 10s
2025-08-03 14:40:22 -07:00
083140079e
feat: remove current local head lock
All checks were successful
build / trigger-build-image (push) Successful in 18s
2025-08-03 14:39:09 -07:00
aa9a69eaf1
feat: support git ref as scoreboard column
All checks were successful
build / trigger-build-image (push) Successful in 10s
2025-07-27 03:26:24 -07:00
2d7aba5ce0
fix: typo
All checks were successful
build / trigger-build-image (push) Successful in 11s
2025-07-27 03:09:10 -07:00
0b45898b91
feat: repo name in scoreboard
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-07-27 02:42:00 -07:00
353797323d
feat(canvas): export all users
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-07-03 08:46:05 -04:00
beeb45709f
fix: penalty factor calculation
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-06-30 22:49:26 -04:00
54a4f404fe
fix: penalty for negative score
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-06-30 08:09:07 -04:00
9e31fc71be
feat: support --issue-label-exclusive
All checks were successful
build / trigger-build-image (push) Successful in 12s
2025-06-25 04:59:23 -04:00
c3b053f0a5
fix: total score penalty warning
All checks were successful
build / trigger-build-image (push) Successful in 11s
2025-06-24 09:31:39 -04:00
5 changed files with 62 additions and 28 deletions

View File

@ -34,9 +34,9 @@ class Tea:
tea = Tea() # lazy loader tea = Tea() # lazy loader
@app.command("export-students", help="export students from canvas to csv file") @app.command("export-users", help="export users from canvas to csv file")
def export_students_to_csv(output_file: Path) -> None: def export_users_to_csv(output_file: Path = Argument("students.csv")) -> None:
tea.pot.canvas.export_students_to_csv(output_file) tea.pot.canvas.export_users_to_csv(output_file)
@app.command( @app.command(
@ -295,6 +295,10 @@ def joj3_all_env(
False, False,
help="skip creating failed table on gitea", help="skip creating failed table on gitea",
), ),
scoreboard_column_by_ref: bool = Option(
False,
help="use git ref as scoreboard column name",
),
submitter_in_issue_title: bool = Option( submitter_in_issue_title: bool = Option(
True, True,
help="whether to include submitter in issue title", help="whether to include submitter in issue title",
@ -307,6 +311,10 @@ def joj3_all_env(
"#795548", "#795548",
help="label color for the issue created by this command", help="label color for the issue created by this command",
), ),
issue_label_exclusive: bool = Option(
False,
help="label set as exclusive for the issue created by this command",
),
end_time: Optional[datetime] = Option(None), end_time: Optional[datetime] = Option(None),
penalty_config: str = Option( penalty_config: str = Option(
"", "",
@ -360,6 +368,7 @@ def joj3_all_env(
submitter_repo_name, submitter_repo_name,
issue_label_name, issue_label_name,
issue_label_color, issue_label_color,
issue_label_exclusive,
penalty_factor, penalty_factor,
) )
res["issue"] = issue_number res["issue"] = issue_number
@ -398,11 +407,15 @@ def joj3_all_env(
raise Exit(code=1) raise Exit(code=1)
repo.git.reset("--hard", "origin/grading") repo.git.reset("--hard", "origin/grading")
if not skip_scoreboard: if not skip_scoreboard:
exercise_name = env.joj3_conf_name
if scoreboard_column_by_ref:
exercise_name = env.github_ref
joj3.generate_scoreboard( joj3.generate_scoreboard(
env.joj3_output_path, env.joj3_output_path,
env.github_actor, env.github_actor,
os.path.join(repo_path, scoreboard_filename), os.path.join(repo_path, scoreboard_filename),
env.joj3_conf_name, exercise_name,
submitter_repo_name,
) )
tea.pot.git.add_commit( tea.pot.git.add_commit(
grading_repo_name, grading_repo_name,

View File

@ -240,6 +240,7 @@ class Teapot:
submitter_repo_name: str, submitter_repo_name: str,
issue_label_name: str, issue_label_name: str,
issue_label_color: str, issue_label_color: str,
issue_label_exclusive: bool,
penalty_factor: float, penalty_factor: float,
) -> int: ) -> int:
title, comment = joj3.generate_title_and_comment( title, comment = joj3.generate_title_and_comment(
@ -280,7 +281,11 @@ class Teapot:
label = self.gitea.issue_api.issue_create_label( label = self.gitea.issue_api.issue_create_label(
self.gitea.org_name, self.gitea.org_name,
submitter_repo_name, submitter_repo_name,
body={"name": issue_label_name, "color": issue_label_color}, body={
"name": issue_label_name,
"color": issue_label_color,
"exclusive": issue_label_exclusive,
},
) )
label_id = label.id label_id = label.id
joj3_issue = self.gitea.issue_api.issue_create_issue( joj3_issue = self.gitea.issue_api.issue_create_issue(
@ -325,7 +330,7 @@ class Teapot:
f"[{end_time + timedelta(seconds=1)}, {penalty_end_time}].\n", f"[{end_time + timedelta(seconds=1)}, {penalty_end_time}].\n",
True, True,
) )
else: elif now > end_time:
return ( return (
"### Submission Time Check Passed\n" "### Submission Time Check Passed\n"
f"Current time {now} is not in the valid range " f"Current time {now} is not in the valid range "

View File

@ -40,6 +40,7 @@ def generate_scoreboard(
submitter: str, submitter: str,
scoreboard_file_path: str, scoreboard_file_path: str,
exercise_name: str, exercise_name: str,
submitter_repo_name: str,
) -> None: ) -> None:
if not scoreboard_file_path.endswith(".csv"): if not scoreboard_file_path.endswith(".csv"):
logger.error( logger.error(
@ -48,18 +49,25 @@ def generate_scoreboard(
return return
os.makedirs(os.path.dirname(scoreboard_file_path), exist_ok=True) os.makedirs(os.path.dirname(scoreboard_file_path), exist_ok=True)
# Load the csv file if it already exists # 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): if os.path.exists(scoreboard_file_path):
with open(scoreboard_file_path, newline="") as file: with open(scoreboard_file_path, newline="") as file:
reader = csv.reader(file) reader = csv.reader(file)
rows = list(reader) rows = list(reader)
columns = rows[0] columns = rows[0]
data = rows[1:] 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: else:
columns = [ columns = fixed_headers
"",
"last_edit",
"total",
]
data = [] data = []
submitter_found = False submitter_found = False
@ -69,8 +77,7 @@ def generate_scoreboard(
submitter_found = True submitter_found = True
break break
if not submitter_found: if not submitter_found:
fixed_columns = [submitter, "", "0"] submitter_row = fixed_defaults + [""] * (len(columns) - len(fixed_headers))
submitter_row = fixed_columns + [""] * (len(columns) - len(fixed_columns))
data.append(submitter_row) data.append(submitter_row)
# Update data # Update data
@ -85,9 +92,9 @@ def generate_scoreboard(
exercise_name = comment.split("-")[0] exercise_name = comment.split("-")[0]
# Find if exercise in table: # Find if exercise in table:
if exercise_name not in columns: if exercise_name not in columns:
column_tail = columns[3:] column_tail = columns[len(fixed_headers) :]
bisect.insort(column_tail, exercise_name) bisect.insort(column_tail, exercise_name)
columns[3:] = column_tail columns[len(fixed_headers) :] = column_tail
index = columns.index(exercise_name) index = columns.index(exercise_name)
for row in data: for row in data:
row.insert(index, "") row.insert(index, "")
@ -101,17 +108,22 @@ def generate_scoreboard(
total = 0 total = 0
for col in columns: for col in columns:
if col in ["", "total", "last_edit"]: if col in fixed_headers:
continue continue
idx = columns.index(col) idx = columns.index(col)
if (submitter_row[idx] is not None) and (submitter_row[idx] != ""): if (submitter_row[idx] is not None) and (submitter_row[idx] != ""):
total += int(submitter_row[idx]) try:
total += int(submitter_row[idx])
except ValueError:
pass
submitter_row[columns.index("total")] = str(total) submitter_row[columns.index("total")] = str(total)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
submitter_row[columns.index("last_edit")] = now submitter_row[columns.index("last_edit")] = now
submitter_row[columns.index("repo_name")] = submitter_repo_name
# Sort data by total, from low to high # Sort data by total, from low to high
data.sort(key=lambda x: int(x[columns.index("total")])) data.sort(key=lambda x: int(x[columns.index("total")]))
@ -236,9 +248,7 @@ def generate_title_and_comment(
"[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n" "[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n"
) )
if penalty_factor != 1.0: if penalty_factor != 1.0:
comment += ( comment += f"## ⚠Total Score Penalty Warning⚠\n**The total score is multiplied by {penalty_factor}.**\n"
f"## Total Score Penalty Warning\nThe total score is multiplied by 0.75.\n"
)
for stage in stages: for stage in stages:
if all( if all(
result["score"] == 0 and result["comment"].strip() == "" result["score"] == 0 and result["comment"].strip() == ""
@ -260,7 +270,7 @@ def generate_title_and_comment(
total_score += result["score"] total_score += result["score"]
comment += "\n" comment += "\n"
if penalty_factor != 1.0: if penalty_factor != 1.0:
total_score = round(total_score * penalty_factor) total_score = round(total_score - abs(total_score) * (1 - penalty_factor))
title = get_title_prefix(exercise_name, submitter, submitter_in_title) title = get_title_prefix(exercise_name, submitter, submitter_in_title)
if max_total_score >= 0: if max_total_score >= 0:
title += f"{total_score} / {max_total_score}" title += f"{total_score} / {max_total_score}"
@ -307,8 +317,10 @@ def get_penalty_factor(
) -> float: ) -> float:
if not end_time or not penalty_config: if not end_time or not penalty_config:
return 1.0 return 1.0
penalties = parse_penalty_config(penalty_config)
now = datetime.now() now = datetime.now()
if now < end_time:
return 1.0
penalties = parse_penalty_config(penalty_config)
res = 0.0 res = 0.0
for hour, factor in penalties[::-1]: for hour, factor in penalties[::-1]:
if now < end_time + timedelta(hours=hour): if now < end_time + timedelta(hours=hour):

View File

@ -35,7 +35,7 @@ class Canvas:
# types = ["student", "observer"] # types = ["student", "observer"]
types = ["student"] types = ["student"]
def patch_student(student: User) -> User: def patch_user(student: User) -> User:
student.name = ( student.name = (
re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title() re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title()
) # We only care english name ) # We only care english name
@ -44,7 +44,7 @@ class Canvas:
return student return student
self.students = [ self.students = [
patch_student(student) patch_user(student)
for student in self.course.get_users(enrollment_type=types) for student in self.course.get_users(enrollment_type=types)
] ]
for attr in ["login_id", "name"]: for attr in ["login_id", "name"]:
@ -52,6 +52,7 @@ class Canvas:
raise Exception( raise Exception(
f"Unable to gather students' {attr}, please contact the Canvas site admin" f"Unable to gather students' {attr}, please contact the Canvas site admin"
) )
self.users = [patch_user(student) for student in self.course.get_users()]
logger.debug("Canvas students loaded") logger.debug("Canvas students loaded")
self.assignments = self.course.get_assignments() self.assignments = self.course.get_assignments()
logger.debug("Canvas assignments loaded") logger.debug("Canvas assignments loaded")
@ -60,12 +61,12 @@ class Canvas:
self.grade_filename = grade_filename self.grade_filename = grade_filename
logger.debug("Canvas initialized") logger.debug("Canvas initialized")
def export_students_to_csv(self, filename: Path) -> None: def export_users_to_csv(self, filename: Path) -> None:
with open(filename, mode="w", newline="") as file: with open(filename, mode="w", newline="") as file:
writer = csv.writer(file) writer = csv.writer(file)
for student in self.students: for user in self.users:
writer.writerow([student.name, student.sis_id, student.login_id]) writer.writerow([user.name, user.sis_id, user.login_id])
logger.info(f"Students exported to {filename}") logger.info(f"Users exported to {filename}")
def prepare_assignment_dir( def prepare_assignment_dir(
self, dir_or_zip_file: str, create_grade_file: bool = True self, dir_or_zip_file: str, create_grade_file: bool = True

View File

@ -103,7 +103,10 @@ class Git:
"logs/HEAD.lock", "logs/HEAD.lock",
"packed-refs.lock", "packed-refs.lock",
"config.lock", "config.lock",
f"refs/heads/{current_branch}.lock",
f"refs/remotes/origin/{current_branch}.lock", f"refs/remotes/origin/{current_branch}.lock",
f"refs/heads/{checkout_dest}.lock",
f"refs/remotes/origin/{checkout_dest}.lock",
] ]
for lock_file in lock_files: for lock_file in lock_files:
lock_path = os.path.join(repo_dir, ".git", lock_file) lock_path = os.path.join(repo_dir, ".git", lock_file)