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