diff --git a/README.md b/README.md index 88e0c27..905856f 100644 --- a/README.md +++ b/README.md @@ -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 `///`. +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) +``` diff --git a/classroom.py b/classroom.py new file mode 100644 index 0000000..4aeae8b --- /dev/null +++ b/classroom.py @@ -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}\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 - angelegt (Kopie des Templates) + 2. Ein Gitea-Team - 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() diff --git a/groups.csv b/groups.csv new file mode 100644 index 0000000..9f27c64 --- /dev/null +++ b/groups.csv @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..05a4cc3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +py-gitea +requests \ No newline at end of file diff --git a/students.csv b/students.csv new file mode 100644 index 0000000..3750be4 --- /dev/null +++ b/students.csv @@ -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