Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion backend/app/routers/batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from ..database import get_db
from ..deps import require_staff
from ..models import Batch, BatchEnrollment, Student
from ..models import Batch, BatchEnrollment, Payment, Session, Student
from ..schemas import BatchCreate, BatchOut, NameRef, StudentOut

router = APIRouter(prefix="/batches", tags=["batches"])
Expand Down Expand Up @@ -70,6 +70,28 @@ def update_batch(
@router.delete("/{batch_id}", status_code=204)
def delete_batch(batch_id: int, db: Session = Depends(get_db), _=Depends(require_staff)):
batch = _get(db, batch_id)
# Block deletes that would orphan real data (FK has no cascade, so the commit
# would otherwise 500). Only *active* enrollments count as "in use" — unenroll
# is a soft delete, so removed students leave an inactive row behind that we
# clean up below rather than block on.
blockers = []
n = db.query(BatchEnrollment).filter_by(batch_id=batch_id, is_active=True).count()
if n:
blockers.append(f"{n} enrolled student(s)")
n = db.query(Session).filter_by(batch_id=batch_id).count()
if n:
blockers.append(f"{n} session(s)")
n = db.query(Payment).filter_by(batch_id=batch_id).count()
if n:
blockers.append(f"{n} payment(s)")
if blockers:
raise HTTPException(
status_code=409,
detail="Can't delete: batch still has " + ", ".join(blockers) + ". Remove them first.",
)
# Drop the dead join rows left by unenroll — they only hold the FK and have no
# meaning once the batch is gone.
db.query(BatchEnrollment).filter_by(batch_id=batch_id).delete()
db.delete(batch)
db.commit()

Expand Down
1 change: 1 addition & 0 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ async function request(path, { method = "GET", body, form } = {}) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || `Request failed (${res.status})`);
}
if (res.status === 204) return null; // No Content (e.g. DELETE) — nothing to parse
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
Expand Down
29 changes: 23 additions & 6 deletions frontend/src/pages/Batches.jsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
import { useState } from "react";
import { api } from "../api";
import { Page, Card, EmptyState, Stagger, inr, useApi } from "../ui";
import { useToast } from "../components/Toast";

function BatchCard({ batch, allStudents, onEdit, onChanged }) {
// Roster comes from the /batches list response (no per-card fetch); onChanged
// reloads the list, which refreshes this batch's students.
const roster = batch.students || [];
const toast = useToast();

async function add(e) {
const sid = Number(e.target.value);
if (!sid) return;
await api.post("/students/enroll", { student_id: sid, batch_id: batch.id });
onChanged();
try {
await api.post("/students/enroll", { student_id: sid, batch_id: batch.id });
toast.success("Student added.");
onChanged();
} catch (err) {
toast.error(err.message);
}
}
async function remove(sid) {
await api.post("/students/unenroll", { student_id: sid, batch_id: batch.id });
onChanged();
try {
await api.post("/students/unenroll", { student_id: sid, batch_id: batch.id });
toast.success("Student removed.");
onChanged();
} catch (err) {
toast.error(err.message);
}
}
async function del() {
if (!confirm(`Delete batch "${batch.name}"?`)) return;
await api.del(`/batches/${batch.id}`);
onChanged();
try {
await api.del(`/batches/${batch.id}`);
toast.success("Batch deleted.");
onChanged();
} catch (err) {
toast.error(err.message);
}
}

const enrolledIds = new Set(roster.map((s) => s.id));
Expand Down
22 changes: 17 additions & 5 deletions frontend/src/pages/StudentDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api";
import { Page, Table, Loading, useApi } from "../ui";
import { useToast } from "../components/Toast";

const MARK = {
present: { icon: "✓", word: "", cls: "text-sage" },
Expand Down Expand Up @@ -87,6 +88,7 @@ export default function StudentDetail() {
const calendar = useApi(() => api.get(`/students/${id}/attendance-calendar`), [id]);
const [batchId, setBatchId] = useState("");
const [form, setForm] = useState(null); // edit student details
const toast = useToast();

async function save(e) {
e.preventDefault();
Expand All @@ -103,13 +105,23 @@ export default function StudentDetail() {

async function enroll() {
if (!batchId) return;
await api.post("/students/enroll", { student_id: Number(id), batch_id: Number(batchId) });
setBatchId("");
enrolled.reload();
try {
await api.post("/students/enroll", { student_id: Number(id), batch_id: Number(batchId) });
setBatchId("");
toast.success("Enrolled.");
enrolled.reload();
} catch (err) {
toast.error(err.message);
}
}
async function unenroll(bid) {
await api.post("/students/unenroll", { student_id: Number(id), batch_id: bid });
enrolled.reload();
try {
await api.post("/students/unenroll", { student_id: Number(id), batch_id: bid });
toast.success("Unenrolled.");
enrolled.reload();
} catch (err) {
toast.error(err.message);
}
}

if (!student.data) return <Page title="Student">{student.error || "Loading…"}</Page>;
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/pages/Tutors.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from "react";
import { api } from "../api";
import { Page, EntityCard, EmptyState, Stagger, inr, useApi } from "../ui";
import { useToast } from "../components/Toast";

export default function Tutors() {
const list = useApi(() => api.get("/tutors"));
const [form, setForm] = useState(null);
const toast = useToast();

async function save(e) {
e.preventDefault();
Expand All @@ -22,8 +24,13 @@ export default function Tutors() {
}

async function deactivate(t) {
await api.post(`/tutors/${t.id}/deactivate`);
list.reload();
try {
await api.post(`/tutors/${t.id}/deactivate`);
toast.success("Tutor deactivated.");
list.reload();
} catch (err) {
toast.error(err.message);
}
}

return (
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/pages/Users.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useState } from "react";
import { api } from "../api";
import { Page, Table, useApi } from "../ui";
import { useToast } from "../components/Toast";

export default function Users() {
const list = useApi(() => api.get("/users"));
const students = useApi(() => api.get("/students"));
const tutors = useApi(() => api.get("/tutors"));
const [form, setForm] = useState(null);
const toast = useToast();

async function create(e) {
e.preventDefault();
Expand All @@ -22,8 +24,13 @@ export default function Users() {
}

async function toggle(u) {
await api.post(`/users/${u.id}/${u.is_active ? "disable" : "enable"}`);
list.reload();
try {
await api.post(`/users/${u.id}/${u.is_active ? "disable" : "enable"}`);
toast.success(u.is_active ? "User disabled." : "User enabled.");
list.reload();
} catch (err) {
toast.error(err.message);
}
}

return (
Expand Down