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
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,16 @@ public List<ClientResponseDTO> getTeamsByExerciseId(Long exerciseId) {
return List.of();
}
return participations.stream()
.map(this::mapParticipationToClientResponse)
.map(p -> {
try {
return mapParticipationToClientResponse(p);
} catch (Exception e) {
log.warn("Failed to map participation {} (team {}): {}",
p.getParticipation(), p.getName(), e.getMessage());
return null;
}
})
.filter(java.util.Objects::nonNull)
.toList();
}

Expand Down Expand Up @@ -229,7 +238,8 @@ public LlmTokenTotalsDTO readTeamTokenTotals(TeamParticipation tp) {
*/
public CQIResultDTO reconstructCqiDetails(TeamParticipation participation, AnalysisMode mode) {
if (participation.getCqiEffortBalance() == null && participation.getCqiLocBalance() == null
&& participation.getCqiTemporalSpread() == null && participation.getCqiOwnershipSpread() == null) {
&& participation.getCqiTemporalSpread() == null && participation.getCqiOwnershipSpread() == null
&& participation.getCqiPairProgrammingStatus() == null) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,18 +190,19 @@ public ClientResponseDTO saveGitAnalysisResult(TeamRepositoryDTO repo,
teamRepo.setVcsLogs(vcsLogs);
teamRepositoryRepository.save(teamRepo);

// Compute git-only CQI (including PP status) for ALL teams before the failed check
CQIResultDTO gitCqiDetails = calculateGitOnlyCqi(repo, teamParticipation, team, students);

boolean hasFailed = checkAndMarkFailed(teamParticipation, students);
if (hasFailed) {
return new ClientResponseDTO(
tutor != null ? tutor.getName() : "Unassigned",
team.id(), participation.id(), team.name(), team.shortName(),
participation.submissionCount(),
studentDtos, 0.0, false, TeamAnalysisStatus.DONE,
null, null, null, null, 0, true, null);
gitCqiDetails, null, null, null, 0, true, null);
}

CQIResultDTO gitCqiDetails = calculateGitOnlyCqi(repo, teamParticipation, team, students);

CQIResultDTO finalDetails = gitCqiDetails;
if (mode == AnalysisMode.SIMPLE && gitCqiDetails != null) {
finalDetails = cqiCalculatorService.renormalizeWithoutEffort(gitCqiDetails, exerciseId);
Expand Down Expand Up @@ -730,8 +731,7 @@ private boolean checkAndMarkFailed(TeamParticipation tp, List<Student> students)
tp.setCqiLocBalance(null);
tp.setCqiTemporalSpread(null);
tp.setCqiOwnershipSpread(null);
tp.setCqiPairProgramming(null);
tp.setCqiPairProgrammingStatus(null);
// Preserve PP fields — failed teams should still show their PP status
tp.setCqiBaseScore(null);
teamParticipationRepository.save(tp);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import de.tum.cit.aet.artemis.ArtemisClientService;
import de.tum.cit.aet.core.dto.ArtemisCredentials;
import de.tum.cit.aet.dataProcessing.domain.AnalysisMode;
import de.tum.cit.aet.pairProgramming.service.PairProgrammingRecomputeService;
import de.tum.cit.aet.pairProgramming.service.PairProgrammingService;
import de.tum.cit.aet.repositoryProcessing.domain.TeamAnalysisStatus;
import de.tum.cit.aet.repositoryProcessing.domain.TeamParticipation;
import de.tum.cit.aet.repositoryProcessing.dto.*;
Expand Down Expand Up @@ -51,6 +53,8 @@ public class StreamingAnalysisPipelineService {
private final AnalysisTaskManager analysisTaskManager;
private final ExerciseTeamLifecycleService exerciseDataCleanupService;
private final AnalysisResultPersistenceService persistenceService;
private final PairProgrammingService pairProgrammingService;
private final PairProgrammingRecomputeService pairProgrammingRecomputeService;

public StreamingAnalysisPipelineService(
ArtemisClientService artemisClientService,
Expand All @@ -62,7 +66,9 @@ public StreamingAnalysisPipelineService(
ExerciseTemplateAuthorRepository templateAuthorRepository,
AnalysisTaskManager analysisTaskManager,
ExerciseTeamLifecycleService exerciseDataCleanupService,
AnalysisResultPersistenceService persistenceService) {
AnalysisResultPersistenceService persistenceService,
PairProgrammingService pairProgrammingService,
PairProgrammingRecomputeService pairProgrammingRecomputeService) {
this.artemisClientService = artemisClientService;
this.gitOperationsService = gitOperationsService;
this.analysisStateService = analysisStateService;
Expand All @@ -73,6 +79,8 @@ public StreamingAnalysisPipelineService(
this.analysisTaskManager = analysisTaskManager;
this.exerciseDataCleanupService = exerciseDataCleanupService;
this.persistenceService = persistenceService;
this.pairProgrammingService = pairProgrammingService;
this.pairProgrammingRecomputeService = pairProgrammingRecomputeService;
}

// =====================================================================
Expand Down Expand Up @@ -264,6 +272,17 @@ public void fetchAnalyzeAndSaveRepositoriesStream(ArtemisCredentials credentials
log.info("Phase 2 complete: {} of {} repositories git-analyzed", gitAnalyzedCount.get(), clonedCount);
eventEmitter.accept(Map.of("type", "GIT_DONE", "processed", gitAnalyzedCount.get()));

// Synchronously recompute PP statuses after git analysis so they are in the DB
// before AI analysis starts (async would race with AI analysis saves)
if (pairProgrammingService.hasAttendanceData()) {
try {
int ppUpdated = pairProgrammingRecomputeService.recomputePairProgrammingForExercise(exerciseId);
log.info("PP recompute after git analysis: {} teams updated for exerciseId={}", ppUpdated, exerciseId);
} catch (Exception e) {
log.error("PP recompute after git analysis failed for exerciseId={}: {}", exerciseId, e.getMessage());
}
}

if (!analysisStateService.isRunning(exerciseId)) {
exerciseDataCleanupService.markPendingTeamsAsCancelled(exerciseId);
eventEmitter.accept(Map.of("type", "CANCELLED",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,9 @@ public void clear() {
* @return the pair programming status
*/
public PairProgrammingStatus getPairProgrammingStatus(String teamName, String shortName) {
if (!hasAttendanceData()) {
return null; // No Excel uploaded — PP not applicable
}
boolean hasAttendance = hasTeamAttendance(teamName, shortName);
boolean hasCancelledWarning = hasCancelledSessionWarning(teamName, shortName);
boolean pairedMandatory = isPairedMandatorySessions(teamName, shortName);
Expand All @@ -435,6 +438,9 @@ public Double calculateScore(String teamName, String shortName,
if (teamName == null || teamSize != 2) {
return null;
}
if (!hasAttendanceData()) {
return null; // No Excel uploaded — PP not applicable
}
try {
boolean hasAttendance = hasTeamAttendance(teamName, shortName);
boolean hasCancelledWarning = hasCancelledSessionWarning(teamName, shortName);
Expand Down
2 changes: 2 additions & 0 deletions src/main/webapp/src/components/CqiWeightsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default function CqiWeightsPanel({ exerciseId, disabled }: CqiWeightsPane
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cqiWeights', exerciseId] });
queryClient.invalidateQueries({ queryKey: ['teams', exerciseId] });
toast({ title: 'CQI weights saved' });
},
onError: (error: unknown) => {
Expand All @@ -110,6 +111,7 @@ export default function CqiWeightsPanel({ exerciseId, disabled }: CqiWeightsPane
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cqiWeights', exerciseId] });
queryClient.invalidateQueries({ queryKey: ['teams', exerciseId] });
toast({ title: 'CQI weights reset to defaults' });
},
onError: (error: unknown) => {
Expand Down
61 changes: 13 additions & 48 deletions src/main/webapp/src/components/TeamDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ interface TeamDetailProps {
onBack: () => void;
course?: string;
exercise?: string;
pairProgrammingBadgeStatus?: PairProgrammingBadgeStatus | null;
courseAverages?: CourseAverages | null;
onTeamUpdate?: (team: TeamDTO) => void;
onToggleReviewed?: () => void;
Expand All @@ -40,7 +39,6 @@ interface TeamDetailProps {
* @param onBack - navigate back to the teams list
* @param course - course
* @param exercise - exercise
* @param pairProgrammingBadgeStatus - status of the PP badge
* @param courseAverages - course average
* @param onTeamUpdate
* @param onToggleReviewed
Expand All @@ -51,7 +49,6 @@ const TeamDetail = ({
onBack,
course,
exercise,
pairProgrammingBadgeStatus = null,
courseAverages = null,
onTeamUpdate,
onToggleReviewed,
Expand Down Expand Up @@ -236,7 +233,11 @@ const TeamDetail = ({
);
};

// Show Pair Programming card from server subMetrics when present; otherwise from attendance badge when available (e.g. when analysis failed)
// Derive PP badge status from server data
const pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus
? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus)
: null;

const isSimpleMode = analysisMode === 'SIMPLE';
const metricsToShow = useMemo((): SubMetric[] => {
const effortBalancePlaceholder: SubMetric = isAiComputing
Expand Down Expand Up @@ -310,50 +311,8 @@ const TeamDetail = ({
return m;
});
}
if (pairProgrammingBadgeStatus != null) {
const description = 'Did both students commit during pair programming sessions?';
const synthetic: SubMetric =
pairProgrammingBadgeStatus === 'pass'
? {
name: 'Pair Programming',
value: 100,
weight: 0,
description,
details:
'Verifies that both team members actually collaborated by checking if they both made commits on the dates when they attended pair programming tutorials together.',
}
: pairProgrammingBadgeStatus === 'fail'
? {
name: 'Pair Programming',
value: 0,
weight: 0,
description,
details: 'Team was found in Excel but attended fewer than the mandatory number of pair-programming sessions.',
}
: pairProgrammingBadgeStatus === 'warning'
? {
name: 'Pair Programming',
value: -3,
weight: 0,
description,
details:
'Some pair-programming tutorials were cancelled, so mandatory attendance could not be evaluated reliably. Some sessions were attended.',
status: 'WARNING',
}
: {
name: 'Pair Programming',
value: -2,
weight: 0,
description,
details: 'Team not found in attendance Excel file. Please check that the team name in the Excel matches exactly.',
status: 'NOT_FOUND',
};
// Client-side attendance data is the most current source of truth — override any server PP metric
const withoutServerPP = fromServer.filter(m => m.name !== 'Pair Programming');
return withoutServerPP.concat(synthetic);
}
return fromServer;
}, [team.subMetrics, pairProgrammingBadgeStatus, isSimpleMode, hasAiResult, isAiComputing]);
}, [team.subMetrics, isSimpleMode, hasAiResult, isAiComputing]);

return (
<div className="space-y-6 px-4 py-8 max-w-7xl mx-auto">
Expand Down Expand Up @@ -509,7 +468,13 @@ const TeamDetail = ({
Analyzing...
</Badge>
)}
<PairProgrammingBadge status={pairProgrammingBadgeStatus} verbose={true} />
{pairProgrammingBadgeStatus ? (
<PairProgrammingBadge status={pairProgrammingBadgeStatus} verbose={true} />
) : team.analysisStatus !== 'ERROR' && team.analysisStatus !== 'CANCELLED' ? (
<Badge variant="outline" className="text-muted-foreground border-amber-500/50 bg-amber-500/10">
PP: Pending
</Badge>
) : null}
</div>
</div>
</div>
Expand Down
65 changes: 22 additions & 43 deletions src/main/webapp/src/components/TeamsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,12 @@ import FileUpload from '@/components/FileUpload';
import { getFailedReason } from '@/lib/utils';
import PairProgrammingBadge from '@/components/PairProgrammingBadge';
import { PairProgrammingFilterButton, type PairProgrammingFilterValue } from '@/components/PairProgrammingFilterButton';
import {
getPairProgrammingBadgeStatus,
hasValidPairProgrammingAttendanceData,
type PairProgrammingAttendanceMap,
type PairProgrammingBadgeStatus,
} from '@/lib/pairProgramming';
import type { PairProgrammingBadgeStatus } from '@/lib/pairProgramming';

interface TeamsListProps {
teams: TeamDTO[];
courseAverages: CourseAverages | null;
onTeamSelect: (team: TeamDTO, pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null) => void;
onTeamSelect: (team: TeamDTO) => void;
onToggleReviewed: (teamId: string) => void;
onBackToHome: () => void;
onStart: (mode: AnalysisMode) => void;
Expand All @@ -74,7 +69,6 @@ interface TeamsListProps {
pairProgrammingEnabled: boolean;
attendanceFile: File | null;
uploadedAttendanceFileName: string | null;
pairProgrammingAttendanceByTeamName: PairProgrammingAttendanceMap;
onAttendanceFileSelect: (file: File | null) => void;
onAttendanceUpload: () => void;
onRemoveUploadedAttendanceFile: () => void;
Expand Down Expand Up @@ -112,7 +106,6 @@ const TeamsList = ({
pairProgrammingEnabled,
attendanceFile,
uploadedAttendanceFileName,
pairProgrammingAttendanceByTeamName,
onAttendanceFileSelect,
onAttendanceUpload,
onRemoveUploadedAttendanceFile,
Expand Down Expand Up @@ -274,14 +267,13 @@ const TeamsList = ({
};

// Get priority for analysis status (lower = shown first)
// Failed teams (isFailed) get a separate priority so they sort below teams with real CQI scores
// Active analysis (AI_ANALYZING) shown near top so progress is visible
const getStatusPriority = (team: TeamDTO): number => {
if (team.isFailed) return 1; // Failed teams after successful DONE teams
switch (team.analysisStatus) {
case 'DONE':
return 0; // Fully completed - show first
case 'AI_ANALYZING':
return 2; // AI analysis in progress
return 0; // AI analysis in progress — show at top
case 'DONE':
return team.isFailed ? 2 : 1; // Successful after AI_ANALYZING, failed below
case 'GIT_DONE':
return 3; // Git analysis done, waiting for AI
case 'GIT_ANALYZING':
Expand All @@ -298,11 +290,7 @@ const TeamsList = ({
}
};

const hasValidPairProgrammingData = hasValidPairProgrammingAttendanceData(
pairProgrammingEnabled,
uploadedAttendanceFileName,
pairProgrammingAttendanceByTeamName,
);
const hasValidPairProgrammingData = pairProgrammingEnabled && teams.some(t => t.pairProgrammingStatus != null);

const sortedAndFilteredTeams = useMemo(() => {
let filtered = teams.slice();
Expand Down Expand Up @@ -353,12 +341,9 @@ const TeamsList = ({
// Apply pair programming filter (multi-select)
if (pairProgrammingFilter.length > 0 && hasValidPairProgrammingData) {
filtered = filtered.filter(team => {
const badge = getPairProgrammingBadgeStatus(
team.teamName ?? '',
hasValidPairProgrammingData,
pairProgrammingAttendanceByTeamName,
team.shortName,
);
const badge: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus
? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus)
: null;
return badge != null && pairProgrammingFilter.includes(badge);
});
}
Expand Down Expand Up @@ -416,16 +401,7 @@ const TeamsList = ({
}

return filtered;
}, [
teams,
searchQuery,
sortColumn,
sortDirection,
statusFilter,
pairProgrammingFilter,
hasValidPairProgrammingData,
pairProgrammingAttendanceByTeamName,
]);
}, [teams, searchQuery, sortColumn, sortDirection, statusFilter, pairProgrammingFilter, hasValidPairProgrammingData]);

const renderStartDropdown = (label: string, isPending: boolean, onAction: (mode: AnalysisMode) => void) => (
<DropdownMenu>
Expand Down Expand Up @@ -964,17 +940,14 @@ const TeamsList = ({
</thead>
<tbody>
{sortedAndFilteredTeams.map(team => {
const pairProgrammingBadgeStatus = getPairProgrammingBadgeStatus(
team.teamName ?? '',
hasValidPairProgrammingData,
pairProgrammingAttendanceByTeamName,
team.shortName,
);
const pairProgrammingBadgeStatus: PairProgrammingBadgeStatus | null = team.pairProgrammingStatus
? (team.pairProgrammingStatus.toLowerCase() as PairProgrammingBadgeStatus)
: null;

return (
<tr
key={String(team.teamId)}
onClick={() => onTeamSelect(team, pairProgrammingBadgeStatus)}
onClick={() => onTeamSelect(team)}
className="border-b last:border-b-0 hover:bg-muted/30 cursor-pointer transition-colors"
>
<td className="py-4 px-3">
Expand Down Expand Up @@ -1088,7 +1061,13 @@ const TeamsList = ({
{pairProgrammingEnabled && (
<td className="py-4 px-6">
<div className="flex flex-wrap items-center gap-2">
<PairProgrammingBadge status={pairProgrammingBadgeStatus} />
{pairProgrammingBadgeStatus ? (
<PairProgrammingBadge status={pairProgrammingBadgeStatus} />
) : hasUploadedAttendanceDocument && team.analysisStatus !== 'ERROR' && team.analysisStatus !== 'CANCELLED' ? (
<Badge variant="outline" className="text-muted-foreground border-amber-500/50 bg-amber-500/10">
Pending
</Badge>
) : null}
</div>
</td>
)}
Expand Down
Loading