From 43942509802ab99d65ca53f1b582e4f1903cc4cb Mon Sep 17 00:00:00 2001 From: karilint Date: Wed, 3 Jun 2026 18:00:47 +0300 Subject: [PATCH] Add occurrence row history --- backend/src/services/locality.ts | 8 + backend/src/services/occurrenceService.ts | 144 +++++++++++----- backend/src/services/species.ts | 9 +- .../DetailView/common/FieldUpdateHistory.tsx | 154 +++++++++++++----- .../Locality/Tabs/OccurrencesTab.tsx | 10 ++ .../Species/Tabs/LocalitySpeciesTab.tsx | 10 ++ frontend/src/shared/types/data.ts | 8 +- .../DetailView/FieldUpdateHistory.test.tsx | 89 +++++++++- 8 files changed, 336 insertions(+), 96 deletions(-) diff --git a/backend/src/services/locality.ts b/backend/src/services/locality.ts index 88f51343..c6fac3a2 100644 --- a/backend/src/services/locality.ts +++ b/backend/src/services/locality.ts @@ -21,6 +21,7 @@ import { validateCollectingMethodValues } from '../utils/validation/collectingMe import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person' import { getReferenceDetails } from './reference' import { addNullExactDateToReferenceJoins, referenceWithoutExactDateSelect } from './utils/referenceDate' +import { getOccurrenceUpdatesForRows } from './occurrenceService' const normalizeNumberField = (value: unknown) => { if (typeof value === 'string') { @@ -302,6 +303,13 @@ export const getLocalityDetails = async (id: number, user: User | undefined) => } } + const occurrenceUpdatesByKey = await getOccurrenceUpdatesForRows(result.now_ls) + result.now_ls = result.now_ls.map(occurrence => ({ + ...occurrence, + now_oau: (occurrenceUpdatesByKey.get(`${occurrence.lid}:${occurrence.species_id}`) ?? + []) as unknown as LocalitySpeciesDetailsType['now_oau'], + })) + const { now_time_unit_now_loc_bfa_minTonow_time_unit: minTimeUnit, now_time_unit_now_loc_bfa_maxTonow_time_unit: maxTimeUnit, diff --git a/backend/src/services/occurrenceService.ts b/backend/src/services/occurrenceService.ts index 914c5ef5..9d3c2029 100644 --- a/backend/src/services/occurrenceService.ts +++ b/backend/src/services/occurrenceService.ts @@ -111,6 +111,16 @@ const getOccurrenceLogRows = (rows: RawOccurrenceLogRow[], speciesPk: string): O return occurrenceRows } +const getOccurrenceLogRowsForKey = (rows: RawOccurrenceLogRow[], lid: number, speciesId: number) => { + const lidPk = `${lid.toString().length}.${lid};` + const speciesPk = `${speciesId.toString().length}.${speciesId};` + + return getOccurrenceLogRows( + rows.filter(row => isOccurrenceLogCandidate(row) && row.pk_data.includes(lidPk)), + speciesPk + ) +} + type OccurrenceUpdate = { occ_date: Date | null occ_authorizer: string @@ -120,6 +130,16 @@ type OccurrenceUpdate = { updates: OccurrenceLogRow[] } +const OCCURRENCE_LOG_QUERY_BATCH_SIZE = 100 + +const chunkArray = (values: T[], size: number) => { + const chunks: T[][] = [] + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)) + } + return chunks +} + const stringifyLogValue = (value: unknown) => { if (value === null || value === undefined) return '' if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value) @@ -170,18 +190,32 @@ const deduplicateOccurrenceUpdates = (updates: OccurrenceUpdate[]) => { return Array.from(deduplicated.values()) } -const getOccurrenceUpdates = async (lid: number, speciesId: number) => { - const lidPk = `${lid.toString().length}.${lid};` - const speciesPk = `${speciesId.toString().length}.${speciesId};` +export const getOccurrenceUpdatesForRows = async (keys: Array<{ lid: number; species_id: number }>) => { + const uniqueKeys = Array.from(new Map(keys.map(key => [`${key.lid}:${key.species_id}`, key] as const)).values()) - const candidateLogsRaw = await logDb.log.findMany({ - where: { - table_name: 'now_ls', - pk_data: { contains: lidPk }, - }, - }) + if (uniqueKeys.length === 0) return new Map() - const nowLsLogs = getOccurrenceLogRows(candidateLogsRaw, speciesPk) + const uniqueLidPks = Array.from(new Set(uniqueKeys.map(({ lid }) => `${lid.toString().length}.${lid};`))) + const candidateLogsRaw: RawOccurrenceLogRow[] = [] + + for (const lidPkBatch of chunkArray(uniqueLidPks, OCCURRENCE_LOG_QUERY_BATCH_SIZE)) { + candidateLogsRaw.push( + ...(await logDb.log.findMany({ + where: { + table_name: 'now_ls', + OR: lidPkBatch.map(lidPk => ({ pk_data: { contains: lidPk } })), + }, + })) + ) + } + + const logsByKey = new Map() + + for (const { lid, species_id: speciesId } of uniqueKeys) { + logsByKey.set(`${lid}:${speciesId}`, getOccurrenceLogRowsForKey(candidateLogsRaw, lid, speciesId)) + } + + const nowLsLogs = Array.from(logsByKey.values()).flat() const luids = collectUniqueIds(nowLsLogs, 'luid') const suids = collectUniqueIds(nowLsLogs, 'suid') @@ -217,47 +251,67 @@ const getOccurrenceUpdates = async (lid: number, speciesId: number) => { : Promise.resolve([]), ]) + const localityUpdateById = new Map(localityUpdates.map(update => [update.luid, update] as const)) + const speciesUpdateById = new Map(speciesUpdates.map(update => [update.suid, update] as const)) + const peopleLookup = await buildPersonLookupByInitials([ ...localityUpdates.flatMap(update => [update.lau_authorizer, update.lau_coordinator]), ...speciesUpdates.flatMap(update => [update.sau_authorizer, update.sau_coordinator]), ]) - const occurrenceUpdates = deduplicateOccurrenceUpdates([ - ...localityUpdates.map(update => ({ - occ_date: update.lau_date, - occ_authorizer: getPersonDisplayName( - getPersonFromLookup(peopleLookup, update.lau_authorizer), - update.lau_authorizer - ), - occ_coordinator: getPersonDisplayName( - getPersonFromLookup(peopleLookup, update.lau_coordinator), - update.lau_coordinator - ), - occ_comment: update.lau_comment ?? '', - references: addNullExactDateToReferenceJoins(update.now_lr) as unknown as AnyReference[], - updates: nowLsLogs.filter(logRow => logRow.luid === update.luid), - })), - ...speciesUpdates.map(update => ({ - occ_date: update.sau_date, - occ_authorizer: getPersonDisplayName( - getPersonFromLookup(peopleLookup, update.sau_authorizer), - update.sau_authorizer - ), - occ_coordinator: getPersonDisplayName( - getPersonFromLookup(peopleLookup, update.sau_coordinator), - update.sau_coordinator - ), - occ_comment: update.sau_comment ?? '', - references: addNullExactDateToReferenceJoins(update.now_sr) as unknown as AnyReference[], - updates: nowLsLogs.filter(logRow => logRow.suid === update.suid), - })), - ]) + const occurrenceUpdatesByKey = new Map() + + for (const [key, logs] of logsByKey) { + const occurrenceUpdates = deduplicateOccurrenceUpdates([ + ...Array.from(new Set(logs.map(log => log.luid).filter((luid): luid is number => luid !== null))) + .map(luid => localityUpdateById.get(luid)) + .filter((update): update is NonNullable => Boolean(update)) + .map(update => ({ + occ_date: update.lau_date, + occ_authorizer: getPersonDisplayName( + getPersonFromLookup(peopleLookup, update.lau_authorizer), + update.lau_authorizer + ), + occ_coordinator: getPersonDisplayName( + getPersonFromLookup(peopleLookup, update.lau_coordinator), + update.lau_coordinator + ), + occ_comment: update.lau_comment ?? '', + references: addNullExactDateToReferenceJoins(update.now_lr) as unknown as AnyReference[], + updates: logs.filter(logRow => logRow.luid === update.luid), + })), + ...Array.from(new Set(logs.map(log => log.suid).filter((suid): suid is number => suid !== null))) + .map(suid => speciesUpdateById.get(suid)) + .filter((update): update is NonNullable => Boolean(update)) + .map(update => ({ + occ_date: update.sau_date, + occ_authorizer: getPersonDisplayName( + getPersonFromLookup(peopleLookup, update.sau_authorizer), + update.sau_authorizer + ), + occ_coordinator: getPersonDisplayName( + getPersonFromLookup(peopleLookup, update.sau_coordinator), + update.sau_coordinator + ), + occ_comment: update.sau_comment ?? '', + references: addNullExactDateToReferenceJoins(update.now_sr) as unknown as AnyReference[], + updates: logs.filter(logRow => logRow.suid === update.suid), + })), + ]).sort((a, b) => { + const timeA = a.occ_date ? new Date(a.occ_date).getTime() : 0 + const timeB = b.occ_date ? new Date(b.occ_date).getTime() : 0 + return timeB - timeA + }) - return occurrenceUpdates.sort((a, b) => { - const timeA = a.occ_date ? new Date(a.occ_date).getTime() : 0 - const timeB = b.occ_date ? new Date(b.occ_date).getTime() : 0 - return timeB - timeA - }) + occurrenceUpdatesByKey.set(key, occurrenceUpdates) + } + + return occurrenceUpdatesByKey +} + +const getOccurrenceUpdates = async (lid: number, speciesId: number) => { + const updatesByKey = await getOccurrenceUpdatesForRows([{ lid, species_id: speciesId }]) + return updatesByKey.get(`${lid}:${speciesId}`) ?? [] } export const getOccurrenceByCompositeKey = async (lid: number, speciesId: number, user?: User) => { diff --git a/backend/src/services/species.ts b/backend/src/services/species.ts index 0cd1e571..00f25350 100644 --- a/backend/src/services/species.ts +++ b/backend/src/services/species.ts @@ -7,6 +7,7 @@ import { logDb, nowDb } from '../utils/db' import { getReferenceDetails } from './reference' import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person' import { getIdsOfUsersProjects } from './locality' +import { getOccurrenceUpdatesForRows } from './occurrenceService' const TAXONOMIC_FIELDS: Array = ['genus_name', 'species_name', 'unique_identifier'] @@ -236,7 +237,13 @@ export const getSpeciesDetails = async (id: number, user?: User) => { }) const filteredLocalities = await filterSpeciesLocalitiesByUser(result.now_ls as SpeciesDetailsType['now_ls'], user) - const sanitizedLocalities = stripLocalityProjectLinks(filteredLocalities) + const occurrenceUpdatesByKey = await getOccurrenceUpdatesForRows(filteredLocalities) + const localitiesWithOccurrenceUpdates = filteredLocalities.map(occurrence => ({ + ...occurrence, + now_oau: (occurrenceUpdatesByKey.get(`${occurrence.lid}:${occurrence.species_id}`) ?? + []) as unknown as SpeciesDetailsType['now_ls'][number]['now_oau'], + })) + const sanitizedLocalities = stripLocalityProjectLinks(localitiesWithOccurrenceUpdates) return fixBigInt({ ...result, now_ls: sanitizedLocalities, com_taxa_synonym: synonyms || [] }) as SpeciesDetailsType } diff --git a/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx index ce55cc1b..af92caf0 100644 --- a/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx +++ b/frontend/src/components/DetailView/common/FieldUpdateHistory.tsx @@ -9,8 +9,8 @@ type UpdateContainer = Record & { updates: UpdateLog[] } -type FieldUpdate = { - log: UpdateLog +type HistoryUpdate = { + logs: UpdateLog[] container: UpdateContainer } @@ -52,12 +52,12 @@ const collectUpdateContainers = (value: unknown, seen = new Set()): Upd return containers } -const getFieldUpdates = (data: unknown, field: string): FieldUpdate[] => +const getFieldUpdates = (data: unknown, field: string): HistoryUpdate[] => collectUpdateContainers(data).flatMap(container => container.updates .filter(log => log.column_name === field) .map(log => ({ - log, + logs: [log], container, })) ) @@ -98,38 +98,37 @@ const getEntryUpdates = ( columnName: string | undefined, rowValue: unknown, pkValues: unknown[] -): FieldUpdate[] => { +): HistoryUpdate[] => { const encodedPkSegments = getEncodedPkSegments(pkValues) const fallbackPkSegment = getEncodedPkSegment(rowValue) - return collectUpdateContainers(data).flatMap(container => - container.updates - .filter(log => { - if (log.table_name !== tableName) return false - - const pkData = getPkData(log) - const pkDataMatches = - typeof pkData === 'string' && encodedPkSegments.length > 0 - ? encodedPkSegments.every(segment => pkData.includes(segment)) - : false - if (pkDataMatches) return true - if (typeof pkData === 'string' && encodedPkSegments.length > 0) return false - - const fallbackPkDataMatches = - typeof pkData === 'string' && encodedPkSegments.length === 0 && fallbackPkSegment - ? pkData.includes(fallbackPkSegment) - : false - if (fallbackPkDataMatches) return true - - if (columnName && log.column_name !== columnName) return false - - return valuesMatch(log.new_data, rowValue) || valuesMatch(log.old_data, rowValue) - }) - .map(log => ({ - log, - container, - })) - ) + const updates = collectUpdateContainers(data).flatMap(container => { + const logs = container.updates.filter(log => { + if (log.table_name !== tableName) return false + + const pkData = getPkData(log) + const pkDataMatches = + typeof pkData === 'string' && encodedPkSegments.length > 0 + ? encodedPkSegments.every(segment => pkData.includes(segment)) + : false + if (pkDataMatches) return true + if (typeof pkData === 'string' && encodedPkSegments.length > 0) return false + + const fallbackPkDataMatches = + typeof pkData === 'string' && encodedPkSegments.length === 0 && fallbackPkSegment + ? pkData.includes(fallbackPkSegment) + : false + if (fallbackPkDataMatches) return true + + if (columnName && log.column_name !== columnName) return false + + return valuesMatch(log.new_data, rowValue) || valuesMatch(log.old_data, rowValue) + }) + + return logs.length > 0 ? [{ logs, container }] : [] + }) + + return deduplicateHistoryUpdates(updates) } const formatValue = (value: unknown): string => { @@ -176,12 +175,58 @@ const collectReferences = (value: unknown, seen = new Set()): AnyRefere .flatMap(([, nestedValue]) => collectReferences(nestedValue, seen)) } +const getLogSignature = (log: UpdateLog) => + [ + formatValue(log.table_name), + formatValue(log.column_name), + formatValue(log.log_action), + formatValue(getPkData(log)), + formatValue(log.old_data), + formatValue(log.new_data), + ].join('|') + +const getReferencesSignature = (container: UpdateContainer) => + collectReferences(container) + .map(reference => formatValue(reference.rid)) + .sort() + .join(',') + +const getHistoryUpdateSignature = ({ logs, container }: HistoryUpdate) => + [ + formatDate(getDate(container)), + formatValue(getEditor(container)), + formatValue(getCoordinator(container)), + formatValue(getComment(container)), + getReferencesSignature(container), + [...logs].map(getLogSignature).sort().join('||'), + ].join('\n') + +const deduplicateHistoryUpdates = (updates: HistoryUpdate[]) => { + const seen = new Set() + return updates.filter(update => { + const signature = getHistoryUpdateSignature(update) + if (seen.has(signature)) return false + seen.add(signature) + return true + }) +} + const formatAction = (action: number | null | undefined) => { if (action === 1) return 'Delete' if (action === 3) return 'Update' return 'Add' } +const formatActions = (logs: UpdateLog[]) => { + const actions = Array.from(new Set(logs.map(log => formatAction(log.log_action)))) + return actions.join(', ') +} + +const formatTables = (logs: UpdateLog[]) => { + const tables = Array.from(new Set(logs.map(log => formatValue(log.table_name)).filter(Boolean))) + return tables.join(', ') +} + const UpdateHistoryPopover = ({ idPrefix, title, @@ -194,7 +239,7 @@ const UpdateHistoryPopover = ({ title: string tooltip: string ariaLabel: string - updates: FieldUpdate[] + updates: HistoryUpdate[] onOpen?: (event: MouseEvent) => void }) => { const [anchorElement, setAnchorElement] = useState(null) @@ -236,10 +281,11 @@ const UpdateHistoryPopover = ({ {title} - {updates.map(({ log, container }, index) => { + {updates.map(({ logs, container }, index) => { + const primaryLog = logs[0] const references = collectReferences(container) return ( - + Date: {formatDate(getDate(container))} @@ -250,17 +296,37 @@ const UpdateHistoryPopover = ({ Coordinator: {formatValue(getCoordinator(container))} - Action: {formatAction(log.log_action)} + Action: {formatActions(logs)} - Table: {formatValue(log.table_name)} - - - Before: {log.old_data ?? ''} - - - After: {log.new_data ?? ''} + Table: {formatTables(logs)} + {logs.length === 1 ? ( + <> + + Before: {logs[0].old_data ?? ''} + + + After: {logs[0].new_data ?? ''} + + + ) : ( + + + Changes: + + {logs.map((log, logIndex) => ( + + {formatValue(log.column_name)}: {formatValue(log.old_data)} ->{' '} + {formatValue(log.new_data)} + + ))} + + )} {getComment(container) ? ( Comment: {formatValue(getComment(container))} diff --git a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx index 980782a2..2f684c62 100644 --- a/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx +++ b/frontend/src/components/Locality/Tabs/OccurrencesTab.tsx @@ -16,6 +16,7 @@ import { exportOccurrenceMapSvg, getUniqueLocalityOccurrenceMapExportLocalities, } from '@/components/Species/localitySpeciesMapExport' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' const hasMesowearScoreInputs = (row: LocalitySpecies) => { return ( @@ -258,6 +259,15 @@ export const OccurrencesTab = () => { getDetailPath={row => `/occurrence/${row.lid}/${row.species_id}`} kmlExport={kmlExport} svgExport={svgExport} + renderReadRowActions={({ row }) => ( + occurrence.species_id} + getPkValues={occurrence => [occurrence.lid, occurrence.species_id]} + /> + )} /> ) diff --git a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx index cce268ba..37cc0390 100755 --- a/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx +++ b/frontend/src/components/Species/Tabs/LocalitySpeciesTab.tsx @@ -17,6 +17,7 @@ import { exportOccurrenceMapSvg, getUniqueSpeciesLocalityMapExportLocalities, } from '../localitySpeciesMapExport' +import { EntryUpdateHistory } from '@/components/DetailView/common/FieldUpdateHistory' const hasMesowearScoreInputs = (row: SpeciesLocality) => { return ( @@ -244,6 +245,15 @@ export const LocalitySpeciesTab = () => { checkRowRestriction={row => Boolean(row.now_loc?.loc_status)} kmlExport={kmlExport} svgExport={svgExport} + renderReadRowActions={({ row }) => ( + occurrence.lid} + getPkValues={occurrence => [occurrence.lid, occurrence.species_id]} + /> + )} /> ) diff --git a/frontend/src/shared/types/data.ts b/frontend/src/shared/types/data.ts index dd3e9d41..6cbf2491 100755 --- a/frontend/src/shared/types/data.ts +++ b/frontend/src/shared/types/data.ts @@ -19,10 +19,14 @@ export type SedimentaryStructureValues = Prisma.now_ss_values export type CollectingMethod = Prisma.now_coll_meth export type CollectingMethodValues = Prisma.now_coll_meth_values export type LocalityProject = Prisma.now_plr & { now_proj: Prisma.now_proj } -export type LocalitySpecies = FixBigInt & { com_species: SpeciesType } -export type LocalitySpeciesDetailsType = FixBigInt & { com_species: SpeciesDetailsType } +export type LocalitySpecies = FixBigInt & { com_species: SpeciesType; now_oau?: OccurrenceUpdate[] } +export type LocalitySpeciesDetailsType = FixBigInt & { + com_species: SpeciesDetailsType + now_oau?: OccurrenceUpdate[] +} export type SpeciesLocality = FixBigInt & { now_loc: Prisma.now_loc + now_oau?: OccurrenceUpdate[] // Explicitly required in the Species locality payload because MW Score is // calculated client-side from these values in the occurrence table. mw_scale_min: number | null diff --git a/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx index 4986c5a8..2e92239a 100644 --- a/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx +++ b/frontend/src/tests/components/DetailView/FieldUpdateHistory.test.tsx @@ -69,6 +69,62 @@ const detailData = { ], }, ], + now_oau: [ + { + occ_date: '2026-06-04', + occ_authorizer: 'ED', + occ_coordinator: 'CO', + occ_comment: 'Occurrence row changed', + references: [reference], + updates: [ + { + log_id: 6, + table_name: 'now_ls', + pk_data: '3.100;3.200;', + column_name: 'lid', + log_action: 3, + old_data: '100', + new_data: '101', + }, + { + log_id: 7, + table_name: 'now_ls', + pk_data: '3.100;3.200;', + column_name: 'species_id', + log_action: 3, + old_data: '200', + new_data: '201', + }, + ], + }, + { + occ_date: '2026-06-04', + occ_authorizer: 'ED', + occ_coordinator: 'CO', + occ_comment: 'Occurrence row changed', + references: [reference], + updates: [ + { + log_id: 8, + table_name: 'now_ls', + pk_data: '3.100;3.200;', + column_name: 'lid', + log_action: 3, + old_data: '100', + new_data: '101', + }, + { + log_id: 9, + table_name: 'now_ls', + pk_data: '3.100;3.200;', + column_name: 'species_id', + log_action: 3, + old_data: '200', + new_data: '201', + }, + ], + }, + ], now_sau: [ { sau_date: '2026-05-29', @@ -167,16 +223,41 @@ describe('FieldUpdateHistory', () => { await user.click(screen.getByLabelText('Show entry history for sedimentary structure cross-bedding')) expect(screen.getByRole('heading', { name: 'sedimentary structure cross-bedding entry history' })).toBeTruthy() - expect(screen.getAllByText('Added sedimentary structure')).toHaveLength(2) - expect(screen.getAllByText(/Field update reference/)).toHaveLength(2) + expect(screen.getAllByText('Added sedimentary structure')).toHaveLength(1) + expect(screen.getAllByText(/Field update reference/)).toHaveLength(1) const tableRow = screen.getAllByText('Table:')[0].closest('p') expect(tableRow).toBeTruthy() expect(within(tableRow as HTMLElement).getByText('now_ss')).toBeInTheDocument() - expect(screen.getByText('old row comment')).toBeInTheDocument() - expect(screen.getByText('new row comment')).toBeInTheDocument() + expect(screen.getByText('ss_comment:').closest('p')).toHaveTextContent('old row comment -> new row comment') expect(screen.queryByText('101')).not.toBeInTheDocument() }) + it('groups duplicate occurrence row update logs into one history entry', async () => { + const user = userEvent.setup() + + render( + + + row.species_id} + getPkValues={row => [row.lid, row.species_id]} + /> + + + ) + + await user.click(screen.getByLabelText('Show entry history for occurrence 100/200')) + + expect(screen.getByRole('heading', { name: 'occurrence 100/200 entry history' })).toBeTruthy() + expect(screen.getAllByText('Occurrence row changed')).toHaveLength(1) + expect(screen.getAllByText(/Field update reference/)).toHaveLength(1) + expect(screen.getByText('lid:').closest('p')).toHaveTextContent('100 -> 101') + expect(screen.getByText('species_id:').closest('p')).toHaveTextContent('200 -> 201') + }) + it('uses a safe popover id for entry values with spaces or punctuation', async () => { const user = userEvent.setup()