Skip to content

Commit 9854c31

Browse files
authored
Merge pull request #1118 from digital-land/1116-lpa-dashboard-generalisation-2---show-all-datasets-in-development
Datasets used are now loaded dynamically based on a query to provision table
2 parents f8a0110 + 43e8c7a commit 9854c31

11 files changed

Lines changed: 622 additions & 127 deletions

config/default.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ datasetsConfig:
9898
entityDisplayName:
9999
base: listed building
100100
variable: outline
101+
# Any dataset that has one of these three previous rules set, will be displayed in the LPA Dashboard to be checked.
102+
provisionReasons:
103+
- statutory
104+
- prospective
105+
- expected
101106
tablePageLength: 50
102107
jira: {
103108
requestTypeId: 1

src/routes/schemas.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const IssueSpecification = v.optional(v.strictObject({
116116
}))
117117

118118
const OrgField = v.strictObject({ name: NonEmptyString, organisation: NonEmptyString, statistical_geography: v.optional(v.string()), entity: v.optional(v.integer()) })
119-
const DatasetNameField = v.strictObject({ name: NonEmptyString, dataset: NonEmptyString, collection: NonEmptyString })
119+
const DatasetNameField = v.strictObject({ name: NonEmptyString, dataset: NonEmptyString, collection: v.string() })
120120
const DatasetItem = v.strictObject({
121121
endpointCount: v.optional(v.number()),
122122
status: v.enum(datasetStatusEnum),

src/utils/datasetLoader.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function getRedisClient () {
99
if (!config.redis) return null
1010

1111
if (!redisClient) {
12-
const urlPrefix = 'redis'
12+
const urlPrefix = `redis${config.redis.secure ? 's' : ''}`
1313
redisClient = createClient({
1414
url: `${urlPrefix}://${config.redis.host}:${config.redis.port}`
1515
})
@@ -35,6 +35,7 @@ export async function getRedisClient () {
3535

3636
const CACHE_TTL = 300 // 5min
3737

38+
// TODO: future removal of this function in favour of using datasetNameSlug and datasetSubjectLoaded instead.
3839
export async function fetchDatasetNames (datasetKeys) {
3940
if (!datasetKeys?.length) return {}
4041
const params = new URLSearchParams()

src/utils/datasetSubjectLoader.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import fetchDatasetsRequiredForLocalAuthority from './datasetteQueries/fetchDatasetsFromProvisions.js'
2+
import { datasetSlugToReadableName } from './datasetSlugToReadableName.js'
3+
import logger from './logger.js'
4+
import config from '../../config/index.js'
5+
import { getDatasetCollectionSlugNameMapping } from './datasetteQueries/fetchDatasetCollections.js'
6+
import { getRedisClient } from './datasetLoader.js'
7+
8+
// Use a short TTL in development
9+
const CACHE_TTL = (['development', 'local'].includes(config.environment)) ? 60 : 60 * 60 // 1 minute or 1 hour depending on environment
10+
11+
const fallbackDataSubjects = {
12+
'article-4-direction': {
13+
available: true,
14+
dataSets: [
15+
{
16+
value: 'article-4-direction',
17+
text: 'Article 4 direction',
18+
available: true
19+
},
20+
{
21+
value: 'article-4-direction-area',
22+
text: 'Article 4 direction area',
23+
available: true
24+
}
25+
]
26+
},
27+
'brownfield-land': {
28+
available: true,
29+
dataSets: [
30+
{
31+
value: 'brownfield-land',
32+
text: 'Brownfield land',
33+
available: true
34+
},
35+
{
36+
value: 'brownfield-site',
37+
text: 'Brownfield site',
38+
available: false
39+
}
40+
]
41+
},
42+
'conservation-area': {
43+
available: true,
44+
dataSets: [
45+
{
46+
value: 'conservation-area',
47+
text: 'Conservation area',
48+
available: true
49+
},
50+
{
51+
value: 'conservation-area-document',
52+
text: 'Conservation area document',
53+
available: true
54+
}
55+
]
56+
},
57+
'listed-building': {
58+
available: true,
59+
dataSets: [
60+
{
61+
value: 'listed-building',
62+
text: 'Listed building',
63+
available: false
64+
},
65+
{
66+
value: 'listed-building-grade',
67+
text: 'Listed building grade',
68+
available: false
69+
},
70+
{
71+
value: 'listed-building-outline',
72+
text: 'Listed building outline',
73+
available: true
74+
}
75+
]
76+
},
77+
'tree-preservation-order': {
78+
available: true,
79+
dataSets: [
80+
{
81+
value: 'tree',
82+
text: 'Tree',
83+
available: true,
84+
requiresGeometryTypeSelection: true
85+
},
86+
{
87+
value: 'tree-preservation-order',
88+
text: 'Tree preservation order',
89+
available: true
90+
},
91+
{
92+
value: 'tree-preservation-zone',
93+
text: 'Tree preservation zone',
94+
available: true
95+
}
96+
]
97+
}
98+
}
99+
100+
/* Assign datasets to their collections if a mapping exists; otherwise, group them under 'other', also a check here is done to see if dataset table exists.
101+
* Special handling: e.g., the 'tree' dataset requires geometry type selection
102+
* The fallbackDataSubjects demos what is being built.
103+
*/
104+
export async function makeDatasetSubjectMap (nameMap) {
105+
const dataSubjectMapping = await getDatasetCollectionSlugNameMapping(nameMap)
106+
107+
if (!dataSubjectMapping) {
108+
logger.warn('Failed to fetch dataset collection mapping, using fallback')
109+
return fallbackDataSubjects
110+
}
111+
112+
const subjects = {}
113+
for (const key of Object.keys(nameMap)) {
114+
const dataset = {
115+
value: key,
116+
text: nameMap[key],
117+
available: true
118+
}
119+
120+
// Add special handling for any: currenlty only for tree dataset
121+
if (key === 'tree') {
122+
dataset.requiresGeometryTypeSelection = true
123+
}
124+
125+
// Only add if there is a collection mapping, if mapping is to empty collection '', make key ='other'
126+
const mapping = dataSubjectMapping.get(key)
127+
if (mapping === undefined) {
128+
continue
129+
}
130+
const subjectKey = mapping === '' ? 'other' : mapping
131+
132+
// Initialize the subject if it doesn't exist
133+
if (!subjects[subjectKey]) {
134+
subjects[subjectKey] = {
135+
available: true,
136+
dataSets: []
137+
}
138+
}
139+
140+
subjects[subjectKey].dataSets.push(dataset)
141+
}
142+
143+
return subjects
144+
}
145+
146+
// Build data subjects by fetching dataset keys from provision table instead of hard coding.
147+
export async function buildDataSubjects () {
148+
// First try to fetch dataset keys(slugs) from provisions table (i.e.the keys(datasets) that the LA is expected to provide based on provision rules)
149+
let datasetKeys = []
150+
try {
151+
datasetKeys = await fetchDatasetsRequiredForLocalAuthority()
152+
} catch (error) {
153+
logger.warn('buildDataSubjects: Error fetching dataset keys roll back to defaults')
154+
datasetKeys = Object.keys(config.datasetsConfig)
155+
}
156+
157+
// Use existing datasetSlugToReadableName to create lookup of dataset keys to readable names
158+
const nameMap = {}
159+
for (const key of datasetKeys) {
160+
nameMap[key] = datasetSlugToReadableName(key)
161+
}
162+
163+
return makeDatasetSubjectMap(nameMap)
164+
}
165+
166+
// Get data subject map, with Redis caching
167+
export async function getDataSubjectMap () {
168+
let dataSubjectMap = {}
169+
170+
const cacheKey = 'dataset:dataSubjectMap'
171+
const client = await getRedisClient()
172+
173+
if (client) {
174+
try {
175+
const cached = await client.get(cacheKey)
176+
if (cached) {
177+
dataSubjectMap = JSON.parse(cached)
178+
return dataSubjectMap
179+
}
180+
} catch (err) {
181+
logger.warn(`datasetSubjectLoader/redis get error: ${err.message}`)
182+
}
183+
}
184+
185+
// fallback → fetch fresh
186+
logger.info('getDataSubjectMap: Dataset subject map cache miss, rebuilding data subject map')
187+
dataSubjectMap = await buildDataSubjects()
188+
189+
if (client) {
190+
try {
191+
await client.setEx(cacheKey, CACHE_TTL, JSON.stringify(dataSubjectMap))
192+
} catch (err) {
193+
logger.warn(`datasetSubjectLoader/redis set error: ${err.message}`)
194+
}
195+
}
196+
return dataSubjectMap
197+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import datasette from '../../services/datasette.js'
2+
import logger from '../logger.js'
3+
import { types } from '../logging.js'
4+
5+
// Temporary utility to get dataset collection slug-name mapping from Datasette, likely/hopefully don't need collection names in the future.
6+
export const getDatasetCollectionSlugNameMapping = async (nameMap) => {
7+
try {
8+
const datasets = Object.keys(nameMap).map(dataset => `'${dataset}'`).join(',')
9+
const datasetSlugNameTable = await datasette.runQuery(
10+
`SELECT dataset, collection FROM dataset WHERE dataset IN (${datasets})`
11+
)
12+
13+
// If no dataset table exists for a dataset, remove from datasetSlugNameMapping
14+
const validDatasets = []
15+
16+
for (const row of datasetSlugNameTable.formattedData) {
17+
try {
18+
// Test if the dataset table exists by running a simple query, for example datasets such as development-plan-geography exist in dataset, but no table created yet in datasette
19+
await datasette.runQuery('SELECT 1 FROM entity LIMIT 1', row.dataset)
20+
validDatasets.push(row)
21+
} catch (error) {
22+
logger.info(`Dataset table '${row.dataset}' does not exist, removing from mapping`, {
23+
dataset: row.dataset,
24+
errorMessage: error.message,
25+
type: types.DataFetch
26+
})
27+
// Don't add to validDatasets
28+
}
29+
}
30+
31+
const datasetMapping = new Map()
32+
validDatasets.forEach(row => {
33+
datasetMapping.set(row.dataset, row.collection)
34+
})
35+
return datasetMapping
36+
} catch (error) {
37+
logger.error(`Failed to fetch dataset=>collection mapping: ${error.message}`)
38+
return null
39+
}
40+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import datasette from '../../services/datasette.js'
2+
import logger from '../logger.js'
3+
import config from '../../../config/index.js'
4+
5+
export const fetchDatasetsRequiredForLocalAuthority = async () => {
6+
const sql = 'SELECT DISTINCT dataset, provision_reason FROM provision'
7+
8+
try {
9+
const response = await datasette.runQuery(sql)
10+
11+
const filteredProvisions = response.formattedData.filter(p =>
12+
// Use config-driven provision reasons
13+
config.provisionReasons.includes(p.provision_reason)
14+
)
15+
16+
let availableDatasets
17+
// Test environment currently uses fall back
18+
if (config.environment === 'development' || config.environment === 'local') {
19+
logger.info('fetchDatasetsForLocalAuthority: Using filtered provisions for available datasets in development/local environment')
20+
availableDatasets = filteredProvisions.map(p => p.dataset)
21+
} else {
22+
logger.info('fetchDatasetsForLocalAuthority: Using hard coded datasets for available datasets')
23+
availableDatasets = Object.keys(config.datasetsConfig)
24+
}
25+
26+
return availableDatasets
27+
} catch (error) {
28+
logger.warn(`fetchDatasetsForLocalAuthority: Error fetching dataset info: ${error.message}`, error)
29+
throw error
30+
}
31+
}
32+
33+
export default fetchDatasetsRequiredForLocalAuthority

0 commit comments

Comments
 (0)