Skip to content
Closed
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
6 changes: 6 additions & 0 deletions backend/migrations/00065_add_instructor_to_overrides.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE public.program_class_event_overrides
ADD COLUMN instructor_id INTEGER REFERENCES public.users(id);

-- +goose Down
ALTER TABLE public.program_class_event_overrides DROP COLUMN IF EXISTS instructor_id;
2 changes: 1 addition & 1 deletion backend/src/database/class_enrollments.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ type EnrollmentDetails struct {
func (db *DB) GetProgramClassEnrollmentsForProgram(args *models.QueryContext, classId int, status string) ([]EnrollmentDetails, error) {
content := make([]EnrollmentDetails, 0, args.PerPage)
search := args.SearchQuery()
tx := db.WithContext(args.Ctx).Table("program_class_enrollments pse").Select("pse.*, u.name_last || ', ' || u.name_first as name_full, u.doc_id, c.name as class_name, c.start_dt, pc.created_at as completion_dt").
tx := db.WithContext(args.Ctx).Table("program_class_enrollments pse").Select("pse.*, u.name_first || ' ' || u.name_last as name_full, u.doc_id, c.name as class_name, c.start_dt, pc.created_at as completion_dt").
Joins("JOIN program_classes c ON pse.class_id = c.id AND c.deleted_at IS NULL").
Joins("JOIN users u ON pse.user_id = u.id AND u.deleted_at IS NULL").
Joins("LEFT JOIN program_completions pc ON pc.user_id = pse.user_id AND pc.program_class_id = ?", classId).
Expand Down
48 changes: 35 additions & 13 deletions backend/src/database/class_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,12 @@ func (db *DB) CreateOverrideEvents(ctx *models.QueryContext, overrideEvents []*m
isOverrideUpdate = overrideEvent.ID > 0
if err := trans.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"duration", "override_rrule", "is_cancelled", "room_id", "reason", "linked_override_event_id"}),
DoUpdates: clause.AssignmentColumns([]string{"duration", "override_rrule", "is_cancelled", "room_id", "reason", "linked_override_event_id", "instructor_id"}),
}).Create(&overrideEvent).Error; err != nil {
trans.Rollback()
return newCreateDBError(err, "program_class_event_overrides")
}
if overrideEvent.IsCancelled && len(overrideEvents) < 2 { //only add log for cancelled event
if overrideEvent.IsCancelled && len(overrideEvents) < 2 {
changeLogEntry.FieldName = "event_cancelled"
changeLogEntry.OldValue = models.StringPtr("")
changeLogEntry.ParentRefID = overrideEvent.ClassID
Expand All @@ -193,10 +193,30 @@ func (db *DB) CreateOverrideEvents(ctx *models.QueryContext, overrideEvents []*m
changeLogEntry.FieldName = "event_rescheduled"
changeLogEntry.ParentRefID = overrideEvent.ClassID
changeLogEntry.NewValue = eventSummary
} else if overrideEvent.IsCancelled { //not logging the cancelled one from rescheduling action
} else if overrideEvent.IsCancelled {
linkedOverrideID = &overrideEvent.ID
changeLogEntry.OldValue = &overrideEvent.OverrideRrule
}
if !overrideEvent.IsCancelled && overrideEvent.InstructorID != nil {
var instructor models.User
if err := trans.Select("name_first", "name_last").First(&instructor, *overrideEvent.InstructorID).Error; err == nil {
eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006")
summary := instructor.NameFirst + " " + instructor.NameLast + " on " + *eventDate
changeLogEntry.FieldName = "event_substitute_instructor"
changeLogEntry.ParentRefID = overrideEvent.ClassID
changeLogEntry.NewValue = &summary
}
}
if !overrideEvent.IsCancelled && overrideEvent.RoomID != nil && len(overrideEvents) == 1 {
var room models.Room
if err := trans.Select("name").First(&room, *overrideEvent.RoomID).Error; err == nil {
eventDate, _ := overrideEvent.GetFormattedOverrideDate("1/02/2006")
summary := room.Name + " on " + *eventDate
changeLogEntry.FieldName = "event_room_changed"
changeLogEntry.ParentRefID = overrideEvent.ClassID
changeLogEntry.NewValue = &summary
}
}
if overrideEvent.IsCancelled || isOverrideUpdate { //delete attendance
eventDate, err = overrideEvent.GetFormattedOverrideDate("2006-01-02")
if err != nil {
Expand Down Expand Up @@ -880,8 +900,8 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry
untilTime = enrollment.UpdatedAt.AddDate(0, 0, 1).Truncate(24 * time.Hour)
}
} else {
startTime = time.Now().Add(time.Hour * 24 * -14)
untilTime = startTime.AddDate(0, 1, 0)
startTime = rRule.GetDTStart()
untilTime = time.Now().AddDate(0, 1, 0).Truncate(24 * time.Hour)
}
} else {
yearInt, err := strconv.Atoi(year)
Expand Down Expand Up @@ -957,14 +977,16 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry
eventInstances := createEventInstances(event, occurrences, loc, classTime, attendances, canonicalHour, canonicalMinute)

qryCtx.Total = int64(len(eventInstances))
offset := qryCtx.CalcOffset()
end := offset + qryCtx.PerPage
if offset >= len(eventInstances) {
eventInstances = []models.ClassEventInstance{}
} else if end > len(eventInstances) {
eventInstances = eventInstances[offset:]
} else {
eventInstances = eventInstances[offset:end]
if !qryCtx.All {
offset := qryCtx.CalcOffset()
end := offset + qryCtx.PerPage
if offset >= len(eventInstances) {
eventInstances = []models.ClassEventInstance{}
} else if end > len(eventInstances) {
eventInstances = eventInstances[offset:]
} else {
eventInstances = eventInstances[offset:end]
}
}

return eventInstances, nil
Expand Down
2 changes: 1 addition & 1 deletion backend/src/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ func (db *DB) GetChangeLogEntries(args *models.QueryContext, tableName string, r
classCategoryFields := map[string][]string{
"info": {"name", "description", "instructor_name", "class"},
"status": {"status", "archived_at"},
"schedule": {"start_date", "end_date", "start_dt", "end_dt", "meeting_days", "meeting_times", "event_rescheduled_series", "event_rescheduled", "event_cancelled", "event_restored"},
"schedule": {"start_date", "end_date", "start_dt", "end_dt", "meeting_days", "meeting_times", "event_rescheduled_series", "event_rescheduled", "event_cancelled", "event_restored", "event_substitute_instructor", "event_room_changed"},
"settings": {"capacity", "location", "credit_hours"},
}

Expand Down
22 changes: 22 additions & 0 deletions backend/src/handlers/class_enrollments.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func (srv *Server) registerProgramClassEnrollmentsRoutes() []routeDef {
resolve := FacilityAdminResolver("program_classes", "class_id")
return []routeDef{
adminFeatureRoute("GET /api/program-classes/{class_id}/enrollments", srv.handleGetEnrollmentsForProgram, axx),
adminFeatureRoute("POST /api/program-classes/{class_id}/enrollment-conflicts", srv.handleCheckEnrollmentConflicts, axx),
adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/enrollments", srv.handleEnrollUsersInClass, axx, resolve),
adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/enrollments", srv.handleUpdateProgramClassEnrollments, axx, resolve),
adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/enrollments/{enrollment_id}/date", srv.handleUpdateEnrollmentDate, axx, resolve),
Expand Down Expand Up @@ -56,6 +57,27 @@ func (srv *Server) handleGetEnrollmentsForProgram(w http.ResponseWriter, r *http
return writePaginatedResponse(w, http.StatusOK, enrollments, args.IntoMeta())
}

func (srv *Server) handleCheckEnrollmentConflicts(w http.ResponseWriter, r *http.Request, log sLog) error {
classID, err := strconv.Atoi(r.PathValue("class_id"))
if err != nil {
return newInvalidIdServiceError(err, "class ID")
}
body := struct {
UserIDs []int `json:"user_ids"`
}{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return newJSONReqBodyServiceError(err)
}
if len(body.UserIDs) == 0 {
return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": []any{}})
}
conflicts, err := srv.Db.CheckSchedulingConflicts(classID, body.UserIDs)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, map[string]any{"conflicts": conflicts})
}

func (srv *Server) handleEnrollUsersInClass(w http.ResponseWriter, r *http.Request, log sLog) error {
classID, err := strconv.Atoi(r.PathValue("class_id"))
if err != nil {
Expand Down
122 changes: 122 additions & 0 deletions backend/src/handlers/class_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"UnlockEdv2/src/services"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)

func (srv *Server) registerClassEventsRoutes() []routeDef {
Expand All @@ -20,6 +23,7 @@ func (srv *Server) registerClassEventsRoutes() []routeDef {
adminFeatureRoute("GET /api/admin-calendar", srv.handleGetAdminCalendar, axx),
adminFeatureRoute("GET /api/program-classes/todays-schedule", srv.handleGetTodaysSchedule, axx),
adminValidatedFeatureRoute("PUT /api/program-classes/{class_id}/events/{event_id}", srv.handleEventOverrides, axx, resolver),
adminValidatedFeatureRoute("PATCH /api/program-classes/{class_id}/events/{event_id}", srv.handlePatchEventOverride, axx, resolver),
adminValidatedFeatureRoute("DELETE /api/program-classes/{class_id}/events/{event_override_id}", srv.handleDeleteEventOverride, axx, resolver),
adminValidatedFeatureRoute("POST /api/program-classes/{class_id}/events", srv.handleCreateEvent, axx, resolver),
adminValidatedFeatureRoute("PUT /api/program-classes/{class_id}/events", srv.handleRescheduleEventSeries, axx, resolver),
Expand Down Expand Up @@ -159,6 +163,124 @@ func (srv *Server) handleEventOverrides(w http.ResponseWriter, r *http.Request,
return writeJsonResponse(w, http.StatusOK, "Override(s) created successfully")
}

type patchEventOverrideRequest struct {
Date string `json:"date"`
IsCancelled bool `json:"is_cancelled"`
Reason string `json:"reason"`
RoomID *uint `json:"room_id"`
InstructorID *uint `json:"instructor_id"`
NewDate string `json:"new_date"`
NewStartTime string `json:"new_start_time"`
}

func (srv *Server) handlePatchEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error {
eventId, err := strconv.Atoi(r.PathValue("event_id"))
if err != nil {
return newInvalidIdServiceError(err, "event_id")
}
classID, err := strconv.Atoi(r.PathValue("class_id"))
if err != nil {
return newInvalidIdServiceError(err, "class ID")
}
cannotCreateOverride, err := srv.cannotUpdateEvent(classID)
if err != nil {
return err
}
if cannotCreateOverride {
return newBadRequestServiceError(errors.New("cannot create an event override for a completed or cancelled class"), "cannot create event override")
}
var req patchEventOverrideRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return newJSONReqBodyServiceError(err)
}
if req.Date == "" {
return newBadRequestServiceError(errors.New("date is required"), "date is required")
}
ctx := srv.getQueryContext(r)
facilityLoc, err := time.LoadLocation(ctx.Timezone)
if err != nil {
return newBadRequestServiceError(err, "invalid facility timezone")
}
event, err := srv.Db.GetEventById(eventId)
if err != nil {
return newDatabaseServiceError(err)
}
eventRule, err := event.GetRRuleWithTimezone(ctx.Timezone)
if err != nil {
return newDatabaseServiceError(err)
}
eventStartLocal := eventRule.OrigOptions.Dtstart.In(facilityLoc)
canonicalHour := eventStartLocal.Hour()
canonicalMinute := eventStartLocal.Minute()

buildRRule := func(dateStr string, optionalTime string) (string, error) {
dateOnly, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return "", err
}
hour, minute := canonicalHour, canonicalMinute
if optionalTime != "" {
parts := strings.Split(optionalTime, ":")
if len(parts) == 2 {
hour, _ = strconv.Atoi(parts[0])
minute, _ = strconv.Atoi(parts[1])
}
}
cleanDate := dateOnly.Format("20060102")
return fmt.Sprintf("DTSTART;TZID=%s:%sT%02d%02d00\nRRULE:FREQ=DAILY;COUNT=1",
ctx.Timezone, cleanDate, hour, minute), nil
}

originalRRule, err := buildRRule(req.Date, "")
if err != nil {
return newBadRequestServiceError(err, "invalid date format")
}

if req.NewDate != "" {
newRRule, err := buildRRule(req.NewDate, req.NewStartTime)
if err != nil {
return newBadRequestServiceError(err, "invalid new_date format")
}
overrides := []*models.ProgramClassEventOverride{
{
EventID: uint(eventId),
ClassID: uint(classID),
Duration: event.Duration,
OverrideRrule: originalRRule,
IsCancelled: true,
Reason: "rescheduled",
},
{
EventID: uint(eventId),
ClassID: uint(classID),
Duration: event.Duration,
OverrideRrule: newRRule,
IsCancelled: false,
RoomID: event.RoomID,
},
}
if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, overrides); err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, "Event rescheduled successfully")
}

override := &models.ProgramClassEventOverride{
EventID: uint(eventId),
ClassID: uint(classID),
Duration: event.Duration,
OverrideRrule: originalRRule,
IsCancelled: req.IsCancelled,
Reason: req.Reason,
RoomID: req.RoomID,
InstructorID: req.InstructorID,
}
if err := srv.WithUserContext(r).CreateOverrideEvents(&ctx, []*models.ProgramClassEventOverride{override}); err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, "Override created successfully")
}

func (srv *Server) handleDeleteEventOverride(w http.ResponseWriter, r *http.Request, log sLog) error {
id, err := strconv.Atoi(r.PathValue("event_override_id"))
if err != nil {
Expand Down
17 changes: 8 additions & 9 deletions backend/src/handlers/classes_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ func (srv *Server) handleUpdateClass(w http.ResponseWriter, r *http.Request, log
}
}

existing, err := srv.Db.GetClassByID(id)
if err != nil {
return newDatabaseServiceError(err)
}
if existing.CannotUpdateClass() {
return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived")
}

if instructorIDProvided {
if class.InstructorID == nil || *class.InstructorID == 0 {
class.InstructorID = nil
Expand All @@ -202,18 +210,9 @@ func (srv *Server) handleUpdateClass(w http.ResponseWriter, r *http.Request, log
class.InstructorName = instructorName
}
} else {

existing, err := srv.Db.GetClassByID(id)
if err != nil {
return newDatabaseServiceError(err)
}
class.InstructorID = existing.InstructorID
class.InstructorName = existing.InstructorName
}

if class.CannotUpdateClass() {
return newBadRequestServiceError(err, "cannot perform action on class that is completed cancelled or archived")
}
enrolled, err := srv.Db.GetTotalEnrollmentsByClassID(id)
if err != nil {
return newDatabaseServiceError(err)
Expand Down
6 changes: 4 additions & 2 deletions backend/src/models/class_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,12 @@ type ProgramClassEventOverride struct {
RoomID *uint `json:"room_id"`
Reason string `json:"reason"`
LinkedOverrideEventID *uint `json:"linked_override_event_id"`
InstructorID *uint `json:"instructor_id"`

/* Foreign keys */
Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"`
RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"`
Event *ProgramClassEvent `json:"event" gorm:"foreignKey:EventID;references:ID"`
RoomRef *Room `json:"room_ref,omitempty" gorm:"foreignKey:RoomID;references:ID"`
InstructorRef *User `json:"instructor_ref,omitempty" gorm:"foreignKey:InstructorID;references:ID"`
}

func (ProgramClassEventOverride) TableName() string { return "program_class_event_overrides" }
Expand Down
18 changes: 6 additions & 12 deletions frontend-v2/src/layouts/AuthenticatedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ export default function AuthenticatedLayout() {
const breadcrumbItems =
routeBreadcrumbs.length > 0 ? routeBreadcrumbs : contextBreadcrumbs;
const isProgramDetail = /^\/programs\/\d+$/.test(location.pathname);
const isClassDetail = /^\/program-classes\/\d+\/detail$/.test(location.pathname);
const isEventAttendance = /^\/program-classes\/\d+\/events\/\d+\/attendance\//.test(location.pathname);
const isDashboard = location.pathname.startsWith('/dashboard');
const isProgramsList = location.pathname === '/programs';
const isFullBleed = isProgramDetail || isDashboard || isProgramsList;
const fullBleedWrapperClass = isDashboard || isProgramsList ? 'py-0' : 'py-4';
const isFullBleed = isProgramDetail || isClassDetail || isEventAttendance || isDashboard || isProgramsList;

useEffect(() => {
if (pageTitle) {
Expand Down Expand Up @@ -68,10 +69,10 @@ export default function AuthenticatedLayout() {

if (!user) return null;

const rootClass = isProgramDetail
const rootClass = isFullBleed
? 'min-h-screen bg-background flex'
: 'h-screen bg-background flex overflow-hidden';
const contentClass = isProgramDetail
const contentClass = isFullBleed
? 'flex-1 overflow-x-hidden'
: 'flex-1 overflow-y-auto overflow-x-hidden';

Expand Down Expand Up @@ -107,14 +108,7 @@ export default function AuthenticatedLayout() {

<div className={contentClass}>
{isFullBleed ? (
<div className={fullBleedWrapperClass}>
{breadcrumbItems.length > 0 && (
<div className="max-w-7xl mx-auto px-6 mt-2 mb-4">
<Breadcrumbs items={breadcrumbItems} />
</div>
)}
<Outlet />
</div>
<Outlet />
) : (
<div className="max-w-7xl mx-auto px-6 py-4">
{breadcrumbItems.length > 0 && (
Expand Down
Loading
Loading