Browse Source

Add linkedAnnotations field to Perspective model and update serializer; enhance URL routing for perspective detail view

pull/2426/head
GONCALOUNI 6 months ago
parent
commit
2b7a73f3a7
7 changed files with 297 additions and 568 deletions
  1. 3
      backend/config/urls.py
  2. 18
      backend/perspectives/migrations/0003_perspective_linkedannotations.py
  3. 2
      backend/perspectives/models.py
  4. 6
      backend/perspectives/serializers.py
  5. 5
      backend/perspectives/urls.py
  6. 643
      frontend/pages/edit-user.vue
  7. 188
      frontend/pages/projects/_id/perspectives/index.vue

3
backend/config/urls.py

@ -24,6 +24,7 @@ from django.urls import include, path, re_path
from django.views.static import serve
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from label_types.views import CategoryTypeList, CategoryTypeDetail
schema_view = get_schema_view(
openapi.Info(
@ -69,4 +70,6 @@ urlpatterns += [
path("v1/", include("perspectives.urls")),
path("v1/projects/<int:project_id>/perspectives/", include("perspectives.urls")),
path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
path("v1/projects/<int:project_id>/category-types/", CategoryTypeList.as_view(), name="project_category_types"),
path("v1/projects/<int:project_id>/category-types/<int:label_id>/", CategoryTypeDetail.as_view(), name="project_category_type"),
]

18
backend/perspectives/migrations/0003_perspective_linkedannotations.py

@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2025-03-31 05:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("perspectives", "0002_perspective_subject_alter_perspective_category_and_more"),
]
operations = [
migrations.AddField(
model_name="perspective",
name="linkedAnnotations",
field=models.JSONField(blank=True, default=list),
),
]

2
backend/perspectives/models.py

@ -1,6 +1,7 @@
from django.db import models
from django.conf import settings
from projects.models import Project
from django.db.models import JSONField
class Perspective(models.Model):
CATEGORY_CHOICES = [
@ -21,6 +22,7 @@ class Perspective(models.Model):
on_delete=models.CASCADE,
related_name="perspectives"
)
linkedAnnotations = JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

6
backend/perspectives/serializers.py

@ -4,5 +4,7 @@ from .models import Perspective
class PerspectiveSerializer(serializers.ModelSerializer):
class Meta:
model = Perspective
fields = ['id', 'user', 'project', 'subject', 'category', 'text', 'created_at', 'updated_at']
read_only_fields = ['project']
fields = '__all__'
extra_kwargs = {
'linkedAnnotations': {'read_only': False},
}

5
backend/perspectives/urls.py

@ -7,4 +7,9 @@ urlpatterns = [
PerspectiveView.as_view({"get": "list", "post": "create"}),
name="project-perspectives",
),
path(
"projects/<int:project_id>/perspectives/<int:pk>/",
PerspectiveView.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-perspective-detail",
),
]

643
frontend/pages/edit-user.vue

@ -1,545 +1,128 @@
<template>
<v-app id="inspire">
<v-main>
<v-container fluid class="pa-4">
<v-row align="center" justify="center" class="mt-5">
<v-col cols="12" sm="10" md="8">
<v-card class="pa-0 overflow-hidden rounded-lg shadow-lg">
<v-sheet color="primary" class="py-4 px-6 rounded-t-lg">
<div class="text-h6 font-weight-medium" style="color: white">Edit Users</div>
</v-sheet>
<v-card-text class="pa-4">
<v-alert v-if="errorMessage" type="error" dismissible class="mb-4">
{{ errorMessage }}
</v-alert>
<v-text-field
v-model="search"
:prepend-inner-icon="mdiMagnify"
label="Search"
single-line
hide-details
filled
class="mb-4"
/>
<v-data-table
:headers="headers"
:items="pagedUsers"
:items-per-page="options.itemsPerPage"
item-key="id"
:loading="isLoading"
loading-text="Loading users..."
:item-class="getRowClass"
:options.sync="options"
:custom-sort="customSort"
hide-default-footer
>
<template #[`item.id`]="{ item }">
<span v-if="!item._empty">{{ item.id }}</span>
<span v-else>&nbsp;</span>
</template>
<template #[`item.username`]="{ item }">
<div v-if="!item._empty" class="d-flex align-center">
<span
class="status-circle"
:style="{ backgroundColor: getStatusColor(item) }"
></span>
<span>{{ item.username }}</span>
</div>
<span v-else>&nbsp;</span>
</template>
<template #[`item.email`]="{ item }">
<span v-if="!item._empty">{{ item.email }}</span>
<span v-else>&nbsp;</span>
</template>
<template #[`item.role`]="{ item }">
<v-chip
v-if="!item._empty"
:color="
item.role === 'owner'
? '#a8c400'
: item.role === 'admin'
? '#FF2F00'
: 'primary'
"
outlined
>
{{ item.role.charAt(0).toUpperCase() + item.role.slice(1) }}
</v-chip>
<div v-else>&nbsp;</div>
</template>
<template #[`item.date_joined`]="{ item }">
<span v-if="!item._empty">{{ timeAgo(item.date_joined) }}</span>
<span v-else>&nbsp;</span>
</template>
<template #[`item.last_seen`]="{ item }">
<span v-if="!item._empty">
{{
isCurrentUser(item)
? 'Currently online'
: item.last_login
? timeAgo(item.last_login)
: 'Never'
}}
</span>
<span v-else>&nbsp;</span>
</template>
<template #[`item.actions`]="{ item }">
<v-btn
v-if="!item._empty"
icon
color="primary"
:disabled="!canEdit(item)"
@click="openEdit(item)"
>
<v-icon>{{ mdiPencil }}</v-icon>
</v-btn>
<span v-else>&nbsp;</span>
</template>
<template #footer>
<v-row align="center">
<v-col class="d-flex justify-start">
<v-btn color="primary" @click="$router.push('/list-user')">
<v-icon left>{{ mdiChevronLeft }}</v-icon>
Back
</v-btn>
</v-col>
<v-col class="d-flex justify-end">
<v-pagination
v-model="options.page"
:length="Math.ceil(sortedUsers.length / options.itemsPerPage)"
total-visible="7"
/>
</v-col>
</v-row>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="editDialog" max-width="500px">
<v-card>
<v-sheet color="primary" class="py-4 px-6 rounded-t-lg">
<div class="text-h6" style="color: white">
Edit User: {{ editingUser.username || 'User' }}
</div>
</v-sheet>
<v-card-text class="pa-4">
<v-alert v-if="editErrorMessage" type="error" dismissible class="mb-4">
{{ editErrorMessage }}
</v-alert>
<v-form ref="editForm">
<v-text-field
v-model="editingUser.username"
label="Username"
outlined
:prepend-icon="mdiAccount"
></v-text-field>
<v-text-field
v-model="editingUser.email"
label="Email"
outlined
:prepend-icon="mdiEmail"
></v-text-field>
<v-select
v-model="editingUser.role"
:items="roleOptions"
label="Role"
outlined
:prepend-icon="mdiAccountKey"
:color="selectedRoleColor"
></v-select>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="saveEdit">Save</v-btn>
<v-btn text @click="closeEdit">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</v-main>
</v-app>
<v-app id="inspire">
<v-main>
<v-container class="fill-height" fluid>
<!-- New section to display all users -->
<v-row align="center" justify="center" class="mt-5">
<v-col cols="12" sm="8" md="6">
<v-card class="pa-0 overflow-hidden rounded-lg" width="100%">
<v-sheet color="primary" class="py-3 px-4 rounded-t">
<div class="text-h6 font-weight-medium text-black">
All Users
</div>
</v-sheet>
<v-card-text class="pa-6">
<v-list>
<v-list-item-group>
<v-list-item v-for="user in sortedUsers" :key="user.id">
<v-list-item-content>
<v-list-item-title>{{ user.username }}
</v-list-item-title>
<v-list-item-subtitle>{{ user.email }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
import { mdiMagnify, mdiChevronLeft, mdiPencil, mdiAccount, mdiEmail, mdiAccountKey } from '@mdi/js'
import { mapState } from 'vuex'
export default {
data() {
return {
users: [],
search: '',
isLoading: false,
errorMessage: '',
editErrorMessage: '',
options: {
itemsPerPage: 5,
page: 1,
sortBy: [],
sortDesc: []
},
headers: [
{ text: 'Username', value: 'username', sortable: true },
{ text: 'Email', value: 'email', sortable: true },
{ text: 'Role', value: 'role', sortable: true },
{ text: 'Joined', value: 'date_joined', sortable: true },
{ text: 'Last Seen', value: 'last_seen', sortable: true },
{ text: 'Actions', value: 'actions', sortable: false }
],
mdiMagnify,
mdiChevronLeft,
mdiPencil,
mdiAccount,
mdiEmail,
mdiAccountKey,
editDialog: false,
editingUser: {},
originalEditingUser: {}
}
},
computed: {
...mapState('auth', {
id: (state) => state.id,
isStaff: (state) => state.isStaff,
is_superuser: (state) => state.is_superuser
}),
currentUser() {
return {
id: this.id,
role:
this.is_superuser && this.isStaff
? 'owner'
: !this.is_superuser && this.isStaff
? 'admin'
: 'annotator'
}
},
currentUserId() {
return this.currentUser.id
},
currentUserRole() {
return this.currentUser.role
},
roleOptions() {
const options = [
{ text: 'Annotator', value: 'annotator', disabled: false },
{ text: 'Admin', value: 'admin', disabled: false },
{ text: 'Owner', value: 'owner', disabled: false }
]
if (this.currentUserRole === 'annotator') {
options.find((opt) => opt.value === 'admin').disabled = true
options.find((opt) => opt.value === 'owner').disabled = true
} else if (this.currentUserRole === 'admin') {
options.find((opt) => opt.value === 'owner').disabled = true
}
return options
},
selectedRoleColor() {
if (this.editingUser && this.editingUser.role) {
if (this.editingUser.role === 'admin') {
return '#FF2F00'
} else if (this.editingUser.role === 'owner') {
return '#a8c400'
data() {
return {
valid: false,
users: [],
selectedUser: null,
name: '',
email: '',
role: '',
showError: false,
errorMessage: '',
nameRules: [
(v) => !!v || 'Name is required',
(v) => (v && v.length >= 3) || 'Name must be at least 3 characters'
],
emailRules: [
(v) => !!v || 'Email is required',
(v) => /.+@.+\..+/.test(v) || 'E-mail must be valid'
],
roleOptions: [
{ text: 'Admin', value: 'admin' },
{ text: 'Annotator', value: 'annotator' }
]
}
}
return 'primary'
},
sortedUsers() {
const lowerSearch = this.search.toLowerCase()
const usersWithRole = this.users.map((user) => ({
...user,
role: user.role
? user.role
: user.is_staff && !user.is_superuser
? 'admin'
: user.is_superuser && user.is_staff
? 'owner'
: 'annotator'
}))
const filtered = usersWithRole.filter(
(user) =>
user.username.toLowerCase().includes(lowerSearch) ||
user.email.toLowerCase().includes(lowerSearch)
)
return this.customSort(filtered, this.options.sortBy, this.options.sortDesc)
async created() {
await this.fetchUsers()
},
pagedUsers() {
const start = (this.options.page - 1) * this.options.itemsPerPage
const end = start + this.options.itemsPerPage
const pageItems = this.sortedUsers.slice(start, end)
while (pageItems.length < this.options.itemsPerPage) {
pageItems.push({ _empty: true })
}
return pageItems
}
},
watch: {
search() {
this.options.page = 1
this.fetchUsers()
}
},
async created() {
await this.fetchUsers()
console.log(this.$store.state.auth)
},
methods: {
async fetchUsers() {
this.isLoading = true
try {
const response = await this.$axios.get('/v1/users/')
this.users = response.data
this.errorMessage = ''
} catch (error) {
if (error.response && error.response.data) {
const data = error.response.data
if (typeof data === 'string' && data.trim().startsWith('<')) {
this.errorMessage = "Error: Can't access our database!"
} else {
const errors = []
for (const [field, messages] of Object.entries(data)) {
const formattedMessages = Array.isArray(messages) ? messages.join(', ') : messages
errors.push(
`${field.charAt(0).toUpperCase() + field.slice(1)}: ${formattedMessages.replace(
/^\n+/,
''
)}`
)
}
this.errorMessage = errors.join('\n\n')
}
} else {
this.errorMessage = 'Error fetching users'
computed: {
sortedUsers() {
return [...this.users].sort((a, b) => a.id - b.id)
}
console.error('Error fetching users:', error)
} finally {
this.isLoading = false
}
},
getRowClass(item) {
return item._empty ? 'dummy-row' : ''
},
canEdit(user) {
if (user.id === this.currentUserId) return true
if (this.currentUserRole === 'owner') {
return user.role !== 'owner'
}
if (this.currentUserRole === 'admin') {
return user.role === 'annotator'
}
return false
},
openEdit(item) {
this.editErrorMessage = ''
this.originalEditingUser = JSON.parse(JSON.stringify(item))
this.editingUser = { ...item }
this.editDialog = true
},
async saveEdit() {
try {
if (this.editingUser.role === 'owner') {
this.editingUser.is_superuser = true
this.editingUser.is_staff = true
} else if (this.editingUser.role === 'admin') {
if (this.currentUserRole === 'annotator') {
this.editingUser.is_superuser = false
this.editingUser.is_staff = false
this.editingUser.role = 'annotator'
} else {
this.editingUser.is_superuser = false
this.editingUser.is_staff = true
this.editingUser.role = 'admin'
}
} else {
this.editingUser.is_superuser = false
this.editingUser.is_staff = false
this.editingUser.role = 'annotator'
}
let response
if (this.editingUser.id) {
response = await this.$axios.put(`/v1/users/${this.editingUser.id}/`, this.editingUser)
const index = this.users.findIndex((u) => u.id === this.editingUser.id)
if (index !== -1) {
this.$set(this.users, index, response.data)
}
methods: {
async fetchUsers() {
try {
const response = await this.$axios.get('/v1/users/')
this.users = response.data
} catch (error) {
console.error('Error fetching users:', error)
}
},
async fetchUserDetails(userId) {
try {
const response = await this.$axios.get(`/api/users/${userId}/`)
const user = response.data
this.name = user.username
this.email = user.email
this.role = user.role
} catch (error) {
console.error('Error fetching user details:', error)
}
},
async submitForm() {
if (!this.valid) {
this.showError = true
this.errorMessage = 'Please fill in all required fields correctly'
return
}
if (this.editingUser.id === this.currentUserId) {
this.$store.commit('auth/updateUser', {
id: response.data.id,
username: response.data.username,
is_staff: response.data.is_staff,
is_superuser: response.data.is_superuser
})
}
} else {
response = await this.$axios.post('/v1/users/', this.editingUser)
this.users.push(response.data)
}
this.options.page = 1
this.editDialog = false
this.$router.push({
path: '/message',
query: {
message: 'User updated successfully!',
redirect: '/edit-user'
}
})
} catch (error) {
console.error('Error saving user:', error)
this.editingUser = { ...this.originalEditingUser }
let errorDetail = ''
if (error.response && error.response.data) {
const data = error.response.data
if (data.username) {
errorDetail = "Error: " + (Array.isArray(data.username) ? data.username.join(' ') : data.username)
} else if (data.email) {
errorDetail = (Array.isArray(data.email) ? data.email.join(' ') : data.email)
} else if (typeof data === 'string' && data.trim().startsWith('<')) {
errorDetail = "Error: Can't access our database!"
} else {
const errors = []
for (const [field, messages] of Object.entries(data)) {
const formattedMessages =
Array.isArray(messages) ? messages.join(' ') : messages
errors.push(
`${field.charAt(0).toUpperCase() + field.slice(1)}: ${formattedMessages.replace(/^\n+/, '')}`
)
try {
const userData = {
username: this.name,
email: this.email,
role: this.role
}
await this.$axios.put(`/api/users/${this.selectedUser}/`, userData)
console.log('User updated successfully')
this.showError = false
} catch (error) {
this.showError = true
let errorDetail = ''
if (error.response && error.response.data) {
for (const [field, messages] of Object.entries(error.response.data)) {
if (Array.isArray(messages)) {
errorDetail += `<strong>${field}:</strong> ${messages.join(', ')}<br/>`
} else {
errorDetail += `<strong>${field}:</strong> ${messages}<br/>`
}
}
} else {
errorDetail = 'User update failed'
}
this.errorMessage = errorDetail
console.error('Update error:', error.response && error.response.data)
}
errorDetail = errors.join('\n')
}
} else {
errorDetail = 'Error saving user'
}
this.editErrorMessage = errorDetail
console.error('Error details:', error.response && error.response.data)
}
},
closeEdit() {
this.editDialog = false
this.editErrorMessage = ''
},
isCurrentUser(user) {
return user.id === this.currentUserId
},
getStatusColor(user) {
return this.isCurrentUser(user) ? 'green' : 'red'
},
timeAgo(dateStr) {
if (!dateStr) return ''
const now = new Date()
const past = new Date(dateStr)
const diffMs = now - past
const diffSeconds = Math.floor(diffMs / 1000)
if (diffSeconds < 0) return 'right now'
if (diffSeconds < 60) return diffSeconds + ' seconds ago'
const diffMinutes = Math.floor(diffSeconds / 60)
if (diffMinutes < 60) return diffMinutes + ' minutes ago'
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return diffHours + ' hours ago'
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return diffDays + ' days ago'
if (diffDays < 30) return diffDays + ' days ago'
const diffMonths = Math.floor(diffDays / 30)
if (diffMonths < 12) return diffMonths + ' months ago'
const diffYears = Math.floor(diffMonths / 12)
return diffYears + ' years ago'
},
customSort(items, sortBy, sortDesc) {
if (!sortBy.length) {
return items.sort((a, b) => {
if (a._empty && !b._empty) return 1
if (!a._empty && b._empty) return -1
return (a.id || 0) - (b.id || 0)
})
}
const field = sortBy[0]
return items.sort((a, b) => {
if (a._empty && !b._empty) return 1
if (!a._empty && b._empty) return -1
if (a._empty && b._empty) return 0
let comp = 0
if (field === 'role') {
const order = { annotator: 0, admin: 1, owner: 2 }
comp = order[a.role] - order[b.role]
} else if (field === 'date_joined') {
comp = new Date(a.date_joined) - new Date(b.date_joined)
} else if (field === 'last_seen') {
comp = new Date(a.last_login) - new Date(b.last_login)
} else if (field === 'id') {
comp = (a.id || 0) - (b.id || 0)
} else if (typeof a[field] === 'string') {
comp = a[field].localeCompare(b[field])
} else {
comp = (a[field] || 0) - (b[field] || 0)
}
return sortDesc[0] ? -comp : comp
})
}
}
}
</script>
<style scoped>
.v-card {
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.v-container {
padding: 20px;
}
.v-data-table {
margin-top: 20px;
}
.v-pagination {
margin-top: 10px;
}
::v-deep tr.dummy-row:hover {
background-color: transparent !important;
}
.status-circle {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
position: relative;
z-index: 1;
}
.theme--dark .v-card {
background-color: #1e1e1e !important;
color: #ffffff;
}
.theme--dark .v-text-field input {
color: #ffffff;
}
.theme--dark .v-select .v-input__slot {
background-color: #0f0f0f !important;
color: #ffffff;
}
.theme--dark .v-text-field {
background-color: #1e1e1e !important;
color: #ffffff;
}
.theme--dark .v-text-field :hover {
background-color: #191919 !important;
color: #ffffff;
}
.theme--dark .v-text-field input {
color: #ffffff;
}
:deep(.theme--dark .v-data-table tbody tr:hover:not(.dummy-row)) {
background-color: #151515 !important;
}
</style>
</script>

188
frontend/pages/projects/_id/perspectives/index.vue

@ -4,21 +4,10 @@
<v-btn class="text-capitalize ms-2" color="primary" @click="goToAdd">
{{ $t('generic.add') }}
</v-btn>
<v-btn
class="text-capitalize ms-2"
:disabled="!canDelete"
outlined
@click.stop="dialogDelete = true"
>
<v-btn class="text-capitalize ms-2" :disabled="!canDelete" outlined
@click.stop="dialogDelete = true">
{{ $t('generic.delete') }}
</v-btn>
<v-dialog v-model="dialogDelete" max-width="600px">
<form-delete-perspective
:selected="selected"
@cancel="dialogDelete = false"
@remove="remove"
/>
</v-dialog>
</v-card-title>
<v-card-text>
@ -31,18 +20,12 @@
filled
style="margin-bottom: 1rem"
/>
<v-progress-circular
v-if="isLoading"
indeterminate
color="primary"
class="ma-3"
/>
<v-progress-circular v-if="isLoading" indeterminate color="primary" class="ma-3" />
<div class="d-flex justify-center" v-if="!isLoading">
<div style="max-width: 800px; width: 100%;">
<div v-for="item in items" :key="item.id" class="mb-4">
<v-card class="mx-auto" outlined elevation="2" rounded>
<!-- Header bar using v-sheet: shows "username: subject" -->
<v-sheet color="primary" dark class="py-3 px-4 rounded-t-lg d-flex flex-column">
<div class="text-h6 font-weight-medium">
{{ item.user.username }}:<span v-if="item.subject"> {{ item.subject }}</span>
@ -59,10 +42,22 @@
<div>
{{ item.text }}
</div>
<!-- Annotation snippet block -->
<div v-if="item.linkedAnnotations && item.linkedAnnotations.length">
<v-divider class="my-2"></v-divider>
<div v-for="ann in item.linkedAnnotations" :key="ann.id">
<strong>Annotation:</strong> {{ ann.text }}
<span v-if="ann.label"> ({{ ann.label }})</span>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-chip small>{{ item.category }}</v-chip>
<v-spacer></v-spacer>
<v-btn color="secondary" small @click="openLinkDialog(item)">
Link Annotation
</v-btn>
</v-card-actions>
</v-card>
</div>
@ -73,6 +68,41 @@
</div>
</div>
</v-card-text>
<!-- Link Annotation Dialog -->
<v-dialog v-model="dialogLink" persistent max-width="600px">
<v-card>
<v-card-title>
Select a Dataset item to link its annotations
</v-card-title>
<v-card-text>
<v-select
v-model="selectedDataset"
:items="datasetItems"
:item-text="getDatasetLabel"
item-value="id"
label="Choose a dataset item"
dense
/>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" text @click="confirmLink" :disabled="!selectedDataset">
Confirm
</v-btn>
<v-btn text @click="closeLinkDialog">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Perspective Dialog -->
<v-dialog v-model="dialogDelete" max-width="600px">
<form-delete-perspective
:selected="selected"
@cancel="dialogDelete = false"
@remove="remove"
/>
</v-dialog>
</v-card>
</template>
@ -88,6 +118,10 @@ export default Vue.extend({
data() {
return {
dialogDelete: false,
dialogLink: false,
selectedDataset: null,
datasetItems: [] as any[],
currentPerspective: null as any,
selected: [] as any[],
search: '',
options: {
@ -100,6 +134,7 @@ export default Vue.extend({
total: 0,
isLoading: false,
mdiMagnify,
categoryTypes: [] as any[], // Fetched category labels
}
},
computed: {
@ -132,19 +167,13 @@ export default Vue.extend({
console.log('API Response:', response.data)
const data = response.data
const items = data.results || []
// For each perspective, if the user is a number, fetch user details.
const promises = items.map((item: any) => {
if (typeof item.user === 'number') {
return axios.get(`/v1/users/${item.user}/`)
.then((userResponse: any) => {
item.user = userResponse.data
})
.catch(() => {
item.user = { username: 'N/A' }
})
} else {
return Promise.resolve()
.then((userResponse: any) => { item.user = userResponse.data })
.catch(() => { item.user = { username: 'N/A' } })
}
return Promise.resolve()
})
Promise.all(promises).then(() => {
console.log('Processed Items:', items)
@ -155,19 +184,21 @@ export default Vue.extend({
.catch((error: any) => {
console.error('Error fetching perspectives:', error.response || error.message)
})
.finally(() => {
this.isLoading = false
})
.finally(() => { this.isLoading = false })
},
updateQuery() {
this.fetchPerspectives()
},
// Simple relative time formatter.
timeAgo(dateStr: string): string {
if (!dateStr) return 'N/A'
const dateObj = new Date(dateStr)
const cleanDateStr = dateStr
.replace(' ', 'T')
.replace(/(\.\d{3})\d+/, '$1')
.replace(/([+-]\d{2})(\d{2})$/, '$1:$2')
const dateObj = new Date(cleanDateStr)
if (isNaN(dateObj.getTime())) return 'N/A'
const now = new Date()
const diffMs = now.valueOf() - dateObj.valueOf()
const diffMs = now.getTime() - dateObj.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
if (diffSeconds < 60) return `${diffSeconds} seconds ago`
const diffMinutes = Math.floor(diffSeconds / 60)
@ -175,7 +206,6 @@ export default Vue.extend({
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours} hours ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) return `${diffDays} days ago`
if (diffDays < 30) return `${diffDays} days ago`
const diffMonths = Math.floor(diffDays / 30)
if (diffMonths < 12) return `${diffMonths} months ago`
@ -184,6 +214,91 @@ export default Vue.extend({
},
formatTime(time: string): string {
return this.timeAgo(time)
},
openLinkDialog(perspective: any) {
this.currentPerspective = perspective
this.dialogLink = true
this.fetchDatasetItems()
},
fetchDatasetItems() {
axios.get(`/v1/projects/${this.$route.params.id}/examples?limit=10&offset=0`)
.then((response: any) => {
this.datasetItems = response.data.results || []
console.log('Dataset items:', this.datasetItems)
if (this.datasetItems.length > 0 && !this.selectedDataset) {
this.selectedDataset = this.datasetItems[0].id
}
})
.catch((error: any) => {
console.error('Error fetching dataset items:', error.response || error.message)
})
},
fetchCategoryTypes() {
const projectId = this.$route.params.id
axios.get(`/v1/projects/${projectId}/category-types/`)
.then((response: any) => {
this.categoryTypes = response.data.results || response.data || []
console.log('Fetched category types:', this.categoryTypes)
})
.catch((error: any) => {
console.error('Error fetching category types:', error.response || error.message)
})
},
confirmLink() {
if (!this.selectedDataset || !this.currentPerspective) {
console.error('selectedDataset or currentPerspective is not set.')
return
}
console.log('Selected dataset:', this.selectedDataset)
console.log('Current perspective:', this.currentPerspective)
const datasetItem = this.datasetItems.find(item => item.id === this.selectedDataset)
if (!datasetItem) {
console.error('Dataset item not found.')
return
}
const truncatedText = datasetItem.text.length > 50
? datasetItem.text.substring(0, 50) + '...'
: datasetItem.text
let labelText = ''
if (datasetItem.category && this.categoryTypes.length > 0) {
const category = this.categoryTypes.find((cat: any) => cat.id === datasetItem.category)
if (category) {
labelText = category.text
}
}
const annotation = { id: datasetItem.id, text: truncatedText, label: labelText }
this.items.forEach((item: any, index: number) => {
if (item.id === this.currentPerspective.id) {
const updatedAnnotations = item.linkedAnnotations
? [...item.linkedAnnotations, annotation]
: [annotation]
this.$set(this.items, index, { ...item, linkedAnnotations: updatedAnnotations })
console.log('Updated item with annotations:', this.items[index])
const projectId = this.$route.params.id
axios.patch(`/v1/projects/${projectId}/perspectives/${this.currentPerspective.id}/`, {
linkedAnnotations: updatedAnnotations
})
.then(response => {
console.log("Perspective updated:", response.data)
this.fetchPerspectives()
})
.catch(error => {
console.error("Error updating perspective:", error.response || error.message)
})
}
})
this.closeLinkDialog()
},
closeLinkDialog() {
this.dialogLink = false
this.selectedDataset = null
this.currentPerspective = null
},
getDatasetLabel(item: any): string {
const snippet = item.text ? item.text.substring(0, 50) + (item.text.length > 50 ? '...' : '') : ''
const timeLabel = item.created_at ? this.formatTime(item.created_at) : item.upload_name || 'Unknown time'
return `${snippet} (${timeLabel})`
}
},
watch: {
@ -200,6 +315,7 @@ export default Vue.extend({
},
mounted() {
this.fetchPerspectives()
this.fetchCategoryTypes()
}
})
</script>

Loading…
Cancel
Save