Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Thumbs.db
frontend/test-results/
frontend/playwright-report/
backend/.pytest_tmp/

.pytest_tmp/
.pytest_cache/

This file was deleted.

1 change: 0 additions & 1 deletion .pytest_tmp/basetemp/test_upload_badge_imagecurrent

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion .pytest_tmp/basetemp/test_upload_storybook_pagecurrent

This file was deleted.

10 changes: 6 additions & 4 deletions backend/app/api/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from app.crud.badges import BADGE_RULES, calculate_streak_days, get_or_create_badge
from app.database import get_db
from app.deps import require_roles, require_org_roles
from app.section_scope import require_scoped_section
from app.enum import MembershipStatus, ProgressStatus
from app.models import (
AccessGrant,
Expand Down Expand Up @@ -1045,29 +1046,30 @@ def get_section_summary(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
total_enrolled = (
db.query(Enrollment)
.filter(Enrollment.section_id == section_id)
.filter(Enrollment.section_id == section.id)
.count()
)
completed_lessons = (
db.query(SegmentProgress)
.filter(
SegmentProgress.section_id == section_id,
SegmentProgress.section_id == section.id,
SegmentProgress.status == ProgressStatus.COMPLETED,
)
.count()
)
completed_units = (
db.query(StudentUnitProgress)
.filter(
StudentUnitProgress.section_id == section_id,
StudentUnitProgress.section_id == section.id,
StudentUnitProgress.status == ProgressStatus.COMPLETED,
)
.count()
)
return {
"section_id": section_id,
"section_id": str(section.id),
"totals": {
"enrolled": total_enrolled,
"lessons_completed": completed_lessons,
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/routes/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.database import get_db
from app.deps import get_current_user, require_org_roles
from app.models import Assignment, AssignmentSubmission
from app.section_scope import require_scoped_section
from app.enum import AssignmentTargetType, AssignmentSubmissionStatus
from app.schemas import (
AssignmentCreateRequest,
Expand All @@ -24,8 +25,9 @@ def create_assignment(
current_user=Depends(get_current_user),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
assignment = Assignment(
section_id=section_id,
section_id=section.id,
target_type=AssignmentTargetType(payload.target_type),
target_id=payload.target_id,
due_at=payload.due_at,
Expand All @@ -42,9 +44,10 @@ def create_assignment(
def list_assignments(
section_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
return db.query(Assignment).filter(Assignment.section_id == section_id).all()
section = require_scoped_section(db, membership, section_id)
return db.query(Assignment).filter(Assignment.section_id == section.id).all()


@router.post("/assignments/{assignment_id}/submit", response_model=AssignmentSubmissionResponse)
Expand Down
76 changes: 75 additions & 1 deletion backend/app/api/routes/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,30 @@
from app.database import get_db
from app.deps import get_current_user
from app.enum import OrganizationType, OrganizationRole
from app.models import Organization, OrganizationMembership
from app.models import Enrollment, Organization, OrganizationMembership, Section, User
from app.schemas import (
OrganizationCreate,
OrganizationResponse,
OrganizationMemberResponse,
OrganizationSectionResponse,
OrganizationSwitchResponse,
OrganizationUpdate,
)
from app.deps import require_org_roles

router = APIRouter()


def _require_requested_organization(org_id: str, membership: OrganizationMembership) -> uuid.UUID:
try:
org_uuid = uuid.UUID(org_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid organization id") from exc
if membership.organization_id != org_uuid:
raise HTTPException(status_code=403, detail="Organization mismatch")
return org_uuid


@router.get("/orgs", response_model=list[OrganizationResponse])
def list_orgs(
db: Session = Depends(get_db),
Expand Down Expand Up @@ -45,6 +58,67 @@ def list_orgs(
]


@router.get("/orgs/{org_id}/members", response_model=list[OrganizationMemberResponse])
def list_org_members(
org_id: str,
db: Session = Depends(get_db),
membership=Depends(require_org_roles("org_admin")),
):
org_uuid = _require_requested_organization(org_id, membership)
rows = (
db.query(OrganizationMembership, User)
.join(User, User.id == OrganizationMembership.user_id)
.filter(OrganizationMembership.organization_id == org_uuid)
.order_by(User.firstname.asc(), User.lastname.asc(), User.username.asc())
.all()
)
return [
{
"id": row.id,
"user_id": user.id,
"display_name": f"{user.firstname or ''} {user.lastname or ''}".strip() or user.username,
"username": user.username,
"role": row.role.value,
"status": row.status.value,
"joined_at": row.created_at,
}
for row, user in rows
]


@router.get("/orgs/{org_id}/sections", response_model=list[OrganizationSectionResponse])
def list_org_sections(
org_id: str,
db: Session = Depends(get_db),
membership=Depends(require_org_roles("org_admin")),
):
org_uuid = _require_requested_organization(org_id, membership)
sections = (
db.query(Section)
.filter(Section.organization_id == org_uuid)
.order_by(Section.name.asc(), Section.created_at.asc())
.all()
)
results = []
for section in sections:
enrollments = db.query(Enrollment).filter(Enrollment.section_id == section.id).all()
results.append(
{
"id": section.id,
"organization_id": section.organization_id,
"course_version_id": section.course_version_id,
"name": section.name,
"mode": section.mode.value,
"start_date": section.start_date,
"end_date": section.end_date,
"created_by": section.created_by,
"learner_count": sum(1 for row in enrollments if row.role_in_section == "student"),
"teacher_count": sum(1 for row in enrollments if row.role_in_section in {"teacher", "instructor"}),
}
)
return results


@router.post("/orgs", response_model=OrganizationResponse)
def create_org(
payload: OrganizationCreate,
Expand Down
21 changes: 18 additions & 3 deletions backend/app/api/routes/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

from app.database import get_db
from app.deps import get_current_user, require_org_roles
from app.models import Section, Enrollment, User
from app.models import Section, Enrollment, OrganizationMembership, User
from app.enum import SectionMode
from app.schemas import SectionCreateRequest, SectionResponse, EnrollmentCreateRequest, EnrollmentResponse
from app.section_scope import require_scoped_section

router = APIRouter()

Expand Down Expand Up @@ -56,7 +57,8 @@ def section_roster(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
return db.query(Enrollment).filter(Enrollment.section_id == section_id).all()
section = require_scoped_section(db, membership, section_id)
return db.query(Enrollment).filter(Enrollment.section_id == section.id).all()


@router.post("/sections/{section_id}/enrollments", response_model=EnrollmentResponse)
Expand All @@ -66,6 +68,7 @@ def add_enrollment(
db: Session = Depends(get_db),
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
if not payload.user_id and not payload.email:
raise HTTPException(status_code=400, detail="Provide a user_id or email")

Expand All @@ -78,7 +81,19 @@ def add_enrollment(
if not user:
raise HTTPException(status_code=404, detail="User not found")

enrollment = Enrollment(section_id=section_id, user_id=user.id)
active_membership = (
db.query(OrganizationMembership)
.filter(
OrganizationMembership.organization_id == membership.organization_id,
OrganizationMembership.user_id == user.id,
OrganizationMembership.status == "active",
)
.first()
)
if active_membership is None:
raise HTTPException(status_code=400, detail="User is not an active member of this organization")

enrollment = Enrollment(section_id=section.id, user_id=user.id)
db.add(enrollment)
db.commit()
db.refresh(enrollment)
Expand Down
15 changes: 15 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ class Config:
from_attributes = True


class OrganizationMemberResponse(BaseModel):
id: UUID
user_id: UUID
display_name: str
username: str
role: str
status: str
joined_at: datetime


class WorkspaceResponse(BaseModel):
id: UUID
organization_id: Optional[UUID] = None
Expand Down Expand Up @@ -506,6 +516,11 @@ class Config:
from_attributes = True


class OrganizationSectionResponse(SectionResponse):
learner_count: int = 0
teacher_count: int = 0


class EnrollmentCreateRequest(BaseModel):
email: Optional[str] = None
user_id: Optional[UUID] = None
Expand Down
29 changes: 29 additions & 0 deletions backend/app/section_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from uuid import UUID

from fastapi import HTTPException
from sqlalchemy.orm import Session

from app.models import OrganizationMembership, Section


def require_scoped_section(
db: Session,
membership: OrganizationMembership,
section_id: str | UUID,
) -> Section:
try:
normalized_id = section_id if isinstance(section_id, UUID) else UUID(section_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid section id") from exc

section = (
db.query(Section)
.filter(
Section.id == normalized_id,
Section.organization_id == membership.organization_id,
)
.first()
)
if section is None:
raise HTTPException(status_code=404, detail="Section not found")
return section
Loading
Loading