Add locality & species combined export feature

This commit is contained in:
ShootingStar91
2024-07-19 14:19:34 +03:00
parent e95603bbd0
commit 3f33e1830b
10 changed files with 258 additions and 52 deletions
+15 -3
View File
@@ -1,5 +1,10 @@
import { Request, Router } from 'express'
import { getAllLocalities, getLocalityDetails, validateEntireLocality } from '../services/locality'
import {
getAllLocalities,
getLocalityDetails,
getLocalitySpeciesList,
validateEntireLocality,
} from '../services/locality'
import { fixBigInt } from '../utils/common'
import { EditDataType, LocalityDetailsType } from '../../../frontend/src/backendTypes'
import { write } from '../services/write/write'
@@ -9,11 +14,18 @@ import { Role } from '../../../frontend/src/types'
const router = Router()
router.get('/all', async (req, res) => {
const showAll = req.user && [Role.Admin, Role.EditUnrestricted].includes(req.user.role)
const localities = await getAllLocalities(showAll, req.user)
const localities = await getAllLocalities(req.user)
return res.status(200).send(fixBigInt(localities))
})
router.post('/localitySpecies', async (req: Request<object, object, { lids: number[] }>, res) => {
const user = req.user
const lids = req.body.lids
if (!lids) return res.status(400).send({ message: 'Missing lids' })
const localitySpeciesList = await getLocalitySpeciesList(lids, user)
return res.status(200).json(localitySpeciesList)
})
router.get('/:id', async (req, res) => {
const id = parseInt(req.params.id)
const locality = await getLocalityDetails(id, req.user)
+147 -4
View File
@@ -4,9 +4,137 @@ import Prisma from '../../prisma/generated/now_test_client'
import { validateLocality } from '../../../frontend/src/validators/locality'
import { ValidationObject } from '../../../frontend/src/validators/validator'
import { fixBigInt } from '../utils/common'
import { Role } from '../../../frontend/src/types'
import { Role, calculateMeanHypsodonty } from '../../../frontend/src/types'
import { AccessError } from '../middlewares/authorizer'
export const getLocalitySpeciesList = async (lids: number[], user: User) => {
// Get localities separately with getAllLocalities to know which localities user has access to.
// Unoptimal, if it's slow try a different way.
const localityList = await getAllLocalities(user)
const lidsSet = new Set(lids)
const premittedLids = localityList.filter(loc => lidsSet.has(loc.lid)).map(loc => loc.lid)
const localitySpecies = await nowDb.now_loc.findMany({
select: {
lid: true,
loc_name: true,
dms_lat: true,
dms_long: true,
dec_lat: true,
dec_long: true,
altitude: true,
max_age: true,
bfa_max: true,
bfa_max_abs: true,
frac_max: true,
min_age: true,
bfa_min: true,
bfa_min_abs: true,
frac_min: true,
chron: true,
age_comm: true,
basin: true,
subbasin: true,
country: true,
state: true,
county: true,
appr_num_spm: true,
gen_loc: true,
now_syn_loc: true,
estimate_precip: true,
estimate_npp: true,
pers_woody_cover: true,
pers_pollen_nap: true,
pers_pollen_other: true,
now_ls: {
include: { com_species: true },
},
},
where: {
lid: {
in: premittedLids,
},
},
})
// TODO calc mean hypsodonty & add title
const localityTitles = [
'lid',
'loc_name',
'dms_lat',
'dms_long',
'dec_lat',
'dec_long',
'altitude',
'max_age',
'bfa_max',
'bfa_max_abs',
'frac_max',
'min_age',
'bfa_min',
'bfa_min_abs',
'frac_min',
'chron',
'age_comm',
'basin',
'subbasin',
'country',
'state',
'county',
'appr_num_spm',
'gen_loc',
'now_syn_loc',
'estimate_precip',
'estimate_npp',
'pers_woody_cover',
'pers_pollen_nap',
'pers_pollen_other',
'mean hypsodonty',
]
const formatValue = (value: string | number | boolean | null | object | bigint) => {
if (Array.isArray(value)) return `"${value.map((value: { synonym: string }) => value.synonym).join(', ')}"`
if (typeof value === 'object' && value !== null)
throw new Error('Internal error: Unexpected non-array object in export data')
if (typeof value === 'bigint') return Number(BigInt)
return `"${value}"`
}
const speciesTitles = Object.keys(nowDb.com_species.fields)
const lsTitles = Object.keys(nowDb.now_ls.fields)
const titleRow = [...localityTitles, ...speciesTitles, ...lsTitles]
const data = localitySpecies.flatMap(localityWithSpecies => {
const { now_ls, ...locality } = localityWithSpecies
const meanHypsodonty = calculateMeanHypsodonty(localityWithSpecies as unknown as LocalityDetailsType)
const speciesList =
now_ls.length > 0
? now_ls.map(ls => {
const { com_species, ...nowLs } = ls
return [
...Object.values(locality).map(value => formatValue(value)),
meanHypsodonty,
...Object.values(com_species).map(value => formatValue(value)),
...Object.values(nowLs).map(value => formatValue(value)),
]
})
: [
[
...Object.values(locality).map(value => formatValue(value)),
meanHypsodonty,
...speciesTitles.map(() => ''),
...lsTitles.map(() => ''),
],
]
return speciesList
})
return [titleRow, ...data]
}
const getIdsOfUsersProjects = async (user: User) => {
const usersProjects = await nowDb.now_proj_people.findMany({
where: { initials: user.initials },
@@ -16,10 +144,25 @@ const getIdsOfUsersProjects = async (user: User) => {
return new Set(usersProjects.map(({ pid }) => pid))
}
export const getAllLocalities = async (showAll: boolean, user?: User) => {
const removeProjects: (item: { now_plr: unknown }) => unknown = item => {
type LocalityListType = {
lid: number
bfa_max: string | null
bfa_min: string | null
loc_name: string
max_age: number
min_age: number
country: string | null
loc_status: boolean | null
now_plr: {
pid: number
}[]
}
export const getAllLocalities = async (user?: User) => {
const showAll = user && [Role.Admin, Role.EditUnrestricted].includes(user.role)
const removeProjects: (loc: LocalityListType) => Omit<LocalityListType, 'now_plr'> = loc => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { now_plr, ...rest } = item
const { now_plr, ...rest } = loc
return rest
}
+3 -5
View File
@@ -3,9 +3,8 @@ import Box from '@mui/material/Box'
export const FrontPage = () => {
return (
<Box>
<Box sx={{ fontSize: 20 }}>
<h1>NOW Database</h1>
<p>Site under construction.</p>
{ENV !== 'prod' && (
<p>
<b>This version is running on test data. May contain incorrect and outdated information.</b>
@@ -16,9 +15,8 @@ export const FrontPage = () => {
<a href="https://github.com/nowcommunity/nowdatabase">https://github.com/nowcommunity/nowdatabase</a>
</p>
<p>
Have any questions or suggestions for improvement? Make an issue by logging into GitHub and clicking the
&quot;New Issue&quot; -button on this page:{' '}
<a href="https://github.com/nowcommunity/nowdatabase/issue">GitHub Issues</a>
Have any questions or suggestions for improvement? Make an in GitHub by clicking &quot;New Issue&quot; (requires
a GitHub user): <a href="https://github.com/nowcommunity/nowdatabase/issue">GitHub Issues</a>
</p>
<p>
The old application: <a href="https://nowdatabase.luomus.fi/">https://nowdatabase.luomus.fi/</a>
@@ -1,11 +1,13 @@
import { useMemo } from 'react'
import { type MRT_ColumnDef } from 'material-react-table'
import { useGetAllLocalitiesQuery } from '../../redux/localityReducer'
import { useGetAllLocalitiesQuery, useGetLocalitySpeciesListMutation } from '../../redux/localityReducer'
import { Locality } from '@/backendTypes'
import { TableView } from '../TableView/TableView'
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
const localitiesQuery = useGetAllLocalitiesQuery()
const [getLocalitySpeciesList] = useGetLocalitySpeciesListMutation()
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
() => [
{
@@ -33,6 +35,18 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
],
[]
)
const combinedExport = async (lids: number[]) => {
const result = await getLocalitySpeciesList(lids).unwrap()
const dataString = result.map(row => (row as unknown[]).join(',')).join('\n')
const blob = new Blob([dataString], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'localitiesWithSpecies.csv'
a.click()
}
const checkRowRestriction = (row: Locality) => {
return !!row.loc_status
}
@@ -46,6 +60,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
columns={columns}
data={localitiesQuery.data}
url="locality"
combinedExport={combinedExport}
/>
)
}
@@ -1,46 +1,13 @@
import { LocalityDetailsType } from '@/backendTypes'
import { ArrayFrame } from '../../DetailView/common/tabLayoutHelpers'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { calculateMeanHypsodonty } from '@/types'
export const EcometricsTab = () => {
const { textField, data } = useDetailContext<LocalityDetailsType>()
// Mean hypsodonty calculation has been made based on html/include/database.php line 2567 onwards
const relevantOrderNames = [
'Perissodactyla',
'Artiodactyla',
'Primates',
'Proboscidea',
'Hyracoidea',
'Dinocerata',
'Embrithopoda',
'Notoungulata',
'Astrapotheria',
'Pyrotheria',
'Litopterna',
'Condylarthra',
'Pantodonta',
]
const thtToValue = {
bra: 1.0,
mes: 2.0,
hyp: 3.0,
hys: 3.0,
none: 0.0,
} as Record<string, number>
const species = data.now_ls
.map(ls => ls.com_species)
.filter(species => relevantOrderNames.includes(species.order_name))
.map(species => thtToValue[species.tht ?? 'none'])
const sum = species.reduce((sum, cur) => sum + cur, 0.0)
const meanHypsodonty = species.length > 0 ? sum / (species.length * 1.0) : 0.0
const roundedMean = parseFloat((Math.floor(meanHypsodonty * 100) / 100).toFixed(2))
const ecometrics = [
['Mean hypsodonty', roundedMean],
['Mean hypsodonty', calculateMeanHypsodonty(data)],
['Estimate of annual precipitation (mm)', textField('estimate_precip', { type: 'number' })],
['Estimate of mean annual temperature (°C)', textField('estimate_temp', { type: 'number' })],
['Estimate of net primary productivity (g/m2/yr)', textField('estimate_npp', { type: 'number' })],
+7 -3
View File
@@ -25,7 +25,7 @@ export const NavBar = () => {
const handleClose = () => {
setAnchorEl(null)
}
const linkFontSize = 18
const pages: LinkDefinition[] = [
{ title: 'Localities', url: '/locality' },
{ title: 'Species', url: '/species' },
@@ -73,7 +73,9 @@ export const NavBar = () => {
onClick={handleClick}
sx={{ color: 'white', paddingTop: '6px', paddingBottom: '6px', paddingLeft: '16px', paddingRight: '16px' }}
>
<Typography textAlign="center">{link.title}</Typography>
<Typography textAlign="center" fontSize={linkFontSize}>
{link.title}
</Typography>
</Box>
<Menu
id={`${link.url}-menu`}
@@ -103,7 +105,9 @@ export const NavBar = () => {
}
return (
<MenuItem key={`${link.url}-menuitem`} component={Link} to={link.url} style={{ display: 'inline-block' }}>
<Typography textAlign="center">{link.title}</Typography>
<Typography textAlign="center" fontSize={linkFontSize}>
{link.title}
</Typography>
</MenuItem>
)
}
@@ -34,6 +34,7 @@ export const TableView = <T extends MRT_RowData>({
selectorFn,
url,
title,
combinedExport,
}: {
title?: string
data: T[] | undefined
@@ -42,6 +43,7 @@ export const TableView = <T extends MRT_RowData>({
checkRowRestriction?: (row: T) => boolean
selectorFn?: (id: T) => void
url?: string
combinedExport?: (lids: number[]) => Promise<void>
}) => {
const location = useLocation()
const { editRights } = usePageContext()
@@ -147,6 +149,22 @@ export const TableView = <T extends MRT_RowData>({
<Divider sx={{ marginTop: '1rem' }} />
</>
)}
{combinedExport && (
<Box sx={{ margin: '1em', maxWidth: '20em' }}>
<p>
Click here to export a list of the localities shown in the table combined with each species found in those
localities as a comma separated csv-file. Notice, that the export may take a while. It is recommended to
filter the table first. You can also export only the table data by clicking the download icon in the
right-top corner of the table.
</p>
<Button
variant="contained"
onClick={() => void combinedExport(table.getFilteredRowModel().rows.map(d => d.original.lid as number))}
>
Locality & Species export
</Button>
</Box>
)}
<MaterialReactTable table={table} />
</Paper>
)
+13 -1
View File
@@ -15,6 +15,13 @@ const localitiesApi = api.injectEndpoints({
}),
providesTags: result => (result ? [{ type: 'locality', id: result.lid }] : []),
}),
getLocalitySpeciesList: builder.mutation<unknown[], number[]>({
query: (lids: number[]) => ({
url: `/locality/localitySpecies/`,
body: { lids },
method: 'POST',
}),
}),
editLocality: builder.mutation<{ id: number }, EditDataType<LocalityDetailsType>>({
query: locality => ({
url: `/locality/`,
@@ -26,4 +33,9 @@ const localitiesApi = api.injectEndpoints({
}),
})
export const { useGetAllLocalitiesQuery, useGetLocalityDetailsQuery, useEditLocalityMutation } = localitiesApi
export const {
useGetAllLocalitiesQuery,
useGetLocalityDetailsQuery,
useEditLocalityMutation,
useGetLocalitySpeciesListMutation,
} = localitiesApi
+37
View File
@@ -7,3 +7,40 @@ export enum Role {
ProjectPrivate = 6,
ReadOnly = 7,
}
import { LocalityDetailsType } from '@/backendTypes'
export const calculateMeanHypsodonty = (locality: LocalityDetailsType) => {
// Mean hypsodonty calculation has been made based on html/include/database.php line 2567 onwards
const relevantOrderNames = [
'Perissodactyla',
'Artiodactyla',
'Primates',
'Proboscidea',
'Hyracoidea',
'Dinocerata',
'Embrithopoda',
'Notoungulata',
'Astrapotheria',
'Pyrotheria',
'Litopterna',
'Condylarthra',
'Pantodonta',
]
const thtToValue = {
bra: 1.0,
mes: 2.0,
hyp: 3.0,
hys: 3.0,
none: 0.0,
} as Record<string, number>
const species = locality.now_ls
.map(ls => ls.com_species)
.filter(species => relevantOrderNames.includes(species.order_name))
.map(species => thtToValue[species.tht ?? 'none'])
const sum = species.reduce((sum, cur) => sum + cur, 0.0)
const meanHypsodonty = species.length > 0 ? sum / (species.length * 1.0) : 0.0
return parseFloat((Math.floor(meanHypsodonty * 100) / 100).toFixed(2))
}
View File