Erste vollständige Version (getestet).

This commit is contained in:
Enrico Schroeder 2026-05-28 09:07:32 +02:00
parent 3c9147a010
commit 5a43b5129c
5 changed files with 898 additions and 0 deletions

220
README.md
View File

@ -1,2 +1,222 @@
# gitea-classroom
GitHub Classroom Ersatz für selbst-gehostete Gitea-Instanzen.
Unterstützt **Einzel-Assignments** (ein Repo pro Student) und **Gruppen-Assignments** (ein Repo pro Gruppe mit eigenem Gitea-Team).
**Hinweis:** Dieses Tool wurde durch KI erstellt.
## Installation
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
## Konfiguration
```bash
export GITEA_URL="https://gitea.meine-uni.de"
export GITEA_TOKEN="dein_persönliches_api_token"
```
API-Token erstellen: Gitea → Einstellungen → Anwendungen → Token generieren
Benötigte Scopes: `repo`, `org`, `admin:org`
---
## Template-Repo vorbereiten
Das Template-Repo muss in Gitea als Template markiert sein:
**Repo → Einstellungen → Erweiterte Einstellungen → "Dieses Repository ist ein Template"**
Das Template kann in der eigenen Org oder bei einem anderen Nutzer liegen.
Das Template-Repo selbst wird nie verändert.
---
## CSV-Formate
### students.csv — für Einzel-Assignments
```csv
username,name,email
max.mustermann,Max Mustermann,max@uni.de
erika.musterfrau,Erika Musterfrau,erika@uni.de
```
### groups.csv — für Gruppen-Assignments
```csv
group,username
team-a,max.mustermann
team-a,erika.musterfrau
team-b,lena.schmidt
team-b,tom.mueller
```
Gruppenname (`group`) wird Teil des Repo-Namens: `proj01-team-a`
---
## Befehle
### `add-students` — Studenten zur Organisation hinzufügen
```bash
python classroom.py add-students --org info2-ss25 --csv students.csv
```
Fügt alle Nutzer aus der CSV dem `students`-Team der Org hinzu.
Die Gitea-Accounts müssen vorher existieren.
---
### `create-assignment` — Einzel-Assignment erstellen
```bash
python classroom.py create-assignment \
--org info2-ss25 \
--assignment ueb01 \
--template uebung01-template \
--csv students.csv
```
| Option | Beschreibung |
|---|---|
| `--org` | Ziel-Organisation |
| `--assignment` | Prefix für Repo-Namen (z. B. `ueb01`) |
| `--template` | Name des Template-Repos |
| `--template-owner` | Owner des Templates (Standard: `--org`) |
| `--csv` | students.csv |
| `--public` | Repos öffentlich erstellen (Standard: privat) |
Erstellt pro Student:
- Ein privates Repo (`ueb01-max.mustermann`) als Kopie des Templates
- Schreibzugriff nur auf das eigene Repo
- Einen **Feedback-PR** (`main``feedback`), der alle Student-Commits zeigt
```
info2-ss25/
ueb01-max.mustermann
ueb01-erika.musterfrau
...
```
---
### `create-group-assignment` — Gruppen-Assignment erstellen
```bash
python classroom.py create-group-assignment \
--org info2-ss25 \
--assignment proj01 \
--template projekt-template \
--csv groups.csv
```
| Option | Beschreibung |
|---|---|
| `--org` | Ziel-Organisation |
| `--assignment` | Prefix für Repo-Namen (z. B. `proj01`) |
| `--template` | Name des Template-Repos |
| `--template-owner` | Owner des Templates (Standard: `--org`) |
| `--csv` | groups.csv |
| `--public` | Repos öffentlich erstellen (Standard: privat) |
Erstellt pro Gruppe:
- Ein privates Repo (`proj01-team-a`) als Kopie des Templates
- Ein Gitea-Team (`proj01-team-a`) mit Schreibzugriff für alle Gruppenmitglieder
- Einen **Feedback-PR** (`main``feedback`), der alle Gruppen-Commits zeigt
- Studenten sehen ausschließlich das eigene Gruppen-Repo
```
info2-ss25/
proj01-team-a ← max.mustermann + erika.musterfrau
proj01-team-b ← lena.schmidt + tom.mueller
```
---
### `list-submissions` — Abgaben einsehen (Übersicht)
```bash
python classroom.py list-submissions --org info2-ss25 --assignment ueb01
```
Zeigt den letzten Commit je Repo in einer kompakten Tabelle.
Funktioniert für Einzel- und Gruppen-Assignments.
---
### `list-group-submissions` — Abgaben einsehen (Detailansicht)
```bash
python classroom.py list-group-submissions --org info2-ss25 --assignment proj01
```
Zeigt die letzten 5 Commits je Gruppen-Repo.
---
### `clone-submissions` — Abgaben lokal klonen
```bash
python classroom.py clone-submissions \
--org info2-ss25 \
--assignment ueb01 \
--dir ./abgaben
```
| Option | Beschreibung |
|---|---|
| `--org` | Organisation |
| `--assignment` | Assignment-Name |
| `--dir` | Zielverzeichnis (Standard: `.`) |
Klont alle Repos des Assignments nach `<dir>/<assignment>/<student>/`.
Bei erneutem Aufruf wird `git pull` auf bereits geklonte Repos ausgeführt.
---
### `delete-assignment` — Assignment löschen
```bash
python classroom.py delete-assignment --org info2-ss25 --assignment ueb01
```
Löscht alle Repos und die zugehörigen Gruppen-Teams des Assignments.
Verlangt manuelle Bestätigung durch Eingabe des Assignment-Namens.
---
## Feedback-PR
Jedes Repo erhält automatisch einen offenen Pull Request mit dem Titel **„Feedback"**:
- **`feedback`-Branch** — eingefrorener Template-Stand (Referenzpunkt)
- **`main`-Branch** — Student-Commits werden hier gepusht
- Der PR zeigt alle Änderungen relativ zum Template und wächst mit jedem Push
Dozenten können direkt im PR Zeilen kommentieren oder ein Review hinterlassen.
Der PR wird nie gemergt — er dient ausschließlich als Feedback-Kanal.
---
## Typischer Semesterablauf
```
Semesterbeginn:
add-students (einmalig, students.csv)
Übungsblätter (Einzel):
create-assignment → ueb01, ueb02, ...
list-submissions (Abgabe-Übersicht)
clone-submissions (lokal klonen / aktualisieren)
Projekt (Gruppe):
create-group-assignment → proj01
list-group-submissions (während der Bearbeitung)
list-submissions (kompakte Abgabe-Übersicht)
clone-submissions (lokal klonen / aktualisieren)
Semesterende:
delete-assignment (optional, für jedes Assignment)
```

663
classroom.py Normal file
View File

@ -0,0 +1,663 @@
#!/usr/bin/env python3
"""
gitea-classroom GitHub Classroom Ersatz für Gitea
====================================================
Voraussetzungen:
pip install py-gitea requests
Konfiguration:
Umgebungsvariablen setzen oder Werte unten direkt eintragen.
Befehle (Einzel-Assignments):
python classroom.py add-students --org MeinKurs --csv students.csv
python classroom.py create-assignment --org MeinKurs --assignment ueb01 --template uebung01-template --csv students.csv
python classroom.py list-submissions --org MeinKurs --assignment ueb01
python classroom.py delete-assignment --org MeinKurs --assignment ueb01
Befehle (Gruppen-Assignments):
python classroom.py create-group-assignment --org MeinKurs --assignment proj01 --template projekt-template --csv groups.csv
python classroom.py list-group-submissions --org MeinKurs --assignment proj01
python classroom.py delete-assignment --org MeinKurs --assignment proj01 # (gleicher Befehl)
Gruppen-CSV Format (groups.csv):
group,username
team-a,max.mustermann
team-a,erika.musterfrau
team-b,lena.schmidt
team-b,tom.mueller
"""
import argparse
import base64
import csv
import os
import subprocess
import sys
from collections import defaultdict
from datetime import datetime
from urllib.parse import urlparse, urlunparse
import requests
from gitea import Gitea, Organization, Team
# ──────────────────────────────────────────────
# Konfiguration
# ──────────────────────────────────────────────
GITEA_URL = os.environ.get("GITEA_URL", "https://gitea.meine-uni.de")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "DEIN_API_TOKEN_HIER")
SEPARATOR = "-" # Trennzeichen: ueb01-max.mustermann / proj01-team-a
# ──────────────────────────────────────────────
# Hilfsfunktionen (allgemein)
# ──────────────────────────────────────────────
def get_gitea() -> Gitea:
g = Gitea(GITEA_URL, GITEA_TOKEN)
try:
g.get_version()
except Exception as e:
print(f"[FEHLER] Verbindung zu {GITEA_URL} fehlgeschlagen: {e}")
sys.exit(1)
return g
def get_org(g: Gitea, org_name: str) -> Organization:
try:
return Organization.request(g, org_name)
except Exception:
print(f"[FEHLER] Organisation '{org_name}' nicht gefunden.")
sys.exit(1)
def headers() -> dict:
return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
def api(method: str, path: str, **kwargs) -> requests.Response:
"""Sendet einen API-Request an die Gitea-Instanz."""
url = f"{GITEA_URL}/api/v1{path}"
return requests.request(method, url, headers=headers(), **kwargs)
def api_message(r: requests.Response) -> str:
"""Gibt die Fehlermeldung einer API-Antwort zurück (robust gegen leere Body)."""
try:
return r.json().get("message", r.text) if r.text else str(r.status_code)
except Exception:
return r.text or str(r.status_code)
def repo_exists(org: str, name: str) -> bool:
return api("GET", f"/repos/{org}/{name}").status_code == 200
def list_org_repos(org_name: str) -> list[dict]:
"""Gibt alle Repos einer Organisation zurück (paginiert)."""
repos, page = [], 1
while True:
r = api("GET", f"/orgs/{org_name}/repos", params={"limit": 50, "page": page})
batch = r.json()
if not batch:
break
repos.extend(batch)
page += 1
return repos
def copy_repo(template_owner: str, template_repo: str,
dest_org: str, dest_name: str, private: bool = True) -> bool:
"""
Kopiert ein Template-Repo in die Ziel-Org.
Versucht zuerst die Generate-API (funktioniert für Repos auf derselben Instanz,
erfordert dass das Repo in Gitea als Template markiert ist).
Fällt auf die Migrate-API zurück (für externe Repos).
"""
r = api("POST", f"/repos/{template_owner}/{template_repo}/generate", json={
"owner": dest_org,
"name": dest_name,
"private": private,
"git_content": True,
})
if r.status_code in (200, 201):
return True
r = api("POST", "/repos/migrate", json={
"clone_addr": f"{GITEA_URL}/{template_owner}/{template_repo}",
"repo_owner": dest_org,
"repo_name": dest_name,
"mirror": False,
"private": private,
"auth_token": GITEA_TOKEN,
})
if r.status_code in (200, 201):
return True
print(f" [WARNUNG] '{dest_name}' konnte nicht erstellt werden: "
f"{r.status_code} {api_message(r)}")
return False
def get_last_commit(org: str, repo_name: str) -> dict | None:
r = api("GET", f"/repos/{org}/{repo_name}/commits", params={"limit": 1, "page": 1})
if r.status_code == 200:
commits = r.json()
if commits:
return commits[0]
return None
def format_commit(commit: dict | None) -> tuple[str, str, str]:
if not commit:
return " kein Commit ", "", ""
raw_date = commit["commit"]["author"]["date"]
try:
dt = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
date = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
date = raw_date[:16]
author = commit["commit"]["author"]["name"][:18]
message = commit["commit"]["message"].splitlines()[0][:40]
return date, author, message
# ──────────────────────────────────────────────
# Feedback-PR
# ──────────────────────────────────────────────
def create_feedback_pr(org: str, repo_name: str) -> bool:
"""
Erstellt einen Feedback-PR der Student-Commits sichtbar macht:
- feedback-Branch = eingefrorener Template-Stand (Referenzpunkt)
- default-Branch bekommt einen .classroom-Marker-Commit
- PR default feedback wächst mit jedem Student-Push
"""
r = api("GET", f"/repos/{org}/{repo_name}")
if r.status_code != 200:
return False
default_branch = r.json().get("default_branch", "main")
# feedback-Branch am aktuellen HEAD einfrieren
r = api("POST", f"/repos/{org}/{repo_name}/branches",
json={"new_branch_name": "feedback", "old_branch_name": default_branch})
if r.status_code not in (200, 201):
print(f" [WARNUNG] Branch 'feedback' konnte nicht erstellt werden: {api_message(r)}")
return False
# Marker-Commit auf default-Branch damit der PR sofort einen Diff hat
content = base64.b64encode(
"Dieses Repository wird über gitea-classroom verwaltet.\n".encode()
).decode()
r = api("POST", f"/repos/{org}/{repo_name}/contents/.classroom",
json={"message": "Classroom initialisieren", "content": content,
"branch": default_branch})
if r.status_code not in (200, 201):
print(f" [WARNUNG] .classroom konnte nicht erstellt werden: {api_message(r)}")
return False
# PR: default → feedback (zeigt alle Student-Commits)
r = api("POST", f"/repos/{org}/{repo_name}/pulls", json={
"title": "Feedback",
"head": default_branch,
"base": "feedback",
"body": "Dieser Pull Request zeigt alle Abgabe-Commits und dient als Feedback-Kanal.",
})
if r.status_code in (200, 201):
return True
print(f" [WARNUNG] Feedback-PR konnte nicht erstellt werden: {api_message(r)}")
return False
# ──────────────────────────────────────────────
# Gitea-Team-Hilfsfunktionen
# ──────────────────────────────────────────────
def ensure_org_team(org: Organization, team_name: str,
permission: str = "write") -> Team:
"""Gibt ein Team zurück (erstellt es falls nötig)."""
for team in org.get_teams():
if team.name == team_name:
return team
payload = {
"name": team_name,
"permission": permission,
"units": ["repo.code", "repo.issues", "repo.pulls"],
"includes_all_repositories": False,
}
r = api("POST", f"/orgs/{org.name}/teams", json=payload)
if r.status_code not in (200, 201):
print(f"[FEHLER] Team '{team_name}' konnte nicht erstellt werden: {r.text}")
sys.exit(1)
team_id = r.json()["id"]
for team in org.get_teams():
if team.id == team_id:
return team
raise RuntimeError("Team erstellt, aber nicht auffindbar.")
def add_member_to_team(team: Team, username: str) -> bool:
r = api("PUT", f"/teams/{team.id}/members/{username}")
return r.status_code in (200, 204)
def add_repo_to_team(team: Team, org_name: str, repo_name: str) -> bool:
r = api("PUT", f"/teams/{team.id}/repos/{org_name}/{repo_name}")
return r.status_code in (200, 204)
def add_collaborator(org: str, repo: str, username: str,
permission: str = "write") -> bool:
r = api("PUT", f"/repos/{org}/{repo}/collaborators/{username}",
json={"permission": permission})
return r.status_code in (200, 204)
def delete_org_team_by_name(org: Organization, team_name: str):
"""Löscht ein Team der Org anhand des Namens (ignoriert Fehler)."""
for team in org.get_teams():
if team.name == team_name:
api("DELETE", f"/teams/{team.id}")
return
# ──────────────────────────────────────────────
# CSV lesen
# ──────────────────────────────────────────────
def read_students_csv(path: str) -> list[dict]:
"""CSV mit Spalte 'username' (+ optional 'name', 'email')."""
if not os.path.exists(path):
print(f"[FEHLER] CSV '{path}' nicht gefunden.")
sys.exit(1)
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
if not rows or "username" not in rows[0]:
print("[FEHLER] CSV benötigt eine Spalte 'username'.")
sys.exit(1)
return rows
def read_groups_csv(path: str) -> dict[str, list[str]]:
"""
CSV mit Spalten 'group' und 'username'.
Gibt dict { group_name -> [username, ...] } zurück.
"""
if not os.path.exists(path):
print(f"[FEHLER] CSV '{path}' nicht gefunden.")
sys.exit(1)
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
if not rows or "group" not in rows[0] or "username" not in rows[0]:
print("[FEHLER] Gruppen-CSV benötigt Spalten 'group' und 'username'.")
sys.exit(1)
groups: dict[str, list[str]] = defaultdict(list)
for row in rows:
groups[row["group"].strip()].append(row["username"].strip())
return dict(groups)
# ──────────────────────────────────────────────
# Befehle
# ──────────────────────────────────────────────
def cmd_add_students(args):
"""Fügt Studenten zur Org hinzu und legt sie ins 'students'-Team."""
g = get_gitea()
org = get_org(g, args.org)
team = ensure_org_team(org, "students")
students = read_students_csv(args.csv)
print(f"\n{len(students)} Studenten → Org '{args.org}' / Team 'students'\n")
ok = skip = 0
for row in students:
username = row["username"].strip()
if add_member_to_team(team, username):
print(f"{username}")
ok += 1
else:
print(f"{username} (Team-Zuweisung fehlgeschlagen)")
skip += 1
print(f"\nFertig: {ok} hinzugefügt, {skip} übersprungen.")
def cmd_create_assignment(args):
"""Erstellt pro Student ein eigenes Repo (Kopie des Templates)."""
g = get_gitea()
org = get_org(g, args.org)
team = ensure_org_team(org, "students")
students = read_students_csv(args.csv)
template_owner = args.template_owner or args.org
print(f"\n→ Einzel-Assignment '{args.assignment}' für {len(students)} Studenten")
print(f" Template : {template_owner}/{args.template}")
print(f" Muster : {args.assignment}{SEPARATOR}<username>\n")
ok = skip = 0
for row in students:
username = row["username"].strip()
rname = f"{args.assignment}{SEPARATOR}{username}"
if repo_exists(args.org, rname):
print(f" {rname} (bereits vorhanden)")
skip += 1
continue
if copy_repo(template_owner, args.template, args.org, rname,
private=not args.public):
add_collaborator(args.org, rname, username)
add_repo_to_team(team, args.org, rname)
pr_ok = create_feedback_pr(args.org, rname)
print(f"{rname}" + (" (inkl. Feedback-PR)" if pr_ok else ""))
ok += 1
else:
skip += 1
print(f"\nFertig: {ok} Repos erstellt, {skip} übersprungen/fehlgeschlagen.")
print(f"Repos unter: {GITEA_URL}/{args.org}")
def cmd_create_group_assignment(args):
"""
Erstellt pro Gruppe ein gemeinsames Repo und ein eigenes Gitea-Team.
Für jede Gruppe wird:
1. Ein Repo <assignment>-<group> angelegt (Kopie des Templates)
2. Ein Gitea-Team <assignment>-<group> angelegt
3. Alle Gruppenmitglieder ins Team eingetragen
4. Das Repo dem Gruppen-Team und dem 'students'-Team zugewiesen
"""
g = get_gitea()
org = get_org(g, args.org)
template_owner = args.template_owner or args.org
groups = read_groups_csv(args.csv)
total_members = sum(len(m) for m in groups.values())
print(f"\n→ Gruppen-Assignment '{args.assignment}'")
print(f" {len(groups)} Gruppen, {total_members} Mitglieder gesamt")
print(f" Template : {template_owner}/{args.template}\n")
ok = skip = 0
for group_name, members in sorted(groups.items()):
rname = f"{args.assignment}{SEPARATOR}{group_name}"
team_name = rname # Gitea-Team trägt denselben Namen wie das Repo
print(f" Gruppe '{group_name}' → Repo '{rname}' ({len(members)} Mitglieder)")
# 1. Repo erstellen
if repo_exists(args.org, rname):
print(f" Repo bereits vorhanden, übersprungen")
skip += 1
else:
if not copy_repo(template_owner, args.template, args.org, rname,
private=not args.public):
skip += 1
continue
# 2. Gruppen-Team anlegen / holen
group_team = ensure_org_team(org, team_name, permission="write")
# 3. Mitglieder ins Gruppen-Team eintragen
for username in members:
if add_member_to_team(group_team, username):
print(f"{username}")
else:
print(f"{username} (Team-Zuweisung fehlgeschlagen)")
# 4. Repo den Teams zuweisen
add_repo_to_team(group_team, args.org, rname)
pr_ok = create_feedback_pr(args.org, rname)
print(f"{GITEA_URL}/{args.org}/{rname}" + (" (inkl. Feedback-PR)" if pr_ok else ""))
ok += 1
print(f"\nFertig: {ok} Gruppen-Repos erstellt, {skip} übersprungen/fehlgeschlagen.")
def cmd_list_submissions(args):
"""Zeigt letzten Commit je Repo für ein Assignment (Einzel oder Gruppe)."""
get_gitea()
repos = list_org_repos(args.org)
prefix = f"{args.assignment}{SEPARATOR}"
asgn_repos = sorted(
[r for r in repos if r["name"].startswith(prefix)],
key=lambda r: r["name"]
)
if not asgn_repos:
print(f"Keine Repos für '{args.assignment}' in '{args.org}' gefunden.")
return
label_w = 28
print(f"\n{'Team / Student':<{label_w}} {'Letzter Commit':<22} {'Autor':<20} {'Nachricht'}")
print("" * 95)
for repo in asgn_repos:
label = repo["name"][len(prefix):]
commit = get_last_commit(args.org, repo["name"])
date, author, message = format_commit(commit)
print(f" {label:<{label_w-2}} {date:<22} {author:<20} {message}")
print(f"\nGesamt: {len(asgn_repos)} Repos")
def cmd_list_group_submissions(args):
"""Wie list-submissions, zeigt zusätzlich alle Commits pro Gruppe."""
get_gitea()
repos = list_org_repos(args.org)
prefix = f"{args.assignment}{SEPARATOR}"
asgn_repos = sorted(
[r for r in repos if r["name"].startswith(prefix)],
key=lambda r: r["name"]
)
if not asgn_repos:
print(f"Keine Repos für '{args.assignment}' in '{args.org}' gefunden.")
return
for repo in asgn_repos:
group = repo["name"][len(prefix):]
print(f"\n{''*60}")
print(f" Gruppe: {group}{GITEA_URL}/{args.org}/{repo['name']}")
print(f"{''*60}")
# Die letzten 5 Commits des Repos abrufen
r = api("GET", f"/repos/{args.org}/{repo['name']}/commits",
params={"limit": 5, "page": 1})
commits = r.json() if r.status_code == 200 else []
if not commits:
print(" (noch keine Commits)")
continue
print(f" {'Datum':<20} {'Autor':<22} Nachricht")
print(f" {''*20} {''*22} {''*35}")
for c in commits:
raw = c["commit"]["author"]["date"]
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
date = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
date = raw[:16]
author = c["commit"]["author"]["name"][:20]
message = c["commit"]["message"].splitlines()[0][:40]
print(f" {date:<20} {author:<22} {message}")
print()
def cmd_clone_submissions(args):
"""Klont alle Repos eines Assignments lokal (oder aktualisiert sie per git pull)."""
get_gitea()
repos = list_org_repos(args.org)
prefix = f"{args.assignment}{SEPARATOR}"
asgn_repos = sorted(
[r for r in repos if r["name"].startswith(prefix)],
key=lambda r: r["name"],
)
if not asgn_repos:
print(f"Keine Repos für '{args.assignment}' in '{args.org}' gefunden.")
return
base_dir = os.path.join(args.dir, args.assignment)
os.makedirs(base_dir, exist_ok=True)
parsed = urlparse(GITEA_URL)
auth_base = urlunparse(parsed._replace(netloc=f"token:{GITEA_TOKEN}@{parsed.netloc}"))
print(f"\n{len(asgn_repos)} Repos → {os.path.abspath(base_dir)}\n")
ok = updated = failed = 0
for repo in asgn_repos:
name = repo["name"]
label = name[len(prefix):]
dest = os.path.join(base_dir, label)
clone_url = f"{auth_base}/{args.org}/{name}.git"
if os.path.isdir(os.path.join(dest, ".git")):
result = subprocess.run(
["git", "-C", dest, "pull", "--ff-only"],
capture_output=True, text=True,
)
if result.returncode == 0:
print(f"{label} (aktualisiert)")
updated += 1
else:
print(f"{label} (pull fehlgeschlagen): {result.stderr.strip()}")
failed += 1
else:
result = subprocess.run(
["git", "clone", clone_url, dest],
capture_output=True, text=True,
)
if result.returncode == 0:
print(f"{label}")
ok += 1
else:
print(f"{label} (clone fehlgeschlagen): {result.stderr.strip()}")
failed += 1
print(f"\nFertig: {ok} geklont, {updated} aktualisiert, {failed} fehlgeschlagen.")
print(f"Verzeichnis: {os.path.abspath(base_dir)}")
def cmd_delete_assignment(args):
"""Löscht alle Repos (und Gruppen-Teams) eines Assignments."""
g = get_gitea()
org = get_org(g, args.org)
repos = list_org_repos(args.org)
prefix = f"{args.assignment}{SEPARATOR}"
targets = [r["name"] for r in repos if r["name"].startswith(prefix)]
if not targets:
print(f"Keine Repos für '{args.assignment}' gefunden.")
return
print(f"\nFolgende {len(targets)} Repos werden gelöscht:")
for name in targets:
print(f"{args.org}/{name}")
confirm = input("\nSicher? Tippe den Assignment-Namen zur Bestätigung: ").strip()
if confirm != args.assignment:
print("Abgebrochen.")
return
ok = 0
for name in targets:
r = api("DELETE", f"/repos/{args.org}/{name}")
if r.status_code == 204:
print(f" ✓ Repo gelöscht: {name}")
ok += 1
else:
print(f" ✗ Fehler bei {name}: {r.status_code}")
# Gruppen-Team löschen (falls vorhanden, bei Gruppen-Assignments)
delete_org_team_by_name(org, name)
print(f"\n{ok}/{len(targets)} Repos gelöscht.")
# ──────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Gitea Classroom GitHub Classroom Ersatz via Gitea API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
sub = parser.add_subparsers(dest="command", required=True)
# ── add-students ──
p = sub.add_parser("add-students", help="Studenten zur Org hinzufügen")
p.add_argument("--org", required=True)
p.add_argument("--csv", required=True, help="students.csv mit Spalte 'username'")
# ── create-assignment (Einzel) ──
p = sub.add_parser("create-assignment", help="Einzel-Repos pro Student erstellen")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True, help="z. B. ueb01")
p.add_argument("--template", required=True, help="Name des Template-Repos")
p.add_argument("--template-owner", default=None, help="Owner des Templates (Standard: --org)")
p.add_argument("--csv", required=True, help="students.csv")
p.add_argument("--public", action="store_true")
# ── create-group-assignment ──
p = sub.add_parser("create-group-assignment",
help="Gruppen-Repos erstellen (ein Repo pro Gruppe)")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True, help="z. B. proj01")
p.add_argument("--template", required=True, help="Name des Template-Repos")
p.add_argument("--template-owner", default=None)
p.add_argument("--csv", required=True,
help="groups.csv mit Spalten 'group' und 'username'")
p.add_argument("--public", action="store_true")
# ── list-submissions ──
p = sub.add_parser("list-submissions",
help="Letzter Commit je Repo (Einzel & Gruppen)")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True)
# ── list-group-submissions ──
p = sub.add_parser("list-group-submissions",
help="Letzte 5 Commits pro Gruppen-Repo")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True)
# ── clone-submissions ──
p = sub.add_parser("clone-submissions",
help="Alle Abgabe-Repos lokal klonen / aktualisieren")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True)
p.add_argument("--dir", default=".", help="Zielverzeichnis (Standard: .)")
# ── delete-assignment ──
p = sub.add_parser("delete-assignment",
help="Alle Repos + Gruppen-Teams eines Assignments löschen")
p.add_argument("--org", required=True)
p.add_argument("--assignment", required=True)
args = parser.parse_args()
dispatch = {
"add-students": cmd_add_students,
"create-assignment": cmd_create_assignment,
"create-group-assignment": cmd_create_group_assignment,
"list-submissions": cmd_list_submissions,
"list-group-submissions": cmd_list_group_submissions,
"clone-submissions": cmd_clone_submissions,
"delete-assignment": cmd_delete_assignment,
}
dispatch[args.command](args)
if __name__ == "__main__":
main()

8
groups.csv Normal file
View File

@ -0,0 +1,8 @@
group,username
team-a,max.mustermann
team-a,erika.musterfrau
team-b,lena.schmidt
team-b,tom.mueller
team-c,anna.braun
team-c,felix.wagner
team-c,sara.klein
1 group username
2 team-a max.mustermann
3 team-a erika.musterfrau
4 team-b lena.schmidt
5 team-b tom.mueller
6 team-c anna.braun
7 team-c felix.wagner
8 team-c sara.klein

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
py-gitea
requests

5
students.csv Normal file
View File

@ -0,0 +1,5 @@
username,name,email
max.mustermann,Max Mustermann,max.mustermann@uni.de
erika.musterfrau,Erika Musterfrau,erika.musterfrau@uni.de
lena.schmidt,Lena Schmidt,l.schmidt@uni.de
tom.mueller,Tom Müller,t.mueller@uni.de
1 username name email
2 max.mustermann Max Mustermann max.mustermann@uni.de
3 erika.musterfrau Erika Musterfrau erika.musterfrau@uni.de
4 lena.schmidt Lena Schmidt l.schmidt@uni.de
5 tom.mueller Tom Müller t.mueller@uni.de