refactor: extract duplicated patterns into shared helpers

Backend: create api/helpers.py with eligible_enrollment_filter,
count_eligible_students, get_active_enrollment, ensure_unique_name,
upsert_app_configs, and build_heatmap. Add full_name properties to
Student model. Apply across all route files (-481/+184 lines).

Frontend: create stores/helpers.js with withLoading composable,
apply to assessments and classes Pinia stores.

96/96 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:05:10 +01:00
parent b1b7d12a9f
commit a0ab7224e1
10 changed files with 402 additions and 481 deletions

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import assessmentsService from '@/services/assessments'
import { withLoading } from './helpers'
export const useAssessmentsStore = defineStore('assessments', () => {
// State
@@ -10,7 +11,7 @@ export const useAssessmentsStore = defineStore('assessments', () => {
const currentGrades = ref([])
const loading = ref(false)
const error = ref(null)
// Filters
const filters = ref({
trimester: null,
@@ -21,22 +22,22 @@ export const useAssessmentsStore = defineStore('assessments', () => {
// Getters
const assessmentsCount = computed(() => assessments.value.length)
const filteredAssessments = computed(() => {
let result = [...assessments.value]
if (filters.value.trimester) {
result = result.filter(a => a.trimester === filters.value.trimester)
}
if (filters.value.class_id) {
result = result.filter(a => a.class_group_id === filters.value.class_id)
}
return result
})
const incompleteAssessments = computed(() =>
const incompleteAssessments = computed(() =>
assessments.value.filter(a => {
const progress = a.progress || a.grading_progress
return progress?.percentage < 100
@@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => {
)
// Actions
async function fetchAssessments(customFilters = null) {
loading.value = true
error.value = null
try {
const queryFilters = customFilters || filters.value
assessments.value = await assessmentsService.getAll(queryFilters)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
const queryFilters = customFilters || filters.value
assessments.value = await assessmentsService.getAll(queryFilters)
})
async function fetchAssessment(id) {
loading.value = true
error.value = null
try {
currentAssessment.value = await assessmentsService.getById(id)
return currentAssessment.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchAssessment = withLoading(loading, error, async (id) => {
currentAssessment.value = await assessmentsService.getById(id)
return currentAssessment.value
})
async function fetchResults(id) {
loading.value = true
error.value = null
try {
currentResults.value = await assessmentsService.getResults(id)
return currentResults.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchResults = withLoading(loading, error, async (id) => {
currentResults.value = await assessmentsService.getResults(id)
return currentResults.value
})
async function fetchGrades(id) {
loading.value = true
error.value = null
try {
currentGrades.value = await assessmentsService.getGrades(id)
return currentGrades.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchGrades = withLoading(loading, error, async (id) => {
currentGrades.value = await assessmentsService.getGrades(id)
return currentGrades.value
})
async function createAssessment(data) {
loading.value = true
error.value = null
try {
const newAssessment = await assessmentsService.create(data)
assessments.value.unshift(newAssessment)
return newAssessment
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const createAssessment = withLoading(loading, error, async (data) => {
const newAssessment = await assessmentsService.create(data)
assessments.value.unshift(newAssessment)
return newAssessment
})
async function updateAssessment(id, data) {
loading.value = true
error.value = null
try {
const updated = await assessmentsService.update(id, data)
const index = assessments.value.findIndex(a => a.id === id)
if (index !== -1) {
assessments.value[index] = updated
}
if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
const updateAssessment = withLoading(loading, error, async (id, data) => {
const updated = await assessmentsService.update(id, data)
const index = assessments.value.findIndex(a => a.id === id)
if (index !== -1) {
assessments.value[index] = updated
}
}
if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
})
async function deleteAssessment(id) {
loading.value = true
error.value = null
try {
await assessmentsService.delete(id)
assessments.value = assessments.value.filter(a => a.id !== id)
if (currentAssessment.value?.id === id) {
currentAssessment.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
const deleteAssessment = withLoading(loading, error, async (id) => {
await assessmentsService.delete(id)
assessments.value = assessments.value.filter(a => a.id !== id)
if (currentAssessment.value?.id === id) {
currentAssessment.value = null
}
}
})
async function saveGrades(id, grades) {
loading.value = true
error.value = null
try {
const result = await assessmentsService.saveGrades(id, grades)
// Refresh assessment to update progress
await fetchAssessment(id)
return result
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
// fetchAssessment is declared above so it can be called here safely.
// withLoading sets loading=false in its finally block, so the inner call
// to fetchAssessment runs as a nested operation within the outer wrapper.
const saveGrades = withLoading(loading, error, async (id, grades) => {
const result = await assessmentsService.saveGrades(id, grades)
// Refresh assessment to update progress
await fetchAssessment(id)
return result
})
function setFilters(newFilters) {
filters.value = { ...filters.value, ...newFilters }

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import classesService from '@/services/classes'
import { withLoading } from './helpers'
export const useClassesStore = defineStore('classes', () => {
// State
@@ -12,104 +13,50 @@ export const useClassesStore = defineStore('classes', () => {
// Getters
const classesCount = computed(() => classes.value.length)
const totalStudents = computed(() =>
const totalStudents = computed(() =>
classes.value.reduce((sum, c) => sum + (c.students_count || c.student_count || 0), 0)
)
// Actions
async function fetchClasses() {
loading.value = true
error.value = null
try {
classes.value = await classesService.getAll()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchClasses = withLoading(loading, error, async () => {
classes.value = await classesService.getAll()
})
async function fetchClass(id) {
loading.value = true
error.value = null
try {
currentClass.value = await classesService.getById(id)
return currentClass.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchClass = withLoading(loading, error, async (id) => {
currentClass.value = await classesService.getById(id)
return currentClass.value
})
async function fetchClassStats(id, trimester = null) {
loading.value = true
error.value = null
try {
currentStats.value = await classesService.getStats(id, trimester)
return currentStats.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
currentStats.value = await classesService.getStats(id, trimester)
return currentStats.value
})
async function createClass(data) {
loading.value = true
error.value = null
try {
const newClass = await classesService.create(data)
classes.value.push(newClass)
return newClass
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
const createClass = withLoading(loading, error, async (data) => {
const newClass = await classesService.create(data)
classes.value.push(newClass)
return newClass
})
async function updateClass(id, data) {
loading.value = true
error.value = null
try {
const updated = await classesService.update(id, data)
const index = classes.value.findIndex(c => c.id === id)
if (index !== -1) {
classes.value[index] = updated
}
if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
const updateClass = withLoading(loading, error, async (id, data) => {
const updated = await classesService.update(id, data)
const index = classes.value.findIndex(c => c.id === id)
if (index !== -1) {
classes.value[index] = updated
}
}
if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
})
async function deleteClass(id) {
loading.value = true
error.value = null
try {
await classesService.delete(id)
classes.value = classes.value.filter(c => c.id !== id)
if (currentClass.value?.id === id) {
currentClass.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
const deleteClass = withLoading(loading, error, async (id) => {
await classesService.delete(id)
classes.value = classes.value.filter(c => c.id !== id)
if (currentClass.value?.id === id) {
currentClass.value = null
}
}
})
function clearCurrent() {
currentClass.value = null

View File

@@ -0,0 +1,22 @@
/**
* Wraps an async function with loading/error state management.
*
* @param {import('vue').Ref<boolean>} loading - shared loading ref
* @param {import('vue').Ref<string|null>} error - shared error ref
* @param {Function} fn - async function to wrap
* @returns {Function} wrapped function with identical signature
*/
export function withLoading(loading, error, fn) {
return async (...args) => {
loading.value = true
error.value = null
try {
return await fn(...args)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
}