Compare commits

...

1 Commits

Author SHA1 Message Date
2ac29a6d89 feat: support exporting Canvas group members with group info (#7)
Some checks failed
build / trigger-build-image (push) Has been cancelled
## 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
4 changed files with 84 additions and 8 deletions

View File

@ -35,8 +35,17 @@ tea = Tea() # lazy loader
@app.command("export-users", help="export users from canvas to csv file") @app.command("export-users", help="export users from canvas to csv file")
def export_users_to_csv(output_file: Path = Argument("students.csv")) -> None: def export_users_to_csv(
tea.pot.canvas.export_users_to_csv(output_file) output_file: Path = Argument("students.csv"),
group_prefix: str = Option(
"", "--group", help="export members in matched canvas group set"
),
) -> None:
try:
tea.pot.canvas.export_users_to_csv(output_file, group_prefix=group_prefix)
except ValueError as e:
logger.error(e)
raise Exit(code=1)
@app.command( @app.command(

View File

@ -16,19 +16,19 @@ class Settings(BaseSettings):
canvas_course_id: int = 0 canvas_course_id: int = 0
# gitea # gitea
gitea_domain_name: str = "focs.ji.sjtu.edu.cn" gitea_domain_name: str = "focs.gc.sjtu.edu.cn"
gitea_suffix: str = "/git" gitea_suffix: str = "/git"
gitea_access_token: str = "" gitea_access_token: str = ""
gitea_org_name: str = "" gitea_org_name: str = ""
gitea_debug: bool = False gitea_debug: bool = False
# git # git
git_host: str = "ssh://git@focs.ji.sjtu.edu.cn:2222" git_host: str = "ssh://git@focs.gc.sjtu.edu.cn:2222"
repos_dir: str = "./repos" repos_dir: str = "./repos"
default_branch: str = "master" default_branch: str = "master"
# mattermost # mattermost
mattermost_domain_name: str = "focs.ji.sjtu.edu.cn" mattermost_domain_name: str = "focs.gc.sjtu.edu.cn"
mattermost_suffix: str = "/mm" mattermost_suffix: str = "/mm"
mattermost_access_token: str = "" mattermost_access_token: str = ""
mattermost_team: str = "" mattermost_team: str = ""

View File

@ -244,7 +244,7 @@ def generate_title_and_comment(
f"Generated at {now} from [Gitea Actions #{run_number}]({action_link}), " f"Generated at {now} from [Gitea Actions #{run_number}]({action_link}), "
f"commit {commit_hash}, " f"commit {commit_hash}, "
f"triggered by @{submitter}, " f"triggered by @{submitter}, "
f"run ID [`{run_id}`](https://focs.ji.sjtu.edu.cn/joj-mon/d/{settings.gitea_org_name}?var-Filters=RunID%7C%3D%7C{run_id}).\n" 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 " "Powered by [JOJ3](https://github.com/joint-online-judge/JOJ3) and "
"[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n" "[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n"
) )

View File

@ -3,10 +3,11 @@ import os
import re import re
from glob import glob from glob import glob
from pathlib import Path from pathlib import Path
from typing import cast from typing import Dict, List, Tuple, cast
from canvasapi import Canvas as PyCanvas from canvasapi import Canvas as PyCanvas
from canvasapi.assignment import Assignment from canvasapi.assignment import Assignment
from canvasapi.group import GroupCategory
from canvasapi.user import User from canvasapi.user import User
from patoolib import extract_archive from patoolib import extract_archive
from patoolib.util import PatoolError from patoolib.util import PatoolError
@ -86,7 +87,73 @@ Teaching Team"""
print(f"Subject: [{settings.gitea_org_name}] Important: wrong Canvas email") print(f"Subject: [{settings.gitea_org_name}] Important: wrong Canvas email")
print(f"Body:\n{SAMPLE_EMAIL_BODY}") print(f"Body:\n{SAMPLE_EMAIL_BODY}")
def export_users_to_csv(self, filename: Path) -> None: def _get_group_set_by_prefix(self, group_prefix: str) -> GroupCategory:
group_sets = list(self.course.get_group_categories())
exact_match = first(group_sets, lambda group_set: group_set.name == group_prefix)
if exact_match is not None:
return cast(GroupCategory, exact_match)
matched_group_sets = [
group_set
for group_set in group_sets
if group_set.name.startswith(group_prefix)
]
if len(matched_group_sets) == 0:
raise ValueError(
f'Canvas group set with prefix "{group_prefix}" not found'
)
if len(matched_group_sets) > 1:
matched_names = ", ".join(group_set.name for group_set in matched_group_sets)
raise ValueError(
f'Multiple Canvas group sets match prefix "{group_prefix}": {matched_names}'
)
return matched_group_sets[0]
def _parse_group_name(self, group_set_name: str, group_name: str) -> Tuple[int, str]:
match = re.fullmatch(rf"{re.escape(group_set_name)}\s*(\d+)", group_name)
if match is None:
raise ValueError(
f'Canvas group "{group_name}" in group set "{group_set_name}" '
+ f'should be named as "{group_set_name} <number>"'
)
group_number = int(match.group(1))
return group_number, f"{group_set_name}{group_number:02}"
def _get_group_users_csv_rows(self, group_prefix: str) -> Tuple[str, List[List[str]]]:
group_set = self._get_group_set_by_prefix(group_prefix)
users_by_id: Dict[int, User] = {user.id: user for user in self.users}
rows = []
groups = []
for group in group_set.get_groups():
group_number, normalized_group_name = self._parse_group_name(
group_set.name, group.name
)
groups.append((group_number, normalized_group_name, group))
for _, normalized_group_name, group in sorted(groups, key=lambda item: item[0]):
group_rows = []
for membership in group.get_memberships():
user = users_by_id.get(membership.user_id)
if user is None:
logger.warning(
f"user with id {membership.user_id} found in {group.name} "
+ "but not found in course users"
)
continue
group_rows.append(
[user.name, user.sis_id, user.login_id, normalized_group_name]
)
rows.extend(sorted(group_rows))
return group_set.name, rows
def export_users_to_csv(self, filename: Path, group_prefix: str = "") -> None:
if group_prefix:
group_set_name, rows = self._get_group_users_csv_rows(group_prefix)
with open(filename, mode="w", newline="") as file:
writer = csv.writer(file)
for row in rows:
writer.writerow(row)
logger.info(f'Users in group set "{group_set_name}" exported to {filename}')
return
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 user in self.users: for user in self.users: