Merge branch 'master' into fix/upload

This commit is contained in:
Tom Russell 2020-04-09 10:19:13 +01:00 committed by GitHub
commit 055ed426b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 2999 additions and 811 deletions

View File

@ -1,6 +1,6 @@
language: node_js
node_js:
- 8
- 12
cache: npm
before_script:
- cd $TRAVIS_BUILD_DIR/app && npm ci

View File

@ -1,13 +0,0 @@
{
"plugins": [
"@babel/plugin-syntax-jsx",
"@babel/plugin-syntax-typescript",
[
"babel-plugin-typescript-to-proptypes",
{
"implicitChildren": true,
"typeCheck": true
}
]
]
}

View File

@ -89,6 +89,40 @@
<PolygonSymbolizer fill="#800026" />
</Rule>
</Style>
<Style name="size_height">
<Rule>
<Filter>[size_height] &lt; 5.55</Filter>
<PolygonSymbolizer fill="#f7f4f9" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 5.55 and [size_height] &lt; 7.73</Filter>
<PolygonSymbolizer fill="#e7e1ef" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 7.73 and [size_height] &lt; 11.38</Filter>
<PolygonSymbolizer fill="#d4b9da" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 11.38 and [size_height] &lt; 18.45</Filter>
<PolygonSymbolizer fill="#c994c7" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 18.45 and [size_height] &lt; 35.05</Filter>
<PolygonSymbolizer fill="#df65b0" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 35.05 and [size_height] &lt; 89.30</Filter>
<PolygonSymbolizer fill="#e7298a" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 89.30 and [size_height] &lt; 152</Filter>
<PolygonSymbolizer fill="#ce1256" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 152</Filter>
<PolygonSymbolizer fill="#980043" />
</Rule>
</Style>
<Style name="date_year">
<Rule>
<Filter>[date_year] &gt;= 2000</Filter>
@ -250,4 +284,50 @@
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
</Style>
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
<PolygonSymbolizer fill="#52403C" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Recreation And Leisure"</Filter>
<PolygonSymbolizer fill="#ffbfbf" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Transport"</Filter>
<PolygonSymbolizer fill="#bee8ff" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Utilities And Infrastructure"</Filter>
<PolygonSymbolizer fill="#cccccc" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Residential"</Filter>
<PolygonSymbolizer fill="#4a54a6" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Community Services"</Filter>
<PolygonSymbolizer fill="#73ccd1" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Retail"</Filter>
<PolygonSymbolizer fill="#ff8c00" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Industry And Business"</Filter>
<PolygonSymbolizer fill="#f5f58f" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Vacant And Derelict"</Filter>
<PolygonSymbolizer fill="#ffffff" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Defence"</Filter>
<PolygonSymbolizer fill="#898944" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Mixed Use"</Filter>
<PolygonSymbolizer fill="#e5050d" />
</Rule>
</Style>
</Map>

910
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,55 +14,51 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.21",
"@fortawesome/free-solid-svg-icons": "^5.10.1",
"@fortawesome/react-fontawesome": "^0.1.4",
"@fortawesome/react-fontawesome": "^0.1.8",
"@mapbox/sphericalmercator": "^1.1.0",
"body-parser": "^1.19.0",
"bootstrap": "^4.3.1",
"bootstrap": "^4.4.1",
"connect-pg-simple": "^6.0.1",
"express": "^4.17.1",
"express-session": "^1.16.2",
"leaflet": "^1.5.1",
"express-session": "^1.17.0",
"leaflet": "^1.6.0",
"mapnik": "^4.2.1",
"node-fs": "^0.1.7",
"nodemailer": "^6.3.0",
"pg-promise": "^8.7.5",
"prop-types": "^15.7.2",
"query-string": "^6.8.2",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0",
"react-router-dom": "^5.0.1",
"serialize-javascript": "^1.7.0",
"serialize-javascript": "^2.1.1",
"sharp": "^0.22.1"
},
"devDependencies": {
"@babel/plugin-syntax-jsx": "^7.2.0",
"@babel/plugin-syntax-typescript": "^7.3.3",
"@types/express": "^4.17.0",
"@types/express-session": "^1.15.13",
"@types/jest": "^24.0.17",
"@types/express": "^4.17.2",
"@types/express-session": "^1.15.16",
"@types/jest": "^24.0.23",
"@types/lodash": "^4.14.149",
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^8.10.52",
"@types/nodemailer": "^6.2.1",
"@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@types/react-leaflet": "^2.4.0",
"@types/react-router-dom": "^4.3.4",
"@types/sharp": "^0.22.2",
"@types/webpack-env": "^1.14.0",
"babel-eslint": "^10.0.2",
"babel-plugin-typescript-to-proptypes": "^0.17.1",
"@types/node": "^12.12.25",
"@types/nodemailer": "^6.2.2",
"@types/react": "^16.9.16",
"@types/react-dom": "^16.9.4",
"@types/react-leaflet": "^2.5.0",
"@types/react-router-dom": "^4.3.5",
"@types/sharp": "^0.22.3",
"@types/webpack-env": "^1.14.1",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-jest": "^22.15.0",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-jest": "^22.21.0",
"eslint-plugin-react": "^7.17.0",
"razzle": "^3.0.0",
"razzle-plugin-typescript": "^3.0.0",
"ts-jest": "^24.0.2",
"tslint": "^5.18.0",
"tslint-react": "^4.0.0",
"typescript": "^3.5.3"
"ts-jest": "^24.2.0",
"tslint": "^5.20.1",
"tslint-react": "^4.1.0",
"typescript": "^3.7.3"
},
"jest": {
"transform": {

155
app/public/openapi.yml Normal file
View File

@ -0,0 +1,155 @@
openapi: 3.0.0
info:
title: Colouring London API
version: 1.0.0
servers:
- url: https://colouring.london/api
description: Production server (uses live data)
paths:
/extracts:
get:
summary: Returns a list of bulk data extracts
responses:
'200':
description: A list of bulk extracts, from newest to oldest
content:
application/json:
schema:
properties:
extracts:
type: array
items:
$ref: '#/components/schemas/BulkExtract'
example:
extracts:
- extract_id: 1
extracted_on: 2019-10-03T05:33:00.000Z
download_path: /downloads/data-extract-2019-10-03-06_33_00.zip
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/ServerError'
/history:
get:
summary: Returns a paginated list of edits (latest edits if no relevant parameters specified)
parameters:
- name: before_id
description: Returned edits will be ones made directly before the specified revision ID
in: query
schema:
$ref: '#/components/schemas/RevisionId'
required: false
- name: after_id
description: Returned edits will be ones made directly after the specified revision ID
in: query
schema:
$ref: '#/components/schemas/RevisionId'
required: false
- name: count
description: The desired number of records to return
in: query
schema:
type: number
minimum: 1
maximum: 100
default: 100
required: false
responses:
'200':
description: A list of edit history records
content:
application/json:
schema:
properties:
history:
type: array
items:
$ref: '#/components/schemas/BuildingEditHistoryEntry'
paging:
type: object
properties:
id_for_older_query:
allOf:
- $ref: '#/components/schemas/RevisionId'
- description: If older records exist - ID to use for querying them (use as before_id param), otherwise null
nullable: true
id_for_newer_query:
allOf:
- $ref: '#/components/schemas/RevisionId'
- description: If newer records exist - ID to use for querying them (use as after_id param), otherwise null
nullable: true
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/ServerError'
components:
responses:
BadRequest:
description: Invalid request submitted by user
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ServerError:
description: Unexpected server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Database error
schemas:
Error:
type: object
properties:
error:
type: string
description: Error message
BulkExtract:
type: object
properties:
extract_id:
type: integer
description: Unique sequential ID for the extract
extracted_on:
type: string
format: date-time
description: UTC timestamp at which the extract was generated
download_path:
type: string
description: Download path for the extract. Contains only URL path (should be used with the same hostname as the API).
RevisionId:
description: Unique sequential ID for an edit history entry (positive big integer)
type: string
pattern: ^[1-9]\d*&
BuildingEditHistoryEntry:
type: object
properties:
revision_id:
$ref: '#/components/schemas/RevisionId'
forward_patch:
type: object
description: Forward diff of the building attribute data
reverse_patch:
type: object
description: Reverse diff of the building attribute data
revision_timestamp:
type: string
format: date-time
description: UTC timestamp at which the building data was edited
username:
type: string
description: Username of the editor
building_id:
type: number
description: Unique ID of the edited building

View File

@ -9,17 +9,6 @@ module.exports = {
})
config.module.rules = rules;
// find module rule that runs ts-loader for TS(X) files
const tsRule = config.module.rules.find(r =>
new RegExp(r.test).test('test.tsx') && Array.isArray(r.use) && r.use.some(u => u.loader.includes('ts-loader')));
// run babel-loader before ts-loader to generate propTypes
tsRule.use.push({
loader: 'babel-loader',
options: {
babelrc: true
}
})
return config;
},
};

View File

@ -2,6 +2,8 @@ import bodyParser from 'body-parser';
import express from 'express';
import * as editHistoryController from './controllers/editHistoryController';
import { ApiParamError, ApiUserError } from './errors/api';
import { DatabaseError } from './errors/general';
import buildingsRouter from './routes/buildingsRouter';
import extractsRouter from './routes/extractsRouter';
import usersRouter from './routes/usersRouter';
@ -93,14 +95,30 @@ server.get('/search', function (req, res) {
});
});
server.use((err, req, res, next) => {
server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.headersSent) {
return next(err);
}
if (err != undefined) {
console.log('Global error handler: ', err);
res.status(500).send({ error: 'Server error' });
if (err instanceof ApiUserError) {
let errorMessage: string;
if(err instanceof ApiParamError) {
errorMessage = `Problem with parameter ${err.paramName}: ${err.message}`;
} else {
errorMessage = err.message;
}
res.status(400).send({ error: errorMessage });
} else if(err instanceof DatabaseError){
res.status(500).send({ error: 'Database error' });
} else {
res.status(500).send({ error: 'Server error' });
}
}
});

View File

@ -1,5 +1,6 @@
import express from 'express';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building';
import * as userService from '../services/user';
@ -34,9 +35,10 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
// GET individual building, POST building updates
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingById(building_id);
const result = await buildingService.getBuildingById(buildingId);
res.send(result);
} catch(error) {
console.error(error);
@ -61,11 +63,12 @@ const updateBuildingById = asyncController(async (req: express.Request, res: exp
});
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const buildingUpdate = req.body;
try {
const building = await buildingService.saveBuilding(building_id, buildingUpdate, userId);
const building = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
if (typeof (building) === 'undefined') {
return res.send({ error: 'Database error' });
@ -81,9 +84,10 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
// GET building UPRNs
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingUPRNsById(building_id);
const result = await buildingService.getBuildingUPRNsById(buildingId);
if (typeof (result) === 'undefined') {
return res.send({ error: 'Database error' });
@ -100,9 +104,11 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
if (!req.session.user_id) {
return res.send({ like: false }); // not logged in, so cannot have liked
}
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const like = await buildingService.getBuildingLikeById(building_id, req.session.user_id);
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
// any value returned means like
res.send({ like: like });
@ -112,9 +118,10 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
});
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const editHistory = await buildingService.getBuildingEditHistory(building_id);
const editHistory = await buildingService.getBuildingEditHistory(buildingId);
res.send({ history: editHistory });
} catch(error) {
@ -127,13 +134,13 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
return res.send({ error: 'Must be logged in' });
}
const { building_id } = req.params;
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body;
try {
const building = like ?
await buildingService.likeBuilding(building_id, req.session.user_id) :
await buildingService.unlikeBuilding(building_id, req.session.user_id);
await buildingService.likeBuilding(buildingId, req.session.user_id) :
await buildingService.unlikeBuilding(buildingId, req.session.user_id);
if (building.error) {
return res.send(building);

View File

@ -1,17 +1,32 @@
import express from 'express';
import { ApiParamError, ApiUserError } from '../errors/api';
import { ArgumentError } from '../errors/general';
import { checkRegexParam, parsePositiveIntParam, processParam } from '../parameters';
import asyncController from "../routes/asyncController";
import * as editHistoryService from '../services/editHistory';
const getGlobalEditHistory = asyncController(async (req: express.Request, res: express.Response) => {
const revisionIdRegex = /^[1-9]\d*$/;
const afterId: string = processParam(req.query, 'after_id', x => checkRegexParam(x, revisionIdRegex));
const beforeId: string = processParam(req.query, 'before_id', x => checkRegexParam(x, revisionIdRegex));
const count: number = processParam(req.query, 'count', parsePositiveIntParam);
if(afterId != undefined && beforeId != undefined) {
throw new ApiUserError('Cannot specify both after_id and before_id parameters');
}
try {
const result = await editHistoryService.getGlobalEditHistory();
res.send({
history: result
});
const result = await editHistoryService.getGlobalEditHistory(beforeId, afterId, count);
res.send(result);
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
if(error instanceof ArgumentError && error.argumentName === 'count') {
const apiErr = new ApiParamError(error.message, 'count');
throw apiErr;
}
throw error;
}
});

View File

@ -1,5 +1,6 @@
import express from 'express';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as dataExtractService from '../services/dataExtract';
@ -14,8 +15,9 @@ const getAllDataExtracts = asyncController(async function(req: express.Request,
});
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
const extractId = processParam(req.params, 'extract_id', parsePositiveIntParam, true);
try {
const extractId = req.params.extract_id;
const extract = await dataExtractService.getDataExtractById(extractId);
res.send({ extract: extract });
} catch (err) {

View File

@ -0,0 +1,62 @@
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
import { numAsc, numDesc } from '../../../helpers';
/**
* Create an object mocking all method of editHistory dataAccess
* The type is set to reflect the type of that module, with added methods
* used when testing
*/
const mockEditHistory =
jest.genMockFromModule('../editHistory') as typeof import('../editHistory') & {
__setHistory: (mockHistoryData: EditHistoryEntry[]) => void
};
let mockData: EditHistoryEntry[] = [];
mockEditHistory.__setHistory = function(mockHistoryData: EditHistoryEntry[]) {
mockData = mockHistoryData.sort(numDesc(x => BigInt(x.revision_id)));
};
mockEditHistory.getHistoryAfterId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
return Promise.resolve(
mockData
.filter(x => BigInt(x.revision_id) > BigInt(id))
.sort(numAsc(x => BigInt(x.revision_id)))
.slice(0, count)
.sort(numDesc(x => BigInt(x.revision_id)))
);
};
mockEditHistory.getHistoryBeforeId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
let filteredData = id == undefined ? mockData : mockData.filter(x => BigInt(x.revision_id) < BigInt(id));
return Promise.resolve(
filteredData
.slice(0, count)
);
};
mockEditHistory.getIdNewerThan = async function(id: string): Promise<string> {
const historyAfterId = await mockEditHistory.getHistoryAfterId(id, 1);
return historyAfterId[historyAfterId.length - 1]?.revision_id;
};
mockEditHistory.getIdOlderThan = async function(id: string): Promise<string> {
const historyBeforeId = await mockEditHistory.getHistoryBeforeId(id, 1);
return historyBeforeId[0]?.revision_id;
};
const {
__setHistory,
getHistoryAfterId,
getHistoryBeforeId,
getIdNewerThan,
getIdOlderThan
} = mockEditHistory;
export {
__setHistory,
getHistoryAfterId,
getHistoryBeforeId,
getIdNewerThan,
getIdOlderThan
};

View File

@ -0,0 +1,88 @@
import db from '../../db';
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
import { DatabaseError } from '../errors/general';
const baseQuery = `
SELECT
log_id as revision_id,
forward_patch,
reverse_patch,
date_trunc('minute', log_timestamp) as revision_timestamp,
username,
building_id
FROM logs
JOIN users ON logs.user_id = users.user_id`;
export function getHistoryAfterId(id: string, count: number): Promise<EditHistoryEntry[]> {
/**
* SQL with lower time bound specified (records after ID).
* The outer SELECT is so that final results are sorted by descending ID
* (like the other queries). The inner select is sorted in ascending order
* so that the right rows are returned when limiting the result set.
*/
try {
return db.any(`
SELECT * FROM (
${baseQuery}
WHERE log_id > $1
ORDER BY revision_id ASC
LIMIT $2
) AS result_asc ORDER BY revision_id DESC`,
[id, count]
);
} catch(err) {
throw new DatabaseError(err);
}
}
export function getHistoryBeforeId(id: string, count: number): Promise<EditHistoryEntry[]> {
try {
if(id == undefined) {
return db.any(`
${baseQuery}
ORDER BY revision_id DESC
LIMIT $1
`, [count]);
} else {
return db.any(`
${baseQuery}
WHERE log_id < $1
ORDER BY revision_id DESC
LIMIT $2
`, [id, count]);
}
} catch(err) {
throw new DatabaseError(err);
}
}
export async function getIdOlderThan(id: string): Promise<string> {
try {
const result = await db.oneOrNone<{revision_id:string}>(`
SELECT MAX(log_id) as revision_id
FROM logs
WHERE log_id < $1
`, [id]);
return result?.revision_id;
} catch(err) {
throw new DatabaseError(err);
}
}
export async function getIdNewerThan(id: string): Promise<string> {
try {
const result = await db.oneOrNone<{revision_id:string}>(`
SELECT MIN(log_id) as revision_id
FROM logs
WHERE log_id > $1
`, [id]);
return result?.revision_id;
} catch(err) {
throw new DatabaseError(err);
}
}

44
app/src/api/errors/api.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* Note that custom errors and the instanceof operator in TS work together
* only when transpiling to ES2015 and up.
* For earier target versions (ES5), a workaround is required:
* https://stackoverflow.com/questions/41102060/typescript-extending-error-class
*/
export class ApiUserError extends Error {
constructor(message?: string) {
super(message);
this.name = 'ApiUserError';
}
}
export class ApiParamError extends ApiUserError {
public paramName: string;
constructor(message?: string, paramName?: string) {
super(message);
this.name = 'ApiParamError';
this.paramName = paramName;
}
}
export class ApiParamRequiredError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamRequiredError';
}
}
export class ApiParamOutOfBoundsError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamOutOfBoundsError';
}
}
export class ApiParamInvalidFormatError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamInvalidFormatError';
}
}

View File

@ -0,0 +1,17 @@
export class ArgumentError extends Error {
public argumentName: string;
constructor(message?: string, argumentName?: string) {
super(message);
this.name = 'ArgumentError';
this.argumentName = argumentName;
}
}
export class DatabaseError extends Error {
public detail: any;
constructor(detail?: string) {
super();
this.name = 'DatabaseError';
this.detail = detail;
}
}

44
app/src/api/parameters.ts Normal file
View File

@ -0,0 +1,44 @@
import { strictParseInt } from '../parse';
import { ApiParamError, ApiParamInvalidFormatError, ApiParamRequiredError } from './errors/api';
export function processParam<T>(params: object, paramName: string, processingFn: (x: string) => T, required: boolean = false) {
const stringValue = params[paramName];
if(stringValue == undefined && required) {
const err = new ApiParamRequiredError('Parameter required but not supplied');
err.paramName = paramName;
throw err;
}
try {
return processingFn(stringValue);
} catch(error) {
if(error instanceof ApiParamError) {
error.paramName = paramName;
}
throw error;
}
}
export function parsePositiveIntParam(param: string) {
if(param == undefined) return undefined;
const result = strictParseInt(param);
if (isNaN(result)) {
throw new ApiParamInvalidFormatError('Invalid format: not a positive integer');
}
return result;
}
export function checkRegexParam(param: string, regex: RegExp): string {
if(param == undefined) return undefined;
if(param.match(regex) == undefined) {
throw new ApiParamInvalidFormatError(`Invalid format: does not match regular expression ${regex}`);
}
return param;
}

View File

@ -0,0 +1,176 @@
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
import * as editHistoryData from '../../dataAccess/editHistory'; // manually mocked
import { ArgumentError } from '../../errors/general';
import { getGlobalEditHistory } from '../editHistory';
jest.mock('../../dataAccess/editHistory');
const mockedEditHistoryData = editHistoryData as typeof import('../../dataAccess/__mocks__/editHistory');
function generateHistory(n: number, firstId: number = 100) {
return [...Array(n).keys()].map<EditHistoryEntry>(i => ({
revision_id: (firstId + i) + '',
revision_timestamp: new Date(2019, 10, 1, 17, 20 + i).toISOString(),
username: 'testuser',
building_id: 1234567,
forward_patch: {},
reverse_patch: {}
}));
}
describe('getGlobalEditHistory()', () => {
beforeEach(() => mockedEditHistoryData.__setHistory(generateHistory(20)));
afterEach(() => jest.clearAllMocks());
it.each([
[null, null],
['100', null],
[null, '100']
])('Should error when requesting non-positive number of records', async (beforeId: string, afterId: string) => {
let resultPromise = getGlobalEditHistory(beforeId, afterId, 0);
await expect(resultPromise).rejects.toBeInstanceOf(ArgumentError);
await expect(resultPromise).rejects.toHaveProperty('argumentName', 'count');
});
describe('getting history before a point', () => {
it('should return latest history if no ID specified', async () => {
const result = await getGlobalEditHistory(null, null, 5);
expect(result.history.map(x => x.revision_id)).toEqual(['119', '118', '117', '116', '115']);
});
it.each(
[
[null, 3, ['119', '118', '117']],
[null, 6, ['119', '118', '117', '116', '115', '114']],
['118', 1, ['117']],
['104', 10, ['103', '102','101', '100']],
['100', 2, []]
]
)('should return the N records before the specified ID in descending order [beforeId: %p, count: %p]', async (
beforeId: string, count: number, ids: string[]
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.history.map(h => h.revision_id)).toEqual(ids);
});
it.each([
[null, 4, null],
[null, 10, null],
[null, 20, null],
[null, 30, null],
['50', 10, '99'],
['100', 10, '99'],
['130', 10, null],
['105', 2, '104'],
['120', 20, null],
])('should detect if there are any newer records left [beforeId: %p, count: %p]', async (
beforeId: string, count: number, idForNewerQuery: string
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
});
it.each([
[null, 4, '116'],
[null, 10, '110'],
[null, 20, null],
[null, 30, null],
['50', 10, null],
['100', 10, null],
['130', 10, '110'],
['105', 2, '103'],
['120', 20, null],
])('should detect if there are any older records left [beforeId: %p, count: %p]', async (
beforeId: string, count: number, idForOlderQuery: string
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
});
});
describe('getting history after a point', () => {
it.each([
['100', 7, ['107', '106', '105', '104', '103', '102', '101']],
['115', 3, ['118', '117', '116']],
['120', 10, []]
])('should return N records after requested ID in descending order [afterId: %p, count: %p]', async (
afterId: string, count: number, expected: string[]
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.history.map(x => x.revision_id)).toEqual(expected);
});
it.each([
['99', 10, '109'],
['110', 5, '115'],
['119', 20, null],
['99', 20, null],
])('should detect if there are any newer records left [afterId: %p, count: %p]', async (
afterId: string, count: number, idForNewerQuery: string
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
});
it.each([
['99', 10, null],
['110', 5, '111'],
['119', 20, '120'],
['99', 20, null],
])('should detect if there are any older records left [afterId: %p, count: %p]', async (
afterId: string, count: number, idForOlderQuery: string
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
});
});
describe('result count limit', () => {
it.each([
[null, null],
[null, '100'],
['300', null]
])('should not return more than 100 entries (beforeId: %p, afterId: %p)', async (
beforeId: string, afterId: string
) => {
mockedEditHistoryData.__setHistory(
generateHistory(200)
);
const result = await getGlobalEditHistory(beforeId, afterId, 200);
expect(result.history.length).toBe(100);
});
it.each([
[null, null],
[null, '100'],
['300', null]
])('should default to 100 entries', async (
beforeId: string, afterId: string
) => {
mockedEditHistoryData.__setHistory(
generateHistory(200)
);
const result = await getGlobalEditHistory(beforeId, afterId);
expect(result.history.length).toBe(100);
});
});
});

View File

@ -2,12 +2,15 @@
* Building data access
*
*/
import * as _ from 'lodash';
import { ITask } from 'pg-promise';
import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
// JavaScript numerics are 64-bit double, giving only partial coverage.
@ -92,12 +95,16 @@ async function queryBuildingsByReference(key: string, ref: string) {
}
}
async function getCurrentBuildingDataById(id: number) {
return db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
);
}
async function getBuildingById(id: number) {
try {
const building = await db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
);
const building = await getCurrentBuildingDataById(id);
building.edit_history = await getBuildingEditHistory(id);
@ -111,7 +118,7 @@ async function getBuildingById(id: number) {
async function getBuildingEditHistory(id: number) {
try {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp) as revision_timestamp, username
FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id
ORDER BY log_timestamp DESC`,
@ -151,13 +158,15 @@ async function getBuildingUPRNsById(id: number) {
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
try {
return await updateBuildingData(buildingId, userId, async () => {
const processedBuilding = await processBuildingUpdate(buildingId, building);
// remove read-only fields from consideration
delete building.building_id;
delete building.revision_id;
delete building.geometry_id;
delete processedBuilding.building_id;
delete processedBuilding.revision_id;
delete processedBuilding.geometry_id;
// return whitelisted fields to update
return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST);
});
} catch(error) {
console.error(error);
@ -395,6 +404,10 @@ const BUILDING_FIELD_WHITELIST = new Set([
// 'sust_life_expectancy',
'building_attachment_form',
'date_change_building_use',
'current_landuse_class',
'current_landuse_group',
'current_landuse_order'
]);
/**
@ -411,7 +424,7 @@ function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (oldObj[key] != value) {
if (!_.isEqual(oldObj[key], value)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
@ -422,6 +435,7 @@ function compare(oldObj: object, newObj: object): [object, object] {
export {
queryBuildingsAtPoint,
queryBuildingsByReference,
getCurrentBuildingDataById,
getBuildingById,
getBuildingLikeById,
getBuildingEditHistory,

View File

@ -19,7 +19,8 @@ async function listDataExtracts(): Promise<DataExtract[]> {
const extractRecords = await db.manyOrNone<DataExtractRow>(
`SELECT
extract_id, extracted_on, extract_path
FROM bulk_extracts`
FROM bulk_extracts
ORDER BY extracted_on DESC`
);
return extractRecords.map(getDataExtractFromRow);

View File

@ -0,0 +1,102 @@
import * as _ from 'lodash';
import db from '../../../db';
import { isNullishOrEmpty } from '../../../helpers';
import { getCurrentBuildingDataById } from '../building';
export async function processCurrentLandUseClassifications(buildingId: number, building: any): Promise<any> {
let updateData = _.pick(await getCurrentBuildingDataById(buildingId), [
'current_landuse_class',
'current_landuse_group',
'current_landuse_order'
]);
updateData = Object.assign({}, updateData, getClearValues(building));
const updateFrom = getUpdateStartingStage(building);
if(updateFrom === 'class') {
updateData.current_landuse_class = building.current_landuse_class;
updateData.current_landuse_group = await deriveGroupFromClass(updateData.current_landuse_class);
updateData.current_landuse_order = await deriveOrderFromGroup(updateData.current_landuse_group);
} else if (updateFrom === 'group') {
if (isNullishOrEmpty(updateData.current_landuse_class)) {
updateData.current_landuse_group = building.current_landuse_group;
updateData.current_landuse_order = await deriveOrderFromGroup(building.current_landuse_group);
} else {
throw new Error('Trying to update current_landuse_group field but a more detailed field (current_landuse_class) is already filled');
}
} else if (updateFrom === 'order') {
if (isNullishOrEmpty(updateData.current_landuse_class) && isNullishOrEmpty(updateData.current_landuse_group)) {
updateData.current_landuse_order = building.current_landuse_order;
} else {
throw new Error('Trying to update current_landuse_order field but a more detailed field (current_landuse_class or current_landuse_group) is already filled');
}
}
return Object.assign({}, building, updateData);
}
function getClearValues(building) {
const clearValues: any = {};
if(building.hasOwnProperty('current_landuse_class') && isNullishOrEmpty(building.current_landuse_class)) {
clearValues.current_landuse_class = [];
}
if(building.hasOwnProperty('current_landuse_group') && isNullishOrEmpty(building.current_landuse_group)) {
clearValues.current_landuse_group = [];
}
if(building.hasOwnProperty('current_landuse_order') && isNullishOrEmpty(building.current_landuse_order)) {
clearValues.current_landuse_order = null;
}
return clearValues;
}
/**
* Choose which level of the land use classification hierarchy the update should start from.
* @param building
*/
function getUpdateStartingStage(building) {
if(building.hasOwnProperty('current_landuse_class') && !isNullishOrEmpty(building.current_landuse_class)) {
return 'class';
} else if(building.hasOwnProperty('current_landuse_group') && !isNullishOrEmpty(building.current_landuse_group)) {
return 'group';
} else if(building.hasOwnProperty('current_landuse_order') && !isNullishOrEmpty(building.current_landuse_order)) {
return 'order';
} else return 'none';
}
async function deriveGroupFromClass(classes: string[]): Promise<string[]> {
if (classes.length === 0) return [];
return (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_group AS parent
JOIN reference_tables.buildings_landuse_class AS child
ON child.parent_group_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description`,
[classes]
)).map(x => x.description);
}
async function deriveOrderFromGroup(groups: string[]): Promise<string> {
if(groups.length === 0) return null;
const orders = (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_order AS parent
JOIN reference_tables.buildings_landuse_group AS child
ON child.parent_order_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description
`,
[groups]
)).map(x => x.description);
if(orders.length === 1) {
return orders[0];
} else if (orders.length > 1) {
return 'Mixed Use';
} else return null;
}

View File

@ -0,0 +1,11 @@
import { hasAnyOwnProperty } from '../../../helpers';
import { processCurrentLandUseClassifications } from './currentLandUseClassifications';
export async function processBuildingUpdate(buildingId: number, building: any): Promise<any> {
if(hasAnyOwnProperty(building, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) {
building = await processCurrentLandUseClassifications(buildingId, building);
}
return building;
}

View File

@ -1,20 +1,38 @@
import db from '../../db';
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
import { decBigInt, incBigInt } from '../../helpers';
import { getHistoryAfterId, getHistoryBeforeId, getIdNewerThan, getIdOlderThan } from '../dataAccess/editHistory';
import { ArgumentError } from '../errors/general';
async function getGlobalEditHistory() {
try {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username, building_id
FROM logs, users
WHERE logs.user_id = users.user_id
AND log_timestamp >= now() - interval '7 days'
ORDER BY log_timestamp DESC`
);
} catch (error) {
console.error(error);
return [];
async function getGlobalEditHistory(beforeId?: string, afterId?: string, count: number = 100) {
if(count <= 0) throw new ArgumentError('cannot request less than 1 history record', 'count');
if(count > 100) count = 100;
// limited set of records. Expected to be already ordered from newest to oldest
let editHistoryRecords: EditHistoryEntry[];
if(afterId != undefined) {
editHistoryRecords = await getHistoryAfterId(afterId, count);
} else {
editHistoryRecords = await getHistoryBeforeId(beforeId, count);
}
}
const currentBatchMaxId = editHistoryRecords[0]?.revision_id ?? decBigInt(beforeId);
const newer = currentBatchMaxId && await getIdNewerThan(currentBatchMaxId);
const currentBatchMinId = editHistoryRecords[editHistoryRecords.length-1]?.revision_id ?? incBigInt(afterId);
const older = currentBatchMinId && await getIdOlderThan(currentBatchMinId);
const idForOlderQuery = older != undefined ? incBigInt(older) : null;
const idForNewerQuery = newer != undefined ? decBigInt(newer) : null;
return {
history: editHistoryRecords,
paging: {
id_for_newer_query: idForNewerQuery,
id_for_older_query: idForOlderQuery
}
};
}
export {
getGlobalEditHistory

View File

@ -60,7 +60,10 @@ async function authUser(username: string, password: string) {
user_id,
(
pass = crypt($2, pass)
) AS auth_ok
) AS auth_ok,
is_blocked,
blocked_on,
blocked_reason
FROM users
WHERE
username = $1
@ -71,6 +74,9 @@ async function authUser(username: string, password: string) {
);
if (user && user.auth_ok) {
if (user.is_blocked) {
return { error: `Account temporarily blocked.${user.blocked_reason == undefined ? '' : ' Reason: '+user.blocked_reason}` };
}
return { user_id: user.user_id };
} else {
return { error: 'Username or password not recognised' };

View File

@ -0,0 +1,44 @@
type JsonReviver = (name: string, value: any) => any;
export function apiGet(path: string, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'GET', null, options);
}
export function apiPost(path: string, data?: object, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'POST', data, options);
}
export function apiDelete(path: string, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'DELETE', null, options);
}
async function apiRequest(
path: string,
method: 'GET' | 'POST' | 'DELETE',
data?: object,
options?: {
jsonReviver?: JsonReviver
}
): Promise<any> {
const res = await fetch(path, {
method: method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: data == undefined ? null : JSON.stringify(data),
});
const reviver = options == undefined ? undefined : options.jsonReviver;
if (reviver != undefined) {
return JSON.parse(await res.text(), reviver);
} else {
return await res.json();
}
}

View File

@ -43,7 +43,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
case 'use':
return <UseContainer
{...props}
inactive={true}
inactive={false}
title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
help="https://pages.colouring.london/use"

View File

@ -14,7 +14,7 @@ const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (prop
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<div className="form-check">

View File

@ -28,7 +28,7 @@ const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined || props.value == ''}
copy={props.copy}
/>
<input className="form-control" type="text"

View File

@ -57,7 +57,7 @@ class MultiDataEntry extends Component<MultiDataEntryProps> {
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined || props.value.length === 0}
/>
{
(props.mode === 'view')?

View File

@ -19,7 +19,7 @@ const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props)
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<input
@ -27,10 +27,10 @@ const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props)
type="number"
id={props.slug}
name={props.slug}
value={props.value || ''}
step={props.step || 1}
value={props.value == undefined ? '' : props.value}
step={props.step == undefined ? 1 : props.step}
max={props.max}
min={props.min || 0}
min={props.min}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={e =>

View File

@ -17,7 +17,7 @@ const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) =
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<select className="form-control"

View File

@ -16,7 +16,7 @@ const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props)
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<textarea

View File

@ -30,6 +30,8 @@ class YearDataEntry extends Component<YearDataEntryProps, any> {
// TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() {
const props = this.props;
const currentYear = new Date().getFullYear();
return (
<Fragment>
<NumericDataEntry
@ -39,6 +41,8 @@ class YearDataEntry extends Component<YearDataEntryProps, any> {
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
min={1}
max={currentYear}
// "type": "year_estimator"
/>
<NumericDataEntry
@ -49,6 +53,8 @@ class YearDataEntry extends Component<YearDataEntryProps, any> {
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.date_upper.tooltip}
/>
<NumericDataEntry
@ -59,6 +65,8 @@ class YearDataEntry extends Component<YearDataEntryProps, any> {
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.date_lower.tooltip}
/>
</Fragment>

View File

@ -1,6 +1,7 @@
import React, { Fragment } from 'react';
import { NavLink, Redirect } from 'react-router-dom';
import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers';
@ -162,15 +163,10 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
*/
async handleLike(like: boolean) {
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
});
const data = await res.json();
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}/like.json`,
{like: like}
);
if (data.error) {
this.setState({error: data.error});
@ -188,15 +184,10 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
this.setState({error: undefined});
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state.buildingEdits),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const data = await res.json();
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}.json`,
this.state.buildingEdits
);
if (data.error) {
this.setState({error: data.error});

View File

@ -13,69 +13,79 @@ import { CategoryViewProps } from './category-view-props';
/**
* Age view/edit section
*/
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<YearDataEntry
year={props.building.date_year}
upper={props.building.date_upper}
lower={props.building.date_lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.facade_year.title}
slug="facade_year"
value={props.building.facade_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip={dataFields.facade_year.tooltip}
/>
<SelectDataEntry
title={dataFields.date_source.title}
slug="date_source"
value={props.building.date_source}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_source.tooltip}
placeholder=""
options={[
"Survey of London",
"Pevsner Guides",
"Local history publication",
"National Heritage List for England",
"Historical map",
"Archive research",
"Expert knowledge of building",
"Other book",
"Other website",
"Other"
]}
/>
<TextboxDataEntry
title={dataFields.date_source_detail.title}
slug="date_source_detail"
value={props.building.date_source_detail}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_source_detail.tooltip}
/>
<MultiDataEntry
title={dataFields.date_link.title}
slug="date_link"
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
/>
</Fragment>
);
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
const currentYear = new Date().getFullYear();
return (
<Fragment>
<YearDataEntry
year={props.building.date_year}
upper={props.building.date_upper}
lower={props.building.date_lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.facade_year.title}
slug="facade_year"
value={props.building.facade_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.facade_year.tooltip}
/>
<SelectDataEntry
title={dataFields.date_source.title}
slug="date_source"
value={props.building.date_source}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_source.tooltip}
placeholder=""
options={[
"Expert knowledge of building",
"Expert estimate from image",
"Survey of London",
"Pevsner Guides",
"Victoria County History",
"Local history publication",
"Other publication",
"National Heritage List for England",
"Other database or gazetteer",
"Historical map",
"Other archive document",
"Film/Video",
"Other website",
"Other"
]}
/>
<TextboxDataEntry
title={dataFields.date_source_detail.title}
slug="date_source_detail"
value={props.building.date_source_detail}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_source_detail.tooltip}
/>
<MultiDataEntry
title={dataFields.date_link.title}
slug="date_link"
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
/>
</Fragment>
);
};
const AgeContainer = withCopyEdit(AgeView);
export default AgeContainer;

View File

@ -31,6 +31,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
/>
<DataEntry
title={dataFields.location_street.title}
@ -99,8 +100,10 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
value={props.building.location_latitude}
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder="51"
step={0.00001}
min={-90}
max={90}
placeholder="Latitude, e.g. 51.5467"
onChange={props.onChange}
/>
<NumericDataEntry
@ -109,8 +112,10 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
value={props.building.location_longitude}
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder="0"
step={0.00001}
min={-180}
max={180}
placeholder="Longitude, e.g. -0.0586"
onChange={props.onChange}
/>
</Fragment>

View File

@ -13,7 +13,7 @@ import { CategoryViewProps } from './category-view-props';
*/
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntryGroup name="Storeys" collapsed={false}>
<DataEntryGroup name="Storeys">
<NumericDataEntry
title={dataFields.size_storeys_core.title}
@ -24,6 +24,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
tooltip={dataFields.size_storeys_core.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_storeys_attic.title}
@ -34,6 +35,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
tooltip={dataFields.size_storeys_attic.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_storeys_basement.title}
@ -44,9 +46,10 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
tooltip={dataFields.size_storeys_basement.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
</DataEntryGroup>
<DataEntryGroup name="Height">
<DataEntryGroup name="Height" collapsed={false}>
<NumericDataEntry
title={dataFields.size_height_apex.title}
slug="size_height_apex"
@ -55,6 +58,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_height_eaves.title}
@ -65,6 +69,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
</DataEntryGroup>
<DataEntryGroup name="Floor area">
@ -76,6 +81,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_floor_area_total.title}
@ -85,6 +91,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
</DataEntryGroup>
<NumericDataEntry
@ -95,6 +102,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_plot_area_total.title}
@ -104,6 +112,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
disabled={true}
/>
<NumericDataEntry
@ -114,6 +123,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
disabled={true}
/>
<SelectDataEntry

View File

@ -1,5 +1,9 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry';
import MultiDataEntry from '../data-components/multi-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
@ -9,28 +13,35 @@ import { CategoryViewProps } from './category-view-props';
*/
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Single or multiple use?</li>
{
// "disabled": true,
// "slug": "use_multi",
// "type": "checkbox"
}
<li>Type of use/s</li>
{
// "disabled": true,
// "slug": "use_type",
// "type": "text_multi"
}
<li>Number of self-contained units</li>
{
// "disabled": true,
// "slug": "use_number_scu",
// "type": "number",
// "step": 1
}
</ul>
<InfoBox msg="This category is currently read-only. We are working on enabling its editing soon." />
<MultiDataEntry
title={dataFields.current_landuse_class.title}
slug="current_landuse_class"
value={props.building.current_landuse_class}
mode="view"
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use class..."
/>
<MultiDataEntry
title={dataFields.current_landuse_group.title}
slug="current_landuse_group"
value={props.building.current_landuse_group}
mode="view"
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use group..."
/>
<DataEntry
title={dataFields.current_landuse_order.title}
slug="current_landuse_order"
value={props.building.current_landuse_order}
mode="view"
copy={props.copy}
onChange={props.onChange}
/>
</Fragment>
);
const UseContainer = withCopyEdit(UseView);

View File

@ -54,7 +54,7 @@ const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = (
return (
<div className="edit-history-entry">
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.date_trunc))}</h2>
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.revision_timestamp))}</h2>
<h3 className="edit-history-username">By {historyEntry.username}</h3>
{
showBuildingId && historyEntry.building_id != undefined &&

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import './edit-history.css';
import { apiGet } from '../../apiHelpers';
import { Building } from '../../models/building';
import { EditHistoryEntry } from '../../models/edit-history-entry';
import ContainerHeader from '../container-header';
@ -17,10 +18,9 @@ const EditHistory: React.FunctionComponent<EditHistoryProps> = (props) => {
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`/api/buildings/${props.building.building_id}/history.json`);
const data = await res.json();
const {history} = await apiGet(`/api/buildings/${props.building.building_id}/history.json`);
setHistory(data.history);
setHistory(history);
};
if (props.building != undefined) { // only call fn if there is a building provided

View File

@ -10,6 +10,9 @@ function formatValue(value: any) {
if(typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if(Array.isArray(value)) {
return value.map(v => `"${v}"`).join(', ');
}
return value;
}

View File

@ -2,6 +2,8 @@ import { parse } from 'query-string';
import React from 'react';
import { Link, Redirect, RouteComponentProps } from 'react-router-dom';
import { parseJsonOrDefault } from '../../helpers';
import ErrorBox from '../components/error-box';
import { BackIcon } from '../components/icons';
import InfoBox from '../components/info-box';
import { dataFields } from '../data_fields';
@ -10,25 +12,22 @@ import { User } from '../models/user';
import DataEntry from './data-components/data-entry';
import Sidebar from './sidebar';
interface MultiEditRouteParams {
cat: string;
}
interface MultiEditProps extends RouteComponentProps<MultiEditRouteParams> {
interface MultiEditProps {
user?: User;
category: string;
dataString: string;
}
const MultiEdit: React.FC<MultiEditProps> = (props) => {
if (!props.user){
return <Redirect to="/sign-up.html" />;
}
const cat = props.match.params.cat;
if (cat === 'like') {
if (props.category === 'like') {
// special case for likes
return (
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${cat} background-${cat}`}>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">Like me!</h2>
</header>
<form className='buttons-container'>
@ -42,34 +41,34 @@ const MultiEdit: React.FC<MultiEditProps> = (props) => {
);
}
const q = parse(props.location.search);
let data = parseJsonOrDefault(props.dataString);
let data: object;
if (cat === 'like'){
data = { like: true };
} else {
try {
// TODO: verify what happens if data is string[]
data = JSON.parse(q.data as string);
} catch (error) {
console.error(error, q);
data = {};
}
let error: string;
if(data == null) {
error = 'Invalid parameters supplied';
data = {};
} else if(Object.values(data).some(x => x == undefined)) {
error = 'Cannot copy empty values';
data = {};
}
return (
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${cat} background-${cat}`}>
<header className={`section-header view ${props.category} background-${props.category}`}>
<Link
className="icon-button back"
to={`/edit/${cat}`}>
to={`/edit/${props.category}`}>
<BackIcon />
</Link>
<h2 className="h2">Copy {cat} data</h2>
<h2 className="h2">Copy {props.category} data</h2>
</header>
<form>
<InfoBox msg='Click buildings one at a time to colour using the data below' />
{
error ?
<ErrorBox msg={error} /> :
<InfoBox msg='Click buildings one at a time to colour using the data below' />
}
{
Object.keys(data).map((key => {
const info = dataFields[key] || {};
@ -85,8 +84,8 @@ const MultiEdit: React.FC<MultiEditProps> = (props) => {
}
</form>
<form className='buttons-container'>
<Link to={`/view/${cat}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${cat}`} className='btn btn-secondary'>Back to edit</Link>
<Link to={`/view/${props.category}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${props.category}`} className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>

View File

@ -153,6 +153,19 @@ export const dataFields = {
title: "Longitude",
},
current_landuse_class: {
category: Category.LandUse,
title: "Current Land Use Class"
},
current_landuse_group: {
category: Category.LandUse,
title: "Current Land Use Group"
},
current_landuse_order: {
category: Category.LandUse,
title: "Current Land Use Order"
},
building_attachment_form: {
category: Category.Type,
title: "Building configuration (attachment)?",

View File

@ -1,9 +1,11 @@
import { parse } from 'query-string';
import { parse as parseQuery } from 'query-string';
import React, { Fragment } from 'react';
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { parseJsonOrDefault } from '../helpers';
import { strictParseInt } from '../parse';
import { apiGet, apiPost } from './apiHelpers';
import BuildingView from './building/building-view';
import Categories from './building/categories';
import { EditHistory } from './building/edit-history/edit-history';
@ -63,16 +65,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
async fetchLatestRevision() {
try {
const res = await fetch(`/api/buildings/revision`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const data = await res.json();
const {latestRevisionId} = await apiGet(`/api/buildings/revision`);
this.increaseRevision(data.latestRevisionId);
this.increaseRevision(latestRevisionId);
} catch(error) {
console.error(error);
}
@ -88,27 +83,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
// TODO: simplify API calls, create helpers for fetching data
const buildingId = strictParseInt(this.props.match.params.building);
let [building, building_uprns, building_like] = await Promise.all([
fetch(`/api/buildings/${buildingId}.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(res => res.json()),
fetch(`/api/buildings/${buildingId}/uprns.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(res => res.json()),
fetch(`/api/buildings/${buildingId}/like.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(res => res.json())
apiGet(`/api/buildings/${buildingId}.json`),
apiGet(`/api/buildings/${buildingId}/uprns.json`),
apiGet(`/api/buildings/${buildingId}/like.json`)
]);
building.uprns = building_uprns.uprns;
@ -130,6 +107,13 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
return category;
}
getMultiEditDataString(): string {
const q = parseQuery(this.props.location.search);
if(Array.isArray(q.data)) {
throw new Error('Invalid format');
} else return q.data;
}
increaseRevision(revisionId) {
revisionId = +revisionId;
// bump revision id, only ever increasing
@ -150,15 +134,8 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
this.increaseRevision(building.revision_id);
// get UPRNs and update
fetch(`/api/buildings/${building.building_id}/uprns.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
apiGet(`/api/buildings/${building.building_id}/uprns.json`)
.then((res) => {
if (res.error) {
console.error(res);
} else {
@ -171,15 +148,8 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
});
// get if liked and update
fetch(`/api/buildings/${building.building_id}/like.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
apiGet(`/api/buildings/${building.building_id}/like.json`)
.then((res) => {
if (res.error) {
console.error(res);
} else {
@ -199,57 +169,39 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
*
* Pulls data from URL to form update
*
* @param {object} building
* @param {Building} building
*/
colourBuilding(building) {
colourBuilding(building: Building) {
const cat = this.props.match.params.category;
const q = parse(window.location.search);
if (cat === 'like') {
this.likeBuilding(building.building_id);
} else {
try {
// TODO: verify what happens if data is string[]
const data = JSON.parse(q.data as string);
const data = parseJsonOrDefault(this.getMultiEditDataString());
if (data != undefined && !Object.values(data).some(x => x == undefined)) {
this.updateBuilding(building.building_id, data);
} catch (error) {
console.error(error, q);
}
}
}
likeBuilding(buildingId) {
fetch(`/api/buildings/${buildingId}/like.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ like: true })
}).then(
res => res.json()
).then(function (res) {
apiPost(`/api/buildings/${buildingId}/like.json`, { like: true })
.then(res => {
if (res.error) {
console.error({ error: res.error });
} else {
this.increaseRevision(res.revision_id);
}
}.bind(this)).catch(
}).catch(
(err) => console.error({ error: err })
);
}
updateBuilding(buildingId, data) {
fetch(`/api/buildings/${buildingId}.json`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(res => {
apiPost(`/api/buildings/${buildingId}.json`, data)
.then(res => {
if (res.error) {
console.error({ error: res.error });
} else {
@ -281,7 +233,8 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
</Route>
<Route exact path="/multi-edit/:cat" render={(props) => (
<MultiEdit
{...props}
category={category}
dataString={this.getMultiEditDataString()}
user={this.props.user}
/>
)} />

View File

@ -18,8 +18,20 @@ const LEGEND_CONFIG = {
]
},
use: {
title: 'Use',
elements: []
title: 'Land Use',
elements: [
{ color: '#4a54a6', text: 'Residential' },
{ color: '#e5050d', text: 'Mixed Use' },
{ color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry And Business' },
{ color: '#73ccd1', text: 'Community Services' },
{ color: '#ffbfbf', text: 'Recreation And Leisure' },
{ color: '#bee8ff', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities And Infrastructure' },
{ color: '#52403C', text: 'Agriculture And Fisheries' },
{ color: '#898944', text: 'Defence' },
{ color: '#ffffff', text: 'Vacant And Derelict' },
]
},
type: {
title: 'Type',
@ -55,13 +67,16 @@ const LEGEND_CONFIG = {
]
},
size: {
title: 'Number of storeys',
title: 'Height to apex',
elements: [
{ color: '#ffffcc', text: '≥40' },
{ color: '#fed976', text: '2039' },
{ color: '#fd8d3c', text: '1019' },
{ color: '#e31a1c', text: '69' },
{ color: '#800026', text: '15' },
{ color: '#f7f4f9', text: '0-5.55'},
{ color: '#e7e1ef', text: '5.55-7.73'},
{ color: '#d4b9da', text: '7.73-11.38'},
{ color: '#c994c7', text: '11.38-18.45'},
{ color: '#df65b0', text: '18.45-35.05'},
{ color: '#e7298a', text: '35.05-89.30'},
{ color: '#ce1256', text: '89.30-152'},
{ color: '#980043', text: '≥152'}
]
},
construction: {

View File

@ -5,6 +5,7 @@ import { AttributionControl, GeoJSON, Map, TileLayer, ZoomControl } from 'react-
import 'leaflet/dist/leaflet.css';
import './map.css';
import { apiGet } from '../apiHelpers';
import { HelpIcon } from '../components/icons';
import { Building } from '../models/building';
@ -58,13 +59,9 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
handleClick(e) {
const mode = this.props.mode;
const lat = e.latlng.lat;
const lng = e.latlng.lng;
fetch(
'/api/buildings/locate?lat='+lat+'&lng='+lng
).then(
(res) => res.json()
).then(function(data){
const { lat, lng } = e.latlng;
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
.then(data => {
if (data && data.length){
const building = data[0];
if (mode === 'multi-edit') {
@ -82,7 +79,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
this.props.selectBuilding(undefined);
}
}
}.bind(this)).catch(
}).catch(
(err) => console.error(err)
);
}
@ -94,8 +91,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
}
async getBoundary() {
const res = await fetch('/geometries/boundary-detailed.geojson');
const data = await res.json() as GeoJsonObject;
const data = await apiGet('/geometries/boundary-detailed.geojson') as GeoJsonObject;
this.setState({
boundary: data
});
@ -133,12 +130,13 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
const cat = this.props.category;
const tilesetByCat = {
age: 'date_year',
size: 'size_storeys',
size: 'size_height',
location: 'location',
like: 'likes',
planning: 'conservation_area',
sustainability: 'sust_dec',
type: 'building_attachment_form',
use: 'landuse'
};
const tileset = tilesetByCat[cat];
// pick revision id to bust browser cache

View File

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import './search-box.css';
import { apiGet } from '../apiHelpers';
import { SearchIcon } from '../components/icons';
interface SearchResult {
@ -96,11 +97,8 @@ class SearchBox extends Component<SearchBoxProps, SearchBoxState> {
fetching: true
});
fetch(
'/api/search?q='+this.state.q
).then(
(res) => res.json()
).then((data) => {
apiGet(`/api/search?q=${this.state.q}`)
.then((data) => {
if (data && data.results){
this.setState({
results: data.results,

View File

@ -1,5 +1,5 @@
export interface EditHistoryEntry {
date_trunc: string;
revision_timestamp: string;
username: string;
revision_id: string;
forward_patch: object;

View File

@ -0,0 +1,7 @@
.edit-history-link {
margin-top: 4em;
}
.edit-history-latest-link {
float: right;
}

View File

@ -1,31 +1,88 @@
import { parse } from 'query-string';
import React, { useEffect, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import './changes.css';
import { apiGet } from '../apiHelpers';
import { BuildingEditSummary } from '../building/edit-history/building-edit-summary';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { EditHistoryEntry } from '../models/edit-history-entry';
const ChangesPage = () => {
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
interface PagingInfo {
id_for_older_query: string;
id_for_newer_query: string;
}
const recordsPerPage = 20;
const ChangesPage = (props: RouteComponentProps) => {
const { after_id, before_id } = parse(props.location.search);
const [history, setHistory] = useState<EditHistoryEntry[]>();
const [paging, setPaging] = useState<PagingInfo>();
const [error, setError] = useState();
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`/api/history`);
const data = await res.json();
setHistory(null);
setPaging(null);
let url = `/api/history?count=${recordsPerPage}`;
if(after_id) {
url = `${url}&after_id=${after_id}`;
}
if (before_id) {
url = `${url}&before_id=${before_id}`;
}
try {
const {history, paging, error} = await apiGet(url);
if(error) {
setError(error);
} else {
setHistory(history);
setPaging(paging);
}
} catch (err) {
setError('Connection problem. Please try again later...');
}
setHistory(data.history);
};
fetchData();
}, []);
}, [props.location.search]);
return (
<article>
<section className="main-col">
<h1>Global edit history</h1>
{
paging?.id_for_newer_query &&
<Link className='edit-history-link' to={`?after_id=${paging.id_for_newer_query}`}>Show more recent changes</Link>
}
{
(after_id || before_id) &&
<Link className='edit-history-latest-link' to='?'>Show latest changes</Link>
}
<ul className="edit-history-list">
{(history == undefined || history.length == 0) ?
<InfoBox msg="No changes in the last week"></InfoBox> :
{
error &&
<ErrorBox msg={error}></ErrorBox>
}
{
error == undefined && history == undefined &&
<InfoBox msg="Loading history..."></InfoBox>
}
{
(history?.length === 0) &&
<InfoBox msg="No changes so far"></InfoBox>
}
{
(history != undefined && history.length > 0) &&
history.map(entry => (
<li key={`${entry.revision_id}`} className="edit-history-list-element">
<BuildingEditSummary
@ -37,6 +94,10 @@ const ChangesPage = () => {
))
}
</ul>
{
paging?.id_for_older_query &&
<Link to={`?before_id=${paging.id_for_older_query}`}>Show older changes</Link>
}
</section>
</article>
);

View File

@ -2,6 +2,7 @@ import React, { FunctionComponent } from 'react';
import { Link } from 'react-router-dom';
import { dateReviver } from '../../helpers';
import { apiGet } from '../apiHelpers';
interface ExtractViewModel {
@ -28,13 +29,8 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
}
async componentDidMount() {
const res = await fetch('/api/extracts');
const data = JSON.parse(await res.text(), dateReviver);
const extracts = (data.extracts as ExtractViewModel[])
.sort((a, b) => a.extracted_on.valueOf() - b.extracted_on.valueOf())
.reverse();
let data = await apiGet('/api/extracts', { jsonReviver: dateReviver});
const extracts = (data.extracts as ExtractViewModel[]);
this.setState({ extracts: extracts, latestExtract: extracts[0], previousExtracts: extracts.slice(1) });
}

View File

@ -6,13 +6,11 @@ import './welcome.css';
const Welcome = () => (
<div className="jumbotron welcome-float">
<h1 className="h1">Welcome to Colouring London</h1>
<p className="lead">
Colouring London is a knowledge exchange platform collecting information on every
building in London, to help make the city more sustainable. We're developing it at University College London. Can you help us? We're looking for volunteers of all ages and abilities to help test the site and colour the buildings in.
Colouring London is a knowledge exchange platform set up by University College London to help make the city more sustainable. It provides open statistical data on the characteristics of the city's buildings and on the dynamic behaviour of the stock. We're working to collate, collect, generate, verify over fifty types of data and to visualise many of these datasets.
</p>
<p className="lead">
Our building data comes from many different sources. Though we are unable to vouch for their accuracy, we are currently experimenting with a range of features including 'data source', 'edit history', and 'entry verification', to assist you in checking reliability and judging how suitable the data are for your intended use.
Our information comes from many different sources. As we are unable to vouch for data accuracy, we are currently experimenting with a range of features including 'data source', 'edit history', and 'entry verification', to assist you in checking reliability and judging how suitable the data are for your intended use. Your help in checking and adding data is very much appreciated.
</p>
<p className="lead">
All data we collect are made <Link to="/data-extracts.html">openly available</Link>. We just ask you to credit Colouring London and read our <a href="https://www.pages.colouring.london/data-ethics">data ethics policy</a> when using or sharing our data, maps or <a href="https://github.com/tomalrussell/colouring-london">code</a>.

View File

@ -49,6 +49,11 @@ article .color-block {
padding-left: 1em;
padding-right: 1em;
}
@media(min-width: 768px) {
.main-col {
min-width: 48em;
}
}
hr {
display: block;
height: 1px;

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import { Link, Redirect } from 'react-router-dom';
import { apiGet, apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import SupporterLogos from '../components/supporter-logos';
@ -39,24 +40,13 @@ class Login extends Component<LoginProps, any> {
event.preventDefault();
this.setState({error: undefined});
fetch('/api/login', {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
apiPost('/api/login', this.state)
.then(res => {
if (res.error) {
this.setState({error: res.error});
} else {
fetch('/api/users/me', {
credentials: 'same-origin'
}).then(
(res) => res.json()
).then(user => {
apiGet('/api/users/me')
.then(user => {
if (user.error) {
this.setState({error: user.error});
} else {
@ -66,7 +56,7 @@ class Login extends Component<LoginProps, any> {
(err) => this.setState({error: err})
);
}
}.bind(this)).catch(
}).catch(
(err) => this.setState({error: err})
);
}

View File

@ -1,6 +1,7 @@
import React, { Component, FormEvent } from 'react';
import { Link, Redirect } from 'react-router-dom';
import { apiDelete, apiPost } from '../apiHelpers';
import ConfirmationModal from '../components/confirmation-modal';
import ErrorBox from '../components/error-box';
import { User } from '../models/user';
@ -32,12 +33,8 @@ class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
event.preventDefault();
this.setState({error: undefined});
fetch('/api/logout', {
method: 'POST',
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
apiPost('/api/logout')
.then(function(res){
if (res.error) {
this.setState({error: res.error});
} else {
@ -52,18 +49,14 @@ class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
event.preventDefault();
this.setState({error: undefined});
fetch('/api/api/key', {
method: 'POST',
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
apiPost('/api/api/key')
.then(res => {
if (res.error) {
this.setState({error: res.error});
} else {
this.props.updateUser(res);
}
}.bind(this)).catch(
}).catch(
(err) => this.setState({error: err})
);
}
@ -81,11 +74,7 @@ class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
this.setState({ error: undefined });
try {
const res = await fetch('/api/users/me', {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await res.json();
const data = await apiDelete('/api/users/me');
if(data.error) {
this.setState({ error: data.error });

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import { Link, Redirect } from 'react-router-dom';
import { apiGet, apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import SupporterLogos from '../components/supporter-logos';
@ -48,36 +49,21 @@ class SignUp extends Component<SignUpProps, SignUpState> {
} as Pick<SignUpState, keyof SignUpState>);
}
handleSubmit(event) {
async handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined});
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error});
try {
const res = await apiPost('/api/users', this.state);
if(res.error) {
this.setState({ error: res.error });
} else {
fetch('/api/users/me', {
credentials: 'same-origin'
}).then(
(res) => res.json()
).then(
(user) => this.props.login(user)
).catch(
(err) => this.setState({error: err})
);
const user = await apiGet('/api/users/me');
this.props.login(user);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
} catch(err) {
this.setState({error: err});
}
}
render() {

View File

@ -13,3 +13,48 @@ export function dateReviver(name, value) {
}
return value;
}
export function parseJsonOrDefault(jsonString: string) {
try {
return JSON.parse(jsonString);
} catch(error) {
console.error(error);
return null;
}
}
export function hasAnyOwnProperty(obj: {}, keys: string[]) {
return keys.some(k => obj.hasOwnProperty(k));
}
export function isNullishOrEmpty(obj: any) {
return obj == undefined || isEmptyArray(obj);
}
export function isEmptyArray(obj: any) {
return Array.isArray(obj) && obj.length === 0;
}
type AccessorFunction<T, V> = (obj: T) => V;
type CompareFunction<T> = (a: T, b: T) => number;
export function numAsc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T>{
return (a: T, b: T) => Number(accessor(a) - accessor(b));
}
export function numDesc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T> {
return (a: T, b: T) => Number(accessor(b) - accessor(a));
}
/**
* As of Jan 2020, bigint literals e.g. 1n can only be used with TS target esnext
* which then doesn't transpile the null conditional/coalescing operators and breaks on Node 12
* So BigInt(1) needs to be used here
* */
export function incBigInt(bigStr: string): string {
return bigStr == undefined ? bigStr : String(BigInt(bigStr) + BigInt(1));
}
export function decBigInt(bigStr: string): string {
return bigStr == undefined ? bigStr : String(BigInt(bigStr) - BigInt(1));
}

View File

@ -46,6 +46,15 @@ const BUILDING_LAYER_DEFINITIONS = {
WHERE
g.geometry_id = b.geometry_id
) as size_stories`,
size_height: `(
SELECT
b.size_height_apex as size_height,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE g.geometry_id = b.geometry_id
) as size_height`,
location: `(
SELECT
(
@ -108,6 +117,14 @@ const BUILDING_LAYER_DEFINITIONS = {
WHERE
g.geometry_id = b.geometry_id
) as building_attachment_form`,
landuse: `(
SELECT
b.current_landuse_order,
g.geometry_geom
FROM geometries as g
JOIN buildings as b
ON g.geometry_id = b.geometry_id
) as current_landuse_order`,
};
const GEOMETRY_FIELD = 'geometry_geom';

35
docs/adding-new-fields.md Normal file
View File

@ -0,0 +1,35 @@
## Adding new building attribute fields
This document is a checklist for adding a new building attribute to the system. It's split into two sections - actions that apply when adding any field, and additional steps to add a field that will be visualised on the map.
The second section would be required when adding a new category or when changing which field should be visualised for a category.
### Adding any attribute
#### In database
1. Add a column to the `buildings` table in the database.
2. Add any check constraints or foreign key constraints on the column, if necessary (if the foreign key constraint is used to restrict the column to a set of values, the table with the values might need to be created from scratch)
#### In API
1. Add field name to `BUILDING_FIELD_WHITELIST` in the building service to allow saving changes to the field
2. Add any special domain logic for processing updates to the field in the `processBuildingUpdate()` function
#### In frontend
1. Add the field description to the `dataFields` object in `data_fields.ts`
2. Add the data entry React component of the appropriate type (text, numeric etc) to the appropriate category view component in the `building/data-containers` folder. Link to `dataFields` for static string values (field name, description etc)
#### In data extracts
1. Add the field to the select list in the COPY query in `maintenance/extract_data/export_attributes.sql`
2. Add a description of the field to the `README.txt` file
### Adding an attribute which is used to colour the map
All steps from the previous section need to be carried out first.
#### In tileserver
1. Add a SQL query for calculating the value to be visualised to `BUILDING_LAYER_DEFINITIONS` in `app/src/tiles/dataDefinition.ts`
2. Add Mapnik rendering style in `app/map_styles/polygon.xml`
#### In frontend
1. Update the category to field name mapping in the `tilesetByCat` object inside the `ColouringMap` React component (`map.tsx` file)
2. Add an entry for the field to the `LEGEND_CONFIG` object in `legend.tsx` file

View File

@ -37,7 +37,7 @@ Now clone the colouring london codebase.
Now install Node. It is helpful to define some local variables.
```
NODE_VERSION=v8.11.3
NODE_VERSION=v12.14.1
DISTRO=linux-x64
wget -nc https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-$DISTRO.tar.xz
sudo mkdir /usr/local/lib/node
@ -137,7 +137,7 @@ root user profile. Don't forget to exit from root at the end.
```
sudo su root
export NODEJS_HOME=/usr/local/lib/node/node-v8.11.3/bin/`
export NODEJS_HOME=/usr/local/lib/node/node-v12.14.1/bin/`
export PATH=$NODEJS_HOME:$PATH`
npm install -g npm@next`
exit

View File

@ -32,6 +32,10 @@ Install Postgres and associated tools
`sudo apt-get install -y gdal-bin libspatialindex-dev libgeos-dev libproj-dev`
Install Python 3 and pip
`sudo apt-get install python3 python3-pip`
Install Nginx
@ -68,7 +72,7 @@ Now set appropriate permissions on the `colouring-london` directory
First define a couple of convenience variables:
`NODE_VERSION=v8.11.3`
`NODE_VERSION=v12.14.1`
`DISTRO=linux-x64`
@ -107,7 +111,7 @@ Now upgrade the `npm` package manager to the most recent release with global pri
`sudo su root`
`export NODEJS_HOME=/usr/local/lib/node/node-v8.11.3/bin/`
`export NODEJS_HOME=/usr/local/lib/node/node-v12.14.1/bin/`
`export PATH=$NODEJS_HOME:$PATH`
@ -240,7 +244,7 @@ Perform a global install of PM2
`sudo su root`
`export NODEJS_HOME=/usr/local/lib/node/node-v8.11.3/bin/`
`export NODEJS_HOME=/usr/local/lib/node/node-v12.14.1/bin/`
`export PATH=$NODEJS_HOME:$PATH`
@ -303,6 +307,21 @@ To stop the colouring-london app type:
***
#### Set up data extracts
Install requirements for the maintenance Python scripts
`cd /var/www/colouring-london/maintenance`
`sudo pip3 install -r requirements.txt`
The maintenance scripts might need environment variables present at the time of execution, notably the database connection details.
If running the scripts manually, the variables can be provided just before execution, for example
`PGHOST=localhost PGPORT=5432 PGDATABASE=dbname PGUSER=username PGPASSWORD=secretpassword EXTRACTS_DIRECTORY=/var/www/colouring-london/downloads python3 maintenance/extract_data/extract_data.py`
If the maintenance script is to be run on a schedule, the variables should be loaded before running the script, for example from a `.env` file.
#### Set up SSL - TO DO

View File

@ -8,6 +8,17 @@ the appropriate site):
a0a00000-0a00-0aaa-a0a0-0000aaaa0000 \
data.csv
The optional last argument specifies which columns should be parsed as JSON values.
This is required for example for columns of array type to be processed by the API correctly.
Otherwise, those values would be treated as a string and not an array.
An example usage with the json_columns argument (other values in the example are placeholders):
python load_csv.py \
https://colouring.london \
a0a00000-0a00-0aaa-a0a0-0000aaaa0000 \
data.csv \
current_landuse_group,date_url
This script uses the HTTP API, and can process CSV files which identify buildings by id, TOID,
UPRN.
@ -23,6 +34,7 @@ The process:
- else lookup by toid
- else lookup by uprn
- else locate building by representative point
- (optional) parse JSON column values
- update building
TODO extend to allow latitude,longitude or easting,northing columns and lookup by location.
@ -36,13 +48,14 @@ import sys
import requests
def main(base_url, api_key, source_file):
def main(base_url, api_key, source_file, json_columns):
"""Read from file, update buildings
"""
with open(source_file, 'r') as source:
reader = csv.DictReader(source)
for line in reader:
building_id = find_building(line, base_url)
line = parse_json_columns(line, json_columns)
if building_id is None:
continue
@ -70,7 +83,10 @@ def update_building(building_id, data, api_key, base_url):
def find_building(data, base_url):
if 'building_id' in data:
return data['building_id']
building_id = data['building_id']
if building_id is not None:
print("match_by_building_id", building_id)
return building_id
if 'toid' in data:
building_id = find_by_reference(base_url, 'toid', data['toid'])
@ -104,15 +120,22 @@ def find_by_reference(base_url, ref_key, ref_id):
return building_id
def parse_json_columns(row, json_columns):
for col in json_columns:
row[col] = json.loads(row[col])
return row
if __name__ == '__main__':
try:
url, api_key, filename = sys.argv[1], sys.argv[2], sys.argv[3]
except IndexError:
print(
"Usage: {} <URL> <api_key> ./path/to/data.csv".format(
"Usage: {} <URL> <api_key> ./path/to/data.csv [<json_columns>]".format(
os.path.basename(__file__)
))
exit()
main(url, api_key, filename)
json_columns = sys.argv[4].split(',') if len(sys.argv) > 4 else []
main(url, api_key, filename, json_columns)

View File

@ -61,6 +61,11 @@ def update_building(building_id, data, api_key, base_url):
def find_building(data, base_url):
if 'building_id' in data:
building_id = data['building_id']
if building_id is not None:
print("match_by_building_id", building_id)
return building_id
if 'toid' in data:
building_id = find_by_reference(base_url, 'toid', data['toid'])
if building_id is not None:

View File

@ -41,7 +41,11 @@ This is the main table, containing almost all data collected by Colouring London
- `location_postcode`: postcode
- `location_latitude`: latitude
- `location_longitude`: longitude
- `current_landuse_class`: current land use class
- `current_landuse_group`: current land use group
- `current_landuse_order`: current land use order
- `building_attachment_form`: building attachment form
- `date_change_building_use`: year of last building use change
- `date_year`: year built
- `date_lower`: lower bound on year built
- `date_upper`: upper bound on year built

View File

@ -11,7 +11,11 @@ COPY (SELECT
location_postcode,
location_latitude,
location_longitude,
current_landuse_class,
current_landuse_group,
current_landuse_order,
building_attachment_form,
date_change_building_use,
date_year,
date_lower,
date_upper,

View File

@ -64,9 +64,11 @@ def make_data_extract(current_time, connection, zip_file_path):
zip_file_path.parent.mkdir(parents=True, exist_ok=True)
source_dir_path = Path(__file__).parent
try:
with zipfile.ZipFile(zip_file_path, mode='w') as newzip:
newzip.write('README.txt')
newzip.write(source_dir_path / 'README.txt', arcname='README.txt')
newzip.write('/tmp/building_attributes.csv', arcname='building_attributes.csv')
newzip.write('/tmp/building_uprns.csv', arcname='building_uprns.csv')
newzip.write('/tmp/edit_history.csv', arcname='edit_history.csv')

View File

@ -1 +0,0 @@
0 5 * * * /var/www/colouringlondon/maintenance/extract_data/extract_data.py

View File

@ -0,0 +1,4 @@
ALTER TABLE users
DROP COLUMN is_blocked,
DROP COLUMN blocked_on,
DROP COLUMN blocked_reason;

View File

@ -0,0 +1,4 @@
ALTER TABLE users
ADD COLUMN is_blocked BOOLEAN NOT NULL DEFAULT (false),
ADD COLUMN blocked_on TIMESTAMP WITH TIME ZONE NULL,
ADD COLUMN blocked_reason TEXT NULL;

View File

@ -0,0 +1,2 @@
--For landuse classifications there are tables containing the landuses, these are stored in a new schema
DROP SCHEMA IF EXISTS reference_tables CASCADE;

View File

@ -0,0 +1,837 @@
--For landuse classifications there are tables containing the landuses, these are stored in a new schema
CREATE SCHEMA IF NOT EXISTS reference_tables;
--Then create the table for landuse
CREATE TABLE IF NOT EXISTS reference_tables.landuse_classifications (
landuse_id VARCHAR(9) NOT NULL,
description VARCHAR(74) NOT NULL,
level VARCHAR(5) NOT NULL,
parent_id VARCHAR(4),
is_used BOOLEAN DEFAULT True
);
--populate with data
--These are taking from the NLUD calssifcations as url below, accessed 12th August 2019
-- https://land.copernicus.eu/eagle/files/eagle-related-projects/22_NLUD_v44.pdf
-- The is_used column is set based on whether we plan to use a given order/class for urban buildings.
INSERT INTO reference_tables.landuse_classifications
(landuse_id, description, level, parent_id, is_used)
VALUES
-- order
('U010','Agriculture And Fisheries','order',NULL,True),
('U080','Community Services','order',NULL,True),
('U120','Defence','order',NULL,True),
('U020','Forestry','order',NULL,False),
('U100','Industry And Business','order',NULL,True),
('U030','Minerals','order',NULL,False),
('U040','Recreation And Leisure','order',NULL,True),
('U070','Residential','order',NULL,True),
('U090','Retail','order',NULL,True),
('U050','Transport','order',NULL,True),
('U130','Unused Land','order',NULL,False),
('U060','Utilities And Infrastructure','order',NULL,True),
('U110','Vacant And Derelict','order',NULL,True),
--We have created a landuse order that does not exist for mixed use
('U140','Mixed Use','order',NULL,True),
-- group
('U011','Agriculture','group','U010', True),
('U012','Fisheries','group','U010', True),
('U021','Managed forest','group','U020', True),
('U022','Un-managed forest','group','U020', True),
('U031','Mineral workings and quarries','group','U030', True),
('U041','Outdoor amenity and open spaces','group','U040', True),
('U042','Amusement and show places','group','U040', True),
('U043','Libraries, museums and galleries','group','U040', True),
('U044','Sports facilities and grounds','group','U040', True),
('U045','Holiday parks and camps','group','U040', True),
('U046','Allotments and city farms','group','U040', True),
('U051','Transport tracks and ways','group','U050', True),
('U052','Transport terminals and interchanges','group','U050', True), -- corrected U054 to U052
('U053','Car parks','group','U050', True),
('U054','Vehicle storage','group','U050', True), -- corrected U051 to U054
('U055','Goods and freight terminals','group','U050', True),
('U056','Waterways','group','U050', True),
('U061','Energy production and distribution','group','U060', True),
('U062','Water storage and treatment','group','U060', True),
('U063','Refuse disposal','group','U060', True),
('U064','Cemeteries and crematoria','group','U060', True),
('U065','Post and telecommunications','group','U060', True),
('U071','Dwellings','group','U070', True),
('U072','Hotels, boarding and guest houses','group','U070', True),
('U073','Residential institutions','group','U070', True),
('U081','Medical and health care services','group','U080', True),
('U082','Places of worship','group','U080', True),
('U083','Education','group','U080', True),
('U084','Community services','group','U080', True),
('U091','Shops','group','U090', True),
('U092','Financial and professional services','group','U090', True),
('U093','Restaurants and cafes','group','U090', True),
('U094','Public houses, bars and nightclubs','group','U090', True),
('U101','Manufacturing','group','U100', True),
('U102','Offices','group','U100', True),
('U103','Storage','group','U100', True),
('U104','Wholesale distribution','group','U100', True),
('U111','Vacant','group','U110', True),
('U112','Derelict','group','U110', True),
('U121','Defence','group','U120', True),
('U131','Unused land','group','U130', True),
-- class
('AG05A -A','Agricultural research establishment','class','U011',True),
('AG','Agriculture and fisheries places','class','U011',False),
('AG01A -A','Animal artificial insemination centre','class','U011',False),
('AG01B -A','Animal boarding establishment','class','U011',False),
('AG02A','Animal breeding places','class','U011',True),
('AG01B -B','Animal clipping and manicure establishment','class','U011',True),
('AG01A -B','Animal dipping place','class','U011',True),
('AG02','Animal living places','class','U011',True),
('AG03A','Animal milking places','class','U011',True),
('AG03','Animal product processing places','class','U011',True),
('AG03F -A','Animal product waste store','class','U011',True),
('AG03F -B','Animal product waste treatment places','class','U011',True),
('AG03F','Animal products waste handling places','class','U011',True),
('AG02B','Animal rearing places','class','U011',True),
('AG01A','Animal service places','class','U011',True),
('AG01','Animal service places','class','U011',True),
('AG03B','Animal shearing places','class','U011',True),
('AG03C','Animal slaughtering places','class','U011',True),
('AG03C-A','Animal stunning and killing places','class','U011',True),
('AG01B','Animal welfare places','class','U011',True),
('AG06A','Arable farm places','class','U011',True),
('AG02B -A','Bedding and waste removal','class','U011',True),
('AG01A -C','Blacksmith premises','class','U011',True),
('AG06A -A','Cereal crops','class','U011',False),
('AG07A -A','Cereal crops store','class','U011',True),
('AG07A','Crop conditioning grading and storage places','class','U011',True),
('AG07','Crop processing places','class','U011',True),
('AG06','Cultivated places','class','U011',True),
('AG03D-A','Egg grading place','class','U011',True),
('AG06A -B','Fallow land','class','U011',False),
('AG03E','Feed handling places','class','U011',True),
('AG03E -A','Feed preparation place','class','U011',True),
('AG03E -B','Feed storage place','class','U011',True),
('AG02B -B','Feeding','class','U011',True),
('AG03D-B','Fish sorting place','class','U011',True),
('AG06B -B','Flower bed','class','U011',False),
('AG07A -B','Fruit crops','class','U011',False),
('AG06B -C','Glass house','class','U011',True),
('AG08A','Grazing places','class','U011',False),
('AG06A -C','Green forage crops place','class','U011',False),
('AG07A -C','Green forage crops store','class','U011',False),
('AG06B -D','Hop field','class','U011',False),
('AG06B','Horticultural places','class','U011',True),
('AG02B -C','Isolation (animal living place)','class','U011',False),
('AG03C-B','Knackering place','class','U011',True),
('AG03A -A','Milk extraction place','class','U011',True),
('AG03A -B','Milk treatment place','class','U011',True),
('AG07A -F','Mill and mix building','class','U011',True),
('AG06B -E','Mixed market garden','class','U011',False),
('AG08','Non cultivated places','class','U011',False),
('AG06B -F','Nursery','class','U011',False),
('AG06B -G','Orchard with arable land','class','U011',False),
('AG06B -H','Orchard with grass','class','U011',False),
('AG06B -I','Orchard with market garden','class','U011',False),
('AG07A -G','Packing and sorting (crop processing place)','class','U011',True),
('AG03D','Packing places (animal products)','class','U011',True),
('AG02A -C','Parturition place','class','U011',True),
('AG08A -A','Permanent pasture','class','U011',False),
('AG03C-C','Processing place (animal slaughtering place)','class','U011',True),
('AG06A -D','Pulse crops place','class','U011',False),
('AG07A -D','Pulse crops store','class','U011',True),
('AG01B -C','Quarantine place, animal','class','U011',True),
('AG06A -E','Root crops place','class','U011',True),
('AG07A -E','Root crops store','class','U011',True),
('AG08A -B','Rough grazing','class','U011',False),
('AG02A -D','Service pen','class','U011',False),
('AG06B -J','Soft fruit place','class','U011',False),
('AG07A -H','Vegetable (crop conditioning and storage)','class','U011',True),
('AG06B -K','Vegetable field','class','U011',False),
('AG01B -D','Veterinary hospital','class','U011',True),
('AG01B -E','Veterinary surgery','class','U011',True),
('AG02B -E','Weighing place','class','U011',True),
('AG03B -A','Wool grading place','class','U011',True),
('AG03B -B','Wool removal place','class','U011',True),
('AG02A -A','Fish farm','class','U012',True),
('AG04A','Fishery places','class','U012',True),
('AG02A -B','Hatchery','class','U012',False),
('AG04A -A','Net fishery place','class','U012',True),
('AG04A -B','Pot and other inshore or estuarial fishing place','class','U012',True),
('AG02B -D','Rearing pond','class','U012',False),
('AG04A -C','Rod and line fishery place','class','U012',True),
('AG04','Wild life capturing places','class','U012',True),
('MA06A -A','Abrasives and other building materials manufacturing place','class','U101',True),
('MA04E -A','Aerospace equipment manufacturing or repairing place','class','U101',True),
('MA04A -A','Agricultural machinery manufacturing place','class','U101',True),
('MA01B -A','Aluminium and aluminium alloy manufacturing','class','U101',True),
('MA02A -A','Animal and poultry food manufacturing place','class','U101',True),
('MA02A -B','Bacon curing, meat and fish product manufacturing place','class','U101',True),
('MA07A -A','Bedding and soft furnishings manufacturing place','class','U101',True),
('MA02A -C','Biscuit manufacturing','class','U101',True),
('MA04F -D','Bolts, nuts, screws, rivets etc. manufacturing place','class','U101',True),
('MA02A -D','Bread and flour confectionery manufacturing place','class','U101',True),
('MA02A -E','Brewery','class','U101',True),
('MA06A -B','Bricks, fireclay and refractory goods manufacturing place','class','U101',True),
('MA06A','Bricks, pottery, glass, cement manufacturing places','class','U101',True),
('MA04C-A','Broadcast receiving and sound reproducing equipment manufacturing','class','U101',True),
('MA08A -A','Brushes and brooms manufacturing place','class','U101',True),
('MA04F -A','Can and metal box manufacturing place','class','U101',True),
('MA05A -A','Carpet manufacturing place','class','U101',True),
('MA06A -C','Cement manufacturing place','class','U101',True),
('MA03A','Chemical and allied industries manufacturing places','class','U101',True),
('MA05C','Clothing and footwear manufacturing places','class','U101',True),
('MA05','Clothing, textiles, leather, footwear and fur goods manufacturing places','class','U101',True),
('MA01A','Coal and petroleum processing places','class','U101',True),
('MA01','Coal, oil and metal processing places','class','U101',True),
('MA02A -F','Cocoa, chocolate and sugar confectionery manufacturing place','class','U101',True),
('MA01A -A','Coke ovens and solid fuel manufacturing place','class','U101',True),
('MA09A -A','Construction and demolition site','class','U101',True),
('MA04A -B','Construction and earth moving equipment manufacturing place','class','U101',True),
('MA09','Construction places','class','U101',True),
('MA09A','Construction places','class','U101',True),
('MA01B -B','Copper, brass and other copper alloy manufacturing place','class','U101',True),
('MA04F -B','Cutlery and plated tableware manufacturing place','class','U101',True),
('MA05C-A','Dresses, lingerie, infants wear etc. manufacturing place','class','U101',True),
('MA03A -A','Dyestuffs and pigments manufacturing place','class','U101',True),
('MA04C-B','Electric appliances primarily for domestic use manufacturing places','class','U101',True),
('MA04C','Electrical engineering places','class','U101',True),
('MA04C-C','Electrical machinery manufacturing place','class','U101',True),
('MA04C-D','Electronic computers manufacturing place','class','U101',True),
('MA04','Engineering places','class','U101',True),
('MA03A -B','Explosives and fireworks manufacturing place','class','U101',True),
('MA03A -C','Fertilizer manufacturing place','class','U101',True),
('MA02A','Food, drink and tobacco manufacturing place','class','U101',True),
('MA05C-B','Footwear manufacturing place','class','U101',True),
('MA02A -G','Fruit and vegetable product manufacturing place','class','U101',True),
('MA05B -A','Fur goods manufacturing place','class','U101',True),
('MA07A -B','Furniture and upholstery manufacturing place','class','U101',True),
('MA03A -D','General chemical manufacturing place','class','U101',True),
('MA06A -D','Glass manufacturing place','class','U101',True),
('MA02A -H','Grain mill','class','U101',True),
('MA05C-C','Hats, cap and millinery manufacturing place','class','U101',True),
('MA05A -B','Hosiery and other knitted goods manufacturing place','class','U101',True),
('MA10A -A','Industrial research laboratory','class','U101',True),
('MA04A -C','Industrial services equipment manufacturing place','class','U101',True),
('MA04B','Instrument engineering places','class','U101',True),
('MA04C-E','Insulated wires and cables manufacturing place','class','U101',True),
('MA01B -C','Iron and steel manufacturing place','class','U101',True),
('MA04F -C','Jewellery and precious metal manufacturing place','class','U101',True),
('MA05A -C','Jute manufacturing place','class','U101',True),
('MA05A -B','Knitted goods manufacturing place','class','U101',True),
('MA05A -D','Lace manufacturing place','class','U101',True),
('MA01B -D','Lead manufacturing place','class','U101',True),
('MA05B -C','Leather (tanning and dressing) and fellmongery place','class','U101',True),
('MA05B','Leather and fur goods manufacturing places','class','U101',True),
('MA05B -B','Leather goods manufacturing place','class','U101',True),
('MA08A -B','Linoleum, plastics floor covering, leather cloth manufacturing place','class','U101',True),
('MA04E -B','Locomotives and railway track equipment manufacturing place','class','U101',True),
('MA01A -B','Lubricating oil and grease manufacturing place','class','U101',True),
('MA05A -E','Made up textile manufacturing place','class','U101',True),
('MA05A -F','Man made fibre production manufacturing place','class','U101',True),
('MA10A','Manufacturing research establishments','class','U101',True),
('MA12A -A','Manufacturing storage place','class','U101',True),
('MA12A','Manufacturing storage places','class','U101',True),
('MA11A','Manufacturing waste disposal places','class','U101',True),
('MA11A -A','Manufacturing waste tip','class','U101',True),
('MA04A','Mechanical engineering places','class','U101',True),
('MA04A -D','Mechanical handling equipment manufacturing place','class','U101',True),
('MA01B','Metal processing places (basic forms)','class','U101',True),
('MA04A -E','Metal working machine tools manufacturing place','class','U101',True),
('MA02A -I','Milk and milk product manufacturing place','class','U101',True),
('MA01A -C','Mineral oil refinery','class','U101',True),
('MA08A -C','Miscellaneous goods manufacturing place','class','U101',True),
('MA08A -D','Miscellaneous stationers goods manufacturing place','class','U101',True),
('MA07A -C','Miscellaneous wood and cork manufacturing place','class','U101',True),
('MA04E -C','Motor cycle, tricycle and pedal cycle manufacturing place','class','U101',True),
('MA04E -D','Motor vehicle manufacturing place','class','U101',True),
('MA05A -G','Narrow fabric manufacturing place','class','U101',True),
('MA04A -F','Office machinery manufacturing place','class','U101',True),
('MA04A -G','Ordnance and small arms manufacturing place','class','U101',True),
('MA05C-D','Overalls and men''s shirts and underwear manufacturing place','class','U101',True),
('MA07B -B','Packaging products of paper and associated materials manufacturing place','class','U101',True),
('MA03A -E','Paint manufacturing place','class','U101',True),
('MA07B -C','Paper and board manufacturing place','class','U101',True),
('MA07B','Paper, printing and publishing works','class','U101',True),
('MA07B -D','Periodical and newspaper printing and publishing works','class','U101',True),
('MA03A -F','Pharmaceutical chemicals and preparation manufacturing place','class','U101',True),
('MA04B -A','Photographic and document copying equipment manufacturing place','class','U101',True),
('MA06A -E','Pottery manufacturing place','class','U101',True),
('MA04A -H','Prime movers manufacturing place','class','U101',True),
('MA04A -I','Pumps, valves and compressor manufacturing place','class','U101',True),
('MA04C-F','Radio and electronic capital goods manufacturing place','class','U101',True),
('MA04C-G','Radio, radar and electronic capital goods manufacturing place','class','U101',True),
('MA04E -E','Railway carriages and wagons and trams manufacturing place','class','U101',True),
('MA05A -H','Rope, twine and net manufacturing place','class','U101',True),
('MA08A -E','Rubber goods manufacturing place','class','U101',True),
('MA04B -C','Scientific and industrial instruments and systems manufacturing','class','U101',True),
('MA04D-A','Shipbuilding and marine engineering place','class','U101',True),
('MA07A -D','Shop and office fittings manufacturing place','class','U101',True),
('MA04F -E','Small tools, implements and gauges manufacturing place','class','U101',True),
('MA03A -G','Soap, detergent and fat splitting and distillation manufacturing place','class','U101',True),
('MA02A -J','Soft drinks manufacturing place','class','U101',True),
('MA05A -I','Spinning and doubling (cotton and flax systems) manufacturing place','class','U101',True),
('MA07B -A','Stationery manufacturing place','class','U101',True),
('MA02A -K','Sugar refinery','class','U101',True),
('MA04B -B','Surgical instruments and appliances manufacturing place','class','U101',True),
('MA03A -H','Synthetic resins, plastics and synthetic rubber manufacturing place','class','U101',True),
('MA05C-E','Tailored outerwear manufacturing place','class','U101',True),
('MA04C-H','Telegraph and telephone apparatus and equipment manufacturing place','class','U101',True),
('MA05A -J','Textile finishing place','class','U101',True),
('MA04A -J','Textile machinery and accessories manufacturing place','class','U101',True),
('MA05A','Textile manufacturing places','class','U101',True),
('MA07A','Timber and furniture works','class','U101',True),
('MA07','Timber furniture, paper, printing and publishing works','class','U101',True),
('MA07A -E','Timber works','class','U101',True),
('MA02A -L','Tobacco manufacturing place','class','U101',True),
('MA03A -I','Toilet preparation manufacturing place','class','U101',True),
('MA08A -F','Toys, games, children''s carriages and sports equipment manufacturing place','class','U101',True),
('MA02A -M','Vegetable, animal oil and fat manufacturing place','class','U101',True),
('MA04E','Vehicle engineering places','class','U101',True),
('MA04B -D','Watches and clocks manufacturing place','class','U101',True),
('MA05C-F','Weatherproof outerwear manufacturing place','class','U101',True),
('MA05A -K','Weaving of cotton, linen and man made fibres manufacturing place','class','U101',True),
('MA04E -F','Wheeled tractor manufacturing place','class','U101',True),
('MA04F -F','Wire manufacturing place','class','U101',True),
('MA07A -F','Wooden containers and baskets manufacturing place','class','U101',True),
('MA05A -L','Woollen and worsted manufacturing place','class','U101',True),
('OF03A -A','Business discussion places','class','U102',True),
('OF03A','Business meeting places','class','U102',True),
('OF03','Business meeting places','class','U102',True),
('OF01A -A','Central government administration office','class','U102',True),
('OF01A','General offices','class','U102',True),
('OF01A -B','Local government administration office','class','U102',True),
('OF01A -C','Manufacturing administration office','class','U102',True),
('OF','Offices','class','U102',True),
('OF01A -D','Professional services office','class','U102',True),
('OF04A -A','Studio','class','U102',True),
('ST01A -A','Agricultural machinery store','class','U103',True),
('ST02A -A','Builders yard','class','U103',True),
('ST01A -B','Building equipment store','class','U103',True),
('ST02A','Bulk material stores','class','U103',True),
('ST02A -B','Cleaning materials store','class','U103',True),
('ST01A -C','Engineering equipment store','class','U103',True),
('ST01A','Equipment stores','class','U103',True),
('ST03A -A','Furniture depository','class','U103',True),
('ST03A -B','General goods store','class','U103',True),
('ST01A -D','Industrial and office machinery store','class','U103',True),
('ST02','Material stores','class','U103',True),
('ST03A -C','Refrigerated store','class','U103',True),
('ST01A -E','Sports equipment store','class','U103',True),
('ST','Storage','class','U103',True),
('WH01B -A','Agricultural machinery dealers place','class','U104',True),
('WH01A -C','Builders merchant''s place','class','U104',True),
('WH01A','Bulk dealing places','class','U104',True),
('WH01A -A','Coal and oil dealer''s place','class','U104',True),
('WH01A -B','Corn, seed and agricultural supplies dealer''s place','class','U104',True),
('WH01','Dealing in industrial materials, machinery and livestock places','class','U104',True),
('WH02A','Food and drink wholesaling places','class','U104',True),
('WH02A -A','Grocery and provisions confectionery and drinks wholesaling','class','U104',True),
('WH01B -B','Hides, skin and leather dealer''s place','class','U104',True),
('WH01C-A','Horses and livestock dealer','class','U104',True),
('WH01C','Horses and livestock dealing places','class','U104',True),
('WH01B','Industrial materials and other machinery dealing places','class','U104',True),
('WH02B -A','Petroleum products wholesaling place','class','U104',True),
('WH01B -C','Scrap and waste dealer','class','U104',True),
('WH01B -D','Timber dealer''s place','class','U104',True),
('WH02','Wholesale distribution places','class','U104',True),
('UL02A','Unused buildings','class','U111',True),
('UL01B','Unused formerly developed land','class','U111',False),
('UL01B -A','Cleared site','class','U111',True),
('UL01B -C','Protected land (unused)','class','U111',True),
('UL02A -B','Vacant building','class','U111',True),
('UL02A -A','Abandoned building','class','U112',True),
('UL01B -B','Mineral excavation or pit (dry)','class','U112',True),
('UL02A -A','Ruined building','class','U112',True),
('UL01B -D','Spoilt land','class','U112',True),
('UL01B -E','Waste heap or tip','class','U112',True),
('DF01','Defence establishments','class','U121',True),
('DF01A','Defence training places','class','U121',True),
('DF01A -A','Live firing military training area','class','U121',True),
('UL01A -A','Beach or sand dune','class','U131',False),
('UL01C-A','Canal (unused)','class','U131',False),
('UL01A -B','Cliff or natural outcrop','class','U131',False),
('UL01C-B','Dock (unused)','class','U131',False),
('UL01A -C','Grass land','class','U131',False),
('UL01A -D','Heath and moorland','class','U131',False),
('UL01C-C','Mineral excavation or pit (wet)','class','U131',False),
('UL01A -E','Peat, bog, freshwater marsh and swamp','class','U131',False),
('UL01C-D','Pond or lake','class','U131',False),
('UL01A -F','Salt marsh (unused)','class','U131',False),
('UL01','Unused land and water','class','U131',False),
('UL01A','Unused land in natural or semi natural state','class','U131',False),
('UL01C','Unused water','class','U131',False),
('UL01C-E','Water course','class','U131',False),
('UL01A -G','Woodland and scrub','class','U131',False),
('AG08B -A','Coniferous forest','class','U022',False),
('AG08B -D','Deciduous forest','class','U022',False),
('AG08B','Forestry places','class','U022',False),
('AG08B -E','Mixed forest','class','U022',False),
('AG08B -B','Coppice','class','U021',False),
('AG08B -C','Coppice with standards','class','U021',False),
('AG08B -F','Tree nursery','class','U021',False),
('MI01D-A','Aggregate and stone handling installation','class','U031',True),
('MI01A -A','Chalk working','class','U031',False),
('MI01A -B','China clay working','class','U031',False),
('MI01C-A','China clay waste tip and settlement lagoon','class','U031',False),
('MI01A -C','Clay and shale working','class','U031',False),
('MI01A -D','Coal mine working','class','U031',False),
('MI01D-B','Coal handling installation','class','U031',True),
('MI01C-B','Coal waste tip and settlement lagoon','class','U031',False),
('MI01B -A','Colliery headgear','class','U031',True),
('MI01A -E','Gypsum/Anhydrite working','class','U031',False),
('MI01A -F','Igneous rock working','class','U031',False),
('MI01D-C','Iron ore handling installation','class','U031',True),
('MI01A -G','Limestone working','class','U031',True),
('MI01','Mineral extraction places','class','U031',False),
('MI01D-E','Mineral fertiliser handling installation','class','U031',True),
('MI01D-D','Non ferrous ore handling installation','class','U031',True),
('MI01D-F','Oil and gas handling installation','class','U031',True),
('MI01B -B','Oil and gas well head','class','U031',True),
('MI01B -C','Salt and brine pumping installation','class','U031',True),
('MI01A -H','Sand and gravel working','class','U031',False),
('MI01A -I','Sandstone working','class','U031',False),
('MI01A -K','Silica and moulding sand working','class','U031',False),
('MI01A -L','Slate working','class','U031',False),
('MI01C-C','Slate waste tip','class','U031',False),
('MI01B','Surface installations for underground mineral workings','class','U031',True),
('MI01A','Surface mineral workings','class','U031',False),
('MI01A -M','Vein mineral working','class','U031',False),
('MI01C-D','Vein mineral waste tip and settlement lagoon','class','U031',False),
('MI01C','Waste disposal areas from mineral working and processing','class','U031',False),
('LE01','Amenity, amusement and show places','class','#N/A',True),
('LE','Recreation and leisure places','class','#N/A',True),
('LE01B -A','Ancient monument','class','U041',True),
('LE01A -A','Botanical garden','class','U041',False),
('LE01A -B','Country park','class','U041',False),
('LE01A -C','Gardens','class','U041',False),
('LE01B -B','Monument','class','U041',True),
('LE01A','Outdoor amenity places','class','U041',False),
('LE01A -D','Park','class','U041',False),
('LE01A -E','Picnic site','class','U041',False),
('LE01A -F','Recreational open space','class','U041',False),
('LE01A -G','View point','class','U041',False),
('LE01A -A','Zoological garden','class','U041',False),
('LE01C','Amusement places','class','U042',True),
('LE01C-A','Aquarium','class','U042',True),
('LE01C-C','Bingo club','class','U042',True),
('LE01D-A','Broadcasting, filming and sound recording studio','class','U042',True),
('LE01C-D','Children''s playground','class','U042',False),
('LE01D-B','Cinema','class','U042',True),
('LE01D-C','Circus','class','U042',True),
('LE01D-D','Concert arena','class','U042',True),
('LE01D-E','Countryside interpretation centre','class','U042',True),
('LE01C-E','Dance hall','class','U042',True),
('LE01D-F','Display arena','class','U042',True),
('LE01C-F','Fun fair','class','U042',True),
('LE01C-G','Gaming club','class','U042',True),
('LE01C-H','Night club','class','U042',True),
('LE01D','Show places','class','U042',True),
('LE01D-G','Theatre','class','U042',True),
('LE02C-A','Art gallery','class','U043',True),
('LE02C','Galleries','class','U043',True),
('LE02A -A','Lending library','class','U043',True),
('LE02A','Libraries','class','U043',True),
('LE02','Libraries, museums and galleries','class','U043',True),
('LE02B -A','Museum','class','U043',True),
('LE02A -B','Reference','class','U043',True),
('LE03 I','Animal training and competing places','class','U044',True),
('LE03G-A','Archery range','class','U044',True),
('LE03A -A','Association football ground','class','U044',True),
('LE03E','Athletic game courses','class','U044',True),
('LE03D','Athletic games arenas','class','U044',True),
('LE03D-A','Athletic ground','class','U044',True),
('LE03B -A','Badminton court','class','U044',True),
('LE03C','Ball game courses','class','U044',True),
('LE03B','Ball game greens and courts','class','U044',True),
('LE03A','Ball game pitches and grounds','class','U044',True),
('LE03A -B','Baseball ground','class','U044',True),
('LE04B -A','Boating facilities','class','U044',True),
('LE03E -A','Bobsleigh course','class','U044',True),
('LE03B -B','Bowling green','class','U044',False),
('LE04B -B','Canoeing water','class','U044',False),
('LE03F -A','Caving place','class','U044',True),
('LE03F','Climbing, rambling and caving places','class','U044',False),
('LE03D-B','Combative sports place','class','U044',True),
('LE03A -C','Cricket ground','class','U044',False),
('LE03B -C','Croquet lawn','class','U044',False),
('LE03 I -A','Cross country horse trial course','class','U044',False),
('LE03E -B','Cross country running course','class','U044',False),
('LE03H-A','Cycling circuit','class','U044',True),
('LE03 I -B','Dog racing track','class','U044',True),
('LE03 I -C','Dog trials area','class','U044',True),
('LE03C-B','Golf course','class','U044',False),
('LE03C-A','Golf driving range','class','U044',False),
('LE03D-C','Gymnasium','class','U044',True),
('LE03A -D','Hockey ground','class','U044',True),
('LE03 I -D','Horse racing course','class','U044',True),
('LE03 I -E','Horse show jumping, dressage and trotting arena','class','U044',False),
('LE03 I -F','Horse training area','class','U044',True),
('LE03J -A','Hunting place','class','U044',False),
('LE03J','Hunting and shooting places','class','U044',True),
('LE03A -E','Hurling or shinty grounds','class','U044',True),
('LE03D-D','Ice rink','class','U044',True),
('LE03A -F','Lacrosse ground','class','U044',True),
('LE03H-B','Land sailing area','class','U044',False),
('LE03','Land sport places','class','U044',False),
('LE03H','Land vehicle performance places','class','U044',False),
('LE03B -D','Miniature golf course','class','U044',True),
('LE03H-C','Motor vehicle racing track','class','U044',False),
('LE03A -G','Polo ground','class','U044',True),
('LE04B -C','Power craft water','class','U044',False),
('LE03F -B','Rambling and fell walking','class','U044',False),
('LE03E -C','Road running and walking course','class','U044',False),
('LE03F -C','Rock climbing','class','U044',True),
('LE04C-A','Rod/recreational fishing place','class','U044',False),
('LE03D-E','Roller skating rink','class','U044',True),
('LE04B -D','Rowing water','class','U044',False),
('LE03A -H','Rugby football ground','class','U044',False),
('LE04B -E','Sailing','class','U044',True),
('LE03J -B','Shooting and stalking area','class','U044',False),
('LE03E -D','Skiing and tobogganing run','class','U044',False),
('LE03G-B','Small arms range','class','U044',False),
('LE03B -E','Squash court','class','U044',True),
('LE04A','Swimming and bathing','class','U044',True),
('LE04A -A','Swimming baths','class','U044',True),
('LE03G','Target shooting places','class','U044',True),
('LE03B -G','Ten pin bowling alley','class','U044',True),
('LE03B -F','Tennis court','class','U044',True),
('LE04C','Water recreation places','class','U044',True),
('LE04B -F','Water skiing place','class','U044',False),
('LE04','Water sport places','class','U044',False),
('LE04B','Watercraft places','class','U044',True),
('LE05A -A','Camping site','class','U045',False),
('LE05A -B','Holiday camp site','class','U045',False),
('LE05A','Holiday camps','class','U045',True),
('LE05A -C','Holiday caravan site','class','U045',False),
('LE05A -D','Youth hostel','class','U045',True),
('AG06B -A','Allotment gardens','class','U046',False),
('TR02C','Storage places for vehicles','class','U051',True),
('TR','Transport tracks and places','class','U051',True),
('TR01E -A','Access road','class','U051',False),
('TR01E -B','All purpose road','class','U051',False),
('TR01F -A','Branch line','class','U051',False),
('TR01C-A','Bridleway','class','U051',False),
('TR01D-A','Bus only way','class','U051',False),
('TR01D-B','Bus way','class','U051',False),
('TR01B -A','Cycle track','class','U051',False),
('TR01C-B','Drovers way','class','U051',False),
('TR01A -A','Footpath','class','U051',False),
('TR01','Land transport tracks','class','U051',False),
('TR01F -B','Light railway','class','U051',False),
('TR01E -C','Local distributor road','class','U051',False),
('TR01F -C','Main line','class','U051',False),
('TR01F -D','Mineral line','class','U051',False),
('TR01E -E','Motor vehicle practice circuit','class','U051',False),
('TR01E -D','Motor vehicle testing circuit','class','U051',False),
('TR01E -F','Motorway (special road)','class','U051',False),
('TR01C-C','Pony trekking route','class','U051',False),
('TR01A -B','Precinct','class','U051',False),
('TR01E -G','Primary distributor road','class','U051',False),
('TR01E -H','Processional route (road)','class','U051',False),
('TR01A -C','Processional route (walking or marching)','class','U051',False),
('TR01F','Railways','class','U051',False),
('TR01C-D','Ride','class','U051',False),
('TR01E','Roads','class','U051',False),
('TR01E -I','Secondary distributor road','class','U051',False),
('TR01F -E','Tramway','class','U051',False),
('TR01F -F','Underground line','class','U051',False),
('TR01A -D','Walkway','class','U051',False),
('TR02A -A','Aerial ropeway passenger terminal','class','U051',True),
('TR02A -B','Air passenger terminal','class','U051',True),
('TR02A -C','Airport','class','U051',True),
('TR02A -D','Bus station','class','U051',True),
('TR02A -E','Bus stop','class','U051',True),
('TR02A -F','Car park','class','U051',True),
('TR02A -G','Coach station','class','U051',True),
('TR02','Land transport places','class','U051',True),
('TR02A -H','Railway station','class','U051',True),
('TR02A -I','Ship passenger terminal','class','U051',True),
('TR02A','Terminals and interchanges for people','class','U051',True),
('TR02C-C','Car storage place','class','U053',True),
('TR02C-A','Aircraft hangar','class','U051',True),
('TR02C-B','Bus depot','class','U051',True),
('TR02C-D','Coach depot','class','U051',True),
('TR02C-E','Long stay lorry park','class','U051',True),
('TR02C-F','Railway sidings','class','U051',False),
('TR05A -A','Aerial ropeway','class','U055',False),
('TR02B -A','Air freight terminal','class','U055',True),
('TR02B -B','Container depot','class','U055',True),
('TR05A -B','Conveyor','class','U055',False),
('TR06A -A','Customs depot','class','U055',True),
('TR02B -C','Docks','class','U055',True),
('TR06','Goods handling places','class','U055',True),
('TR05A -C','Lift','class','U055',True),
('TR02B -D','Lorry transhipment park','class','U055',False),
('TR05A','Mechanical handling places','class','U055',True),
('TR02B -E','Railway goods siding','class','U055',False),
('TR02B -F','Railway goods yard','class','U055',False),
('TR02B -G','Railway sorting depot','class','U055',True),
('TR02B','Terminals and interchanges for goods','class','U055',True),
('TR04A -A','Anchorage','class','U056',False),
('TR04A -B','Boatyard','class','U056',False),
('TR03A -A','Canal','class','U056',False),
('TR04A -C','Marina','class','U056',False),
('TR04A -D','Mooring','class','U056',False),
('TR03A -B','River','class','U056',False),
('TR04A','Storage places for water craft','class','U056',True),
('TR03A','Water tracks','class','U056',False),
('TR04','Water transport places','class','U056',False),
('TR03','Water transport tracks','class','U056',False),
('UT','Utility services','class','#N/A',False),
('UT06A','District heating places','class','U061',True),
('UT06A -A','District heating plant','class','U061',True),
('UT02B -A','Electricity cableway','class','U061',True),
('UT02B','Electricity distribution places','class','U061',True),
('UT02A','Electricity production places','class','U061',True),
('UT02','Electricity supply places','class','U061',True),
('UT02B -B','Electricity transformer station','class','U061',True),
('UT01B','Gas distribution places','class','U061',True),
('UT01A -A','Gas holder','class','U061',True),
('UT01B -A','Gas pressure control station','class','U061',True),
('UT01A','Gas production and storage places','class','U061',True),
('UT01','Gas supply places','class','U061',True),
('UT01A -B','Gas works','class','U061',True),
('UT02A -A','Hydro electricity generating station','class','U061',True),
('TR05B -A','Oil pumping station','class','U061',True),
('TR05B -B','Pipeline','class','U061',False),
('UT02A -B','Thermal electricity generating station','class','U061',True),
('UT04A -A','Main drain','class','U062',False),
('UT03A -A','Reservoir','class','U062',False),
('UT04','Sewage disposal places','class','U062',True),
('UT04A','Sewage draining places','class','U062',True),
('UT04B -A','Sewage farm','class','U062',True),
('UT04A -B','Sewage pumping station','class','U062',True),
('UT04B','Sewage treatment places','class','U062',True),
('UT04B -B','Sewage treatment works','class','U062',True),
('UT03B','Water distribution places','class','U062',True),
('UT03C','Water extraction places','class','U062',True),
('UT03C-B','Water intake from rivers or streams','class','U062',True),
('UT03C-A','Water intake from springs','class','U062',True),
('UT03C-C','Water intake from underground sources','class','U062',True),
('UT03B -B','Water pipeline','class','U062',True),
('UT03B -A','Water pumping station','class','U062',True),
('UT03A','Water storage and treatment places','class','U062',True),
('UT03','Water supply places','class','U062',True),
('UT03A -B','Water tower','class','U062',True),
('UT03A -C','Water treatment works','class','U062',True),
('UT05A','Refuse disposal places','class','U063',False),
('UT05','Refuse disposal places','class','U063',False),
('UT05A -A','Refuse disposal plant','class','U063',False),
('UT05A -B','Refuse tip','class','U063',False),
('UT07B -A','Cemetery','class','U064',False),
('UT07A -A','Chapel of rest','class','U064',True),
('UT07B -B','Crematorium','class','U064',True),
('UT07B','Dead bodies disposal places','class','U064',True),
('UT07A','Dead bodies storage places','class','U064',True),
('UT07A -B','Mortuary','class','U064',True),
('UT08F','Direction finding places','class','U065',True),
('UT08F -B','Direction finding transmitter','class','U065',True),
('UT08F -A','Navigational light beacon','class','U065',False),
('UT08A','Postal service places','class','U065',True),
('UT08','Postal service, signalling and telecommunications places','class','U065',True),
('UT08A -A','Postal sorting depot','class','U065',True),
('UT08C-A','Radar beacon','class','U065',False),
('UT08C','Radar places','class','U065',True),
('UT08C-B','Radar station','class','U065',True),
('UT08D-B','Radio and television mast','class','U065',False),
('UT08D-A','Radio station','class','U065',True),
('UT08E -A','Satellite communication station','class','U065',True),
('UT08F -C','Signalling station','class','U065',True),
('UT08B -A','Telephone cableway','class','U065',False),
('UT08B -B','Telephone exchange','class','U065',True),
('UT08B -C','Telephone kiosk','class','U065',True),
('UT08D','Television and radio broadcasting places','class','U065',True),
('UT08D-C','Television station','class','U065',True),
('RS','Residences','class','#N/A',False),
('RS02A -A','Building converted to more than one dwelling','class','U071',True),
('RS02A -B','Bungalow','class','U071',True),
('RS02A -C','Detached house','class','U071',True),
('RS02A','Dwellings','class','U071',True),
('RS02A -D','Maisonette','class','U071',True),
('RS01C-A','Movable dwelling site','class','U071',False),
('RS02A -E','Non residential plus single dwelling','class','U071',True),
('RS02A -F','Purpose built block of flats','class','U071',True),
('RS01C-B','Residential caravan site','class','U071',False),
('RS02','Self contained residences','class','U071',True),
('RS02A -G','Semi detached house','class','U071',True),
('RS02A -H','Terraced house','class','U071',True),
('RS01A -A','Boarding house','class','U072',True),
('RS01','Group residences','class','U072',True),
('RS01A -B','Hotel','class','U072',True),
('RS01A -C','Residential club','class','U072',True),
('RS01A -D','Rooming house','class','U072',True),
('RS01B -A','Barracks','class','U073',True),
('CM04A -A','Children''s home','class','U073',True),
('RS01B','Communal homes','class','U073',True),
('CM04A -B','Handicapped and disabled people''s home','class','U073',True),
('CM04','Non medical care places','class','U073',True),
('CM04A','Non medical homes','class','U073',True),
('CM04A -C','Old people''s home','class','U073',True),
('RS01B -B','Residential retreat','class','U073',True),
('RS01B -C','School boarding house','class','U073',True),
('RS01B -D','Staff hostel','class','U073',True),
('CM','Community and health services','class','#N/A',False),
('CM01B -A','Ambulance station','class','U081',True),
('CM01A -A','Ante natal and post natal clinic','class','U081',True),
('CM01C-A','Artificial limb and appliance hospital','class','U081',True),
('CM01B','Auxiliary service centres medical','class','U081',True),
('CM01B -B','Blood transfusion centre','class','U081',True),
('CM01D-A','Convalescent home','class','U081',True),
('CM04B -A','Counselling agency','class','U081',True),
('CM04B','Counselling places','class','U081',True),
('CM01C-B','Dental hospital','class','U081',True),
('CM01A -B','Dentist''s surgery and consulting room','class','U081',True),
('CM01A -C','Dispensary','class','U081',True),
('CM01A -D','Doctor''s surgery and consulting room','class','U081',True),
('CM01C-C','Ear, nose and throat hospital','class','U081',True),
('CM01C-D','Eye hospital','class','U081',True),
('CM01A -E','Eye clinic and optician''s surgery and consulting room','class','U081',True),
('CM01B -C','Family planning clinic','class','U081',True),
('CM01A -F','Foot clinic and chiropodist''s surgery and consulting room','class','U081',True),
('CM01B -D','Forensic medicine centre','class','U081',True),
('CM01C-E','General hospital','class','U081',True),
('CM01C-F','Geriatric hospital','class','U081',True),
('CM01','Health care places','class','U081',True),
('CM01A -G','Health centre','class','U081',True),
('CM01A -H','Hearing aid centre','class','U081',True),
('CM01C','Hospitals','class','U081',True),
('CM01C-G','Isolation hospital','class','U081',True),
('CM01C-H','Maternity hospital','class','U081',True),
('CM01B','Medical auxiliary service centres','class','U081',True),
('CM01A','Medical diagnosis and treatment centres','class','U081',True),
('CM02A','Medical research establishments','class','U081',True),
('CM02A -A','Medical research laboratory','class','U081',True),
('CM01C-I','Mental hospital','class','U081',True),
('CM01A -I','Mental clinic','class','U081',True),
('CM01A -J','Nervous disorders clinic','class','U081',True),
('CM01A -K','Occupational therapy and physiotherapy clinic','class','U081',True),
('CM01C-J','Orthopaedic hosdpital','class','U081',True),
('CM01A -L','Orthopaedic and rheumatic clinic','class','U081',True),
('CM01B -E','Radiography centre','class','U081',True),
('CM01A -M','Surgeon''s surgery and consulting room','class','U081',True),
('CM07A','Places of worship','class','U082',True),
('ED01F -A','Adult education centre','class','U083',True),
('ED02A -A','Archaeological site','class','U083',False),
('ED01F -B','College of further education','class','U083',True),
('ED01F -C','College of technology','class','U083',True),
('ED01A -A','Day nursery school','class','U083',True),
('ED01','Education places','class','U083',True),
('ED01B -A','Infant school','class','U083',True),
('ED01B -B','Junior school','class','U083',True),
('ED01C-A','Middle school','class','U083',True),
('ED02B -A','Nature reserve','class','U083',False),
('ED02B','Nature reserves and sanctuaries','class','U083',False),
('ED01A -B','Nursery school','class','U083',True),
('ED02A -B','Observatory','class','U083',True),
('ED01F -D','Polytechnic','class','U083',True),
('ED01A','Pre primary schools','class','U083',True),
('ED01B','Primary schools','class','U083',True),
('ED02A','Research establishments','class','U083',True),
('ED02','Research places','class','U083',True),
('ED01D-A','Secondary school','class','U083',True),
('ED01D','Secondary schools','class','U083',True),
('ED02B -B','Site of special scientific interest','class','U083',False),
('ED01D-B','Sixth form college','class','U083',True),
('ED01E -A','Special school','class','U083',True),
('ED01F','Specialised, higher and further education centres','class','U083',True),
('ED01F -E','Teacher training college','class','U083',True),
('ED01F -F','Technical college','class','U083',True),
('ED01F -G','University teaching establishment','class','U083',True),
('CM06A -A','Advertising hoarding','class','U084',False),
('CM06A','Advertising places','class','U084',False),
('CM05A -A','Approved school','class','U084',True),
('CM08A -A','Arbitration court','class','U084',True),
('CM05A -B','Borstal institution','class','U084',True),
('CM06B -A','Church hall','class','U084',True),
('CM05B -A','Civil Defence centre','class','U084',True),
('CM06B -B','Club meeting place','class','U084',True),
('CM05B -B','Coastguard station','class','U084',True),
('CM06','Communication places','class','U084',True),
('CM06B -C','Community centre','class','U084',True),
('CM05','Community protection services','class','U084',True),
('CM08A','Courts','class','U084',True),
('CM05A','Detention places','class','U084',True),
('CM05B -C','Fire station','class','U084',True),
('CM08','Justice administration places','class','U084',True),
('CM08A -B','Law court','class','U084',True),
('CM05B -E','Life boat station','class','U084',True),
('CM07A -A','Place of worship','class','U084',True),
('CM05B -D','Police station','class','U084',True),
('CM05A -C','Prison','class','U084',True),
('CM05A -D','Prison rehabilitation centre','class','U084',True),
('CM05B','Protection places','class','U084',True),
('CM03A -A','Public bath','class','U084',True),
('CM03A -B','Public convenience','class','U084',True),
('CM05A -E','Remand centre','class','U084',True),
('CM05A -F','Remand classifying centre','class','U084',True),
('CM05A -G','Remand home','class','U084',True),
('CM03','Sanitation places','class','U084',True),
('CM03A','Sanitation places','class','U084',True),
('CM06B','Social meeting places','class','U084',True),
('CM08A -C','Tribunal place','class','U084',True),
('RT03','Catering service places','class','U093',True),
('RT01','Retail distribution places','class','U091',True),
('RT','Retail distribution and servicing places','class','U091',True),
('RT01A -A','Bakers shop','class','U091',True),
('RT01B -A','Beauty salon','class','U091',True),
('RT02B -A','Boot and shoe repair establishment','class','U091',True),
('RT01A -B','Butcher''s shop','class','U091',True),
('RT01D-A','Caravan sales place','class','U091',True),
('RT01F -A','Cash and carry store','class','U091',True),
('RT01A -C','Cats meat shop','class','U091',True),
('RT01B -C','Clothing and footwear shop','class','U091',True),
('RT01B -B','Confectionery, tobacco and newspaper shop','class','U091',True),
('RT01A -D','Dairy shop','class','U091',True),
('RT01F -B','Department store','class','U091',True),
('RT02B -B','Dry cleaning and clothing repair establishment','class','U091',True),
('RT01B -D','Duplicating and copying centre','class','U091',True),
('RT01C-A','Electricity showroom','class','U091',True),
('RT01A -E','Fish shop','class','U091',True),
('RT01A','Food and drink shops','class','U091',True),
('RT01A -F','Fried fish shop','class','U091',True),
('RT01A -G','Frozen food shop','class','U091',True),
('RT01C-B','Gas showroom','class','U091',True),
('RT01B -E','General stores','class','U091',True),
('RT01A -H','Green grocer''s shop','class','U091',True),
('RT01A -I','Grocery and provision','class','U091',True),
('RT01B -F','Hairdresser''s shop','class','U091',True),
('RT01A -J','Hot food shop','class','U091',True),
('RT01C-C','Household goods shop','class','U091',True),
('RT01C','Household goods shops and showrooms','class','U091',True),
('RT01F','Hybrid shops and stores','class','U091',True),
('RT01F -C','Hypermarket','class','U091',True),
('RT02B -C','Launderette','class','U091',True),
('RT02B -D','Laundry (cleaning only)','class','U091',True),
('RT01B -G','Laundry, cleaning and repairing shop (receiving)','class','U091',True),
('RT02','Maintenance and repair places','class','U091',True),
('RT01D-C','Motor vehicle dealer display area','class','U091',True),
('RT01D','Motor vehicle goods shops and filling stations','class','U091',True),
('RT02A','Motor vehicle maintenance and repair places','class','U091',True),
('RT02A -A','Motor vehicle repair garage','class','U091',True),
('RT01D-D','Motor vehicle sales','class','U091',True),
('RT01D-B','Motor vehicle spare parts and accessories','class','U091',True),
('RT02A -B','Motor vehicle testing station','class','U091',True),
('RT01A -K','Off licence','class','U091',True),
('RT02B','Personal and household goods repair and cleaning places','class','U091',True),
('RT01B -H','Pet animal and bird shop','class','U091',True),
('RT01D-E','Petrol and oil filling station','class','U091',True),
('RT01B -I','Photographic service shop','class','U091',True),
('RT01B -J','Post office','class','U091',True),
('RT01E -A','Retail market place','class','U091',True),
('RT01F -D','Supermarket','class','U091',True),
('RT01B -K','Ticket agency','class','U091',True),
('RT01B -L','Travel agency','class','U091',True),
('RT01A -L','Tripe shop','class','U091',True),
('RT01D-F','Tyre retailing and fitting place','class','U091',True),
('RT01B -M','Undertaker','class','U091',True),
('OF02A -A','Bank','class','U092',True),
('LE01C-B','Betting office','class','U092',True),
('OF02A -B','Building society office','class','U092',True),
('OF02A','Financial service offices','class','U092',True),
('OF02A -C','Insurance office','class','U092',True),
('RT03B','Catering places','class','U093',True),
('RT03B -A','Restaurant','class','U093',True),
('RT03A -A','Public house','class','U094',True);

View File

@ -0,0 +1,30 @@
--Landuse is hierachical. Highest level is Order (ie. Residential) then Group (ie Residential-Dwelling) then Class (ie Residential-Dwelling-Detached house)
--Interface will collected most detailed (class) but visualise highest level (order)
--Landuse is a table as #358
--Land use class, group and order will be stored in a new table
DROP TABLE IF EXISTS reference_tables.buildings_landuse_order CASCADE;
DROP TABLE IF EXISTS reference_tables.buildings_landuse_group CASCADE;
DROP TABLE IF EXISTS reference_tables.buildings_landuse_class CASCADE;
-- Land use class or classes, array object, client constrained.
ALTER TABLE buildings DROP COLUMN IF EXISTS current_landuse_class;
ALTER TABLE buildings DROP COLUMN IF EXISTS current_landuse_group;
-- Land use order, singular. Client and db constrained.
ALTER TABLE buildings DROP COLUMN IF EXISTS current_landuse_order;
--===========================================
--
-- We also collect original landuse, structure & process is as current land use
-- We don't currently collect intermediate historic uses
--
--===========================================
-- Original Land use class or classes, array object, client constrained.
ALTER TABLE buildings DROP COLUMN IF EXISTS original_landuse_class;
-- Land use order, singular. Client and db constrained.
ALTER TABLE buildings DROP COLUMN IF EXISTS original_landuse_order;

View File

@ -0,0 +1,58 @@
-- Create land use and fields
--Landuse is hierachical. Highest level is Order (Residential) then Group (Residential-Dwelling) then Class (Residential-Dwelling-Detached house)
--Some ETL work required to get this together refer to analysis repo
--Prerequesite is to have first run bulk_data_sources migrations
--Then create table landuse_order for the app, this is used as foreign key for current and original landuse_order
CREATE TABLE IF NOT EXISTS reference_tables.buildings_landuse_order AS
SELECT a.landuse_id,
a.description
FROM reference_tables.landuse_classifications a
WHERE a.level = 'order'
AND a.is_used;
ALTER TABLE reference_tables.buildings_landuse_order
ADD UNIQUE (description);
CREATE TABLE IF NOT EXISTS reference_tables.buildings_landuse_group AS
SELECT a.landuse_id,
a.description,
a.parent_id AS parent_order_id
FROM reference_tables.landuse_classifications a
WHERE a.level = 'group'
AND a.is_used;
ALTER TABLE reference_tables.buildings_landuse_group
ADD UNIQUE (description);
--the below is for front end reference, not current used as a constraint
CREATE TABLE IF NOT EXISTS reference_tables.buildings_landuse_class AS
SELECT a.landuse_id,
a.description,
a.parent_id AS parent_group_id
FROM reference_tables.landuse_classifications a
WHERE a.level = 'class'
AND a.is_used;
--Landuse is hierachical. Highest level is Order (Residential) then Group (Residential-Dwelling) then Class (Residential-Dwelling-Detached house)
--Interface will collected most detailed (class) but visualise highest level (order)
--Landuse is a table as #358
--Prerequisite run bulk_sources migration first
-- Land use is table with 3 levels of hierachy (highest to lowest). order > group > class
-- Land use order, singular. Client and db constrained with foreign key
ALTER TABLE buildings
ADD COLUMN IF NOT EXISTS current_landuse_order text,
ADD FOREIGN KEY (current_landuse_order)
REFERENCES reference_tables.buildings_landuse_order (description);
-- Land use groups, array. Derived from classes.
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS current_landuse_group text ARRAY[41];
-- Land use class or classes, array object, client constrained. ARRAY[] is used to constrain array size. The array is limited to 250 based on Westfield Stratford as a single toid with many uses, this may want to be reduced down to reduce maximum size.
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS current_landuse_class text ARRAY[250];

View File

@ -40,7 +40,7 @@ apt-get install -y \
#
# node version and platform
NODE_VERSION=v8.11.3
NODE_VERSION=v12.14.1
DISTRO=linux-x64
# download