Add task_details to the single-asset endpoint response

GET /assets/{id} never returned task_details (the schema didn't even
declare the field), unlike GET /shots/{id} which already populated it
correctly. This silently broke the asset detail panel's task list -
surfaced while wiring real task data into it on the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:44:36 +08:00
parent cd2efe3587
commit e8b26487db
3 changed files with 25 additions and 338 deletions
+11 -2
View File
@@ -474,10 +474,19 @@ async def get_asset(
# This avoids a separate COUNT query
active_tasks = [task for task in asset.tasks if task.deleted_at is None]
task_count = len(active_tasks)
asset_data = AssetResponse.model_validate(asset)
asset_data.task_count = task_count
asset_data.task_details = [
TaskStatusInfo(
task_type=task.task_type,
status=task.status,
task_id=task.id,
assigned_user_id=task.assigned_user_id
)
for task in active_tasks
]
return asset_data
+14 -13
View File
@@ -25,19 +25,6 @@ class AssetUpdate(BaseModel):
status: Optional[AssetStatus] = None
class AssetResponse(AssetBase):
id: int
project_id: int
created_at: datetime
updated_at: datetime
# Summary information
task_count: int = 0
class Config:
from_attributes = True
class TaskStatusInfo(BaseModel):
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
@@ -45,6 +32,20 @@ class TaskStatusInfo(BaseModel):
assigned_user_id: Optional[int] = None
class AssetResponse(AssetBase):
id: int
project_id: int
created_at: datetime
updated_at: datetime
# Summary information
task_count: int = 0
task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information")
class Config:
from_attributes = True
class AssetListResponse(BaseModel):
id: int
name: str
@@ -1,323 +0,0 @@
<template>
<div class="space-y-6">
<!-- Add Member Section -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">Add Team Member</h4>
</div>
<div class="flex gap-3">
<Select
:model-value="newMember.user_id"
@update:model-value="newMember.user_id = $event"
class="flex-1"
>
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id.toString()"
>
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
</SelectItem>
</SelectContent>
</Select>
<Select
:model-value="newMember.department_role"
@update:model-value="newMember.department_role = $event"
class="w-40"
>
<SelectTrigger>
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No Department</SelectItem>
<SelectItem value="layout">Layout</SelectItem>
<SelectItem value="animation">Animation</SelectItem>
<SelectItem value="lighting">Lighting</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
<SelectItem value="modeling">Modeling</SelectItem>
<SelectItem value="rigging">Rigging</SelectItem>
<SelectItem value="surfacing">Surfacing</SelectItem>
</SelectContent>
</Select>
<Button
@click="addMember"
:disabled="!newMember.user_id || isLoading"
size="sm"
>
<Plus class="h-4 w-4 mr-2" />
Add
</Button>
</div>
</div>
<Separator />
<!-- Current Members -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">
Current Members ({{ members.length }})
</h4>
</div>
<!-- Loading State -->
<div
v-if="isLoading && members.length === 0"
class="flex items-center justify-center py-8"
>
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"
></div>
<span class="text-sm text-muted-foreground">Loading members...</span>
</div>
</div>
<!-- Members List -->
<div v-else-if="members.length > 0" class="space-y-2">
<div
v-for="member in members"
:key="member.id"
class="flex items-center justify-between p-3 rounded-lg border bg-card"
>
<div class="flex items-center gap-3">
<div
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
>
<User class="h-4 w-4 text-primary" />
</div>
<div>
<p class="font-medium text-sm">
{{ member.user_first_name }} {{ member.user_last_name }}
</p>
<p class="text-xs text-muted-foreground">
{{ member.user_email }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Select
:model-value="member.department_role || ''"
@update:model-value="(value) => updateMemberRole(member, value)"
>
<SelectTrigger class="w-32 h-8">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No Department</SelectItem>
<SelectItem value="layout">Layout</SelectItem>
<SelectItem value="animation">Animation</SelectItem>
<SelectItem value="lighting">Lighting</SelectItem>
<SelectItem value="composite">Composite</SelectItem>
<SelectItem value="modeling">Modeling</SelectItem>
<SelectItem value="rigging">Rigging</SelectItem>
<SelectItem value="surfacing">Surfacing</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
@click="removeMember(member)"
:disabled="isLoading"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8">
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">
No members assigned to this project
</p>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end pt-4 border-t">
<Button @click="$emit('close')" variant="outline"> Close </Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { Plus, User, Users, X } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/components/ui/toast/use-toast";
import { projectService, type ProjectMember } from "@/services/project";
import { userService } from "@/services/user";
import type { User as UserType } from "@/types/auth";
import type { Project } from "@/stores/projects";
interface Props {
project: Project;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const { toast } = useToast();
// State
const members = ref<ProjectMember[]>([]);
const allUsers = ref<UserType[]>([]);
const isLoading = ref(false);
const newMember = ref({
user_id: "",
department_role: "",
});
// Computed
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map((m) => m.user_id));
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
});
// Methods
const loadMembers = async () => {
try {
isLoading.value = true;
members.value = await projectService.getProjectMembers(props.project.id);
} catch (error) {
toast({
title: "Error",
description: "Failed to load project members",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const loadUsers = async () => {
try {
allUsers.value = await userService.getUsers();
} catch (error) {
toast({
title: "Error",
description: "Failed to load users",
variant: "destructive",
});
}
};
const addMember = async () => {
if (!newMember.value.user_id) return;
try {
isLoading.value = true;
const memberData = {
user_id: parseInt(newMember.value.user_id),
department_role: newMember.value.department_role || undefined,
};
const addedMember = await projectService.addProjectMember(
props.project.id,
memberData
);
members.value.push(addedMember);
// Reset form
newMember.value = {
user_id: "",
department_role: "",
};
toast({
title: "Member added",
description: "Team member has been added to the project",
});
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to add member",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
try {
isLoading.value = true;
const updatedMember = await projectService.updateProjectMember(
props.project.id,
member.id,
{ department_role: newRole || undefined }
);
const index = members.value.findIndex((m) => m.id === member.id);
if (index !== -1) {
members.value[index] = updatedMember;
}
toast({
title: "Role updated",
description: "Member department role has been updated",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to update member role",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
const removeMember = async (member: ProjectMember) => {
try {
isLoading.value = true;
await projectService.removeProjectMember(props.project.id, member.id);
const index = members.value.findIndex((m) => m.id === member.id);
if (index !== -1) {
members.value.splice(index, 1);
}
toast({
title: "Member removed",
description: "Team member has been removed from the project",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to remove member",
variant: "destructive",
});
} finally {
isLoading.value = false;
}
};
// Lifecycle
onMounted(() => {
loadMembers();
loadUsers();
});
</script>