Merge branch 'master' into night_mode

This commit is contained in:
Mateusz Konieczny 2021-12-03 20:37:03 +01:00
commit 9bae9a0b63
68 changed files with 1759 additions and 898 deletions

14
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,14 @@
## Branch naming
Branch names should indicate type of work. For fixes also related issue should be mentioned.
The format is `fix/123-short-description`, `feature/description` or `feature/123-short-description`.
For example
* [PR 648](https://github.com/colouring-london/colouring-london/pull/684) used branch `fix/681-land-use-edit` referencing [#681](https://github.com/colouring-london/colouring-london/issues/681)
* [PR 625](https://github.com/colouring-london/colouring-london/pull/625) used branch `feature/verification`
## Commits
Commit messages should start from an upper case. So `Change public ownership sources field to array` is preferred oved `change public ownership sources field to array`.

View File

@ -14,6 +14,7 @@ visualising twelve categories of information on Londons buildings.
## Structure
This repository will contain open-source code for the project which:
- stores building footprint polygons and source metadata
- allows site users to record building attribute data
- serves map tiles rendered from collected data
@ -25,8 +26,8 @@ for download under a liberal open data license
## Setup and run
1. Provision database (see `migrations`)
1. Load buildings and geometries to database (see `etl`)
1. Provision database (see [migrations](migrations/README.md))
1. Load buildings and geometries to database (see [etl](etl/README.md))
1. Install app dependencies: `cd app && npm i`
1. Run tests: `npm test`
1. Run app: `npm start`

View File

@ -446,6 +446,68 @@
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="community_local_significance_total">
<Rule>
<Filter>[community_local_significance_total] &gt;= 100</Filter>
<PolygonSymbolizer fill="#bd0026" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 50 and [community_local_significance_total] &lt; 100</Filter>
<PolygonSymbolizer fill="#e31a1c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 20 and [community_local_significance_total] &lt; 50</Filter>
<PolygonSymbolizer fill="#fc4e2a" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 10 and [community_local_significance_total] &lt; 20</Filter>
<PolygonSymbolizer fill="#fd8d3c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 3 and [community_local_significance_total] &lt; 10</Filter>
<PolygonSymbolizer fill="#feb24c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] = 2</Filter>
<PolygonSymbolizer fill="#fed976" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] = 1</Filter>
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
<Rule>
<MaxScaleDenominator>17061</MaxScaleDenominator>
<MinScaleDenominator>4264</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
</Rule>
<Rule>
<MaxScaleDenominator>4264</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="community_in_public_ownership">
<Rule>
<Filter>[in_public_ownership] = true</Filter>
<PolygonSymbolizer fill="#1166ff" />
</Rule>
<Rule>
<Filter>[in_public_ownership] = false</Filter>
<PolygonSymbolizer fill="#ffaaa0" />
</Rule>
<Rule>
<MaxScaleDenominator>17061</MaxScaleDenominator>
<MinScaleDenominator>4264</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
</Rule>
<Rule>
<MaxScaleDenominator>4264</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>

72
app/package-lock.json generated
View File

@ -2031,6 +2031,12 @@
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
"dev": true
},
"@types/pg-format": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/pg-format/-/pg-format-1.0.2.tgz",
"integrity": "sha512-D3MEO6u3BObw3G4Xewjdx05MF5v/fiog78CedtrXe8BhONM8GvUz2dPfLWtI0BPRBoRd6anPHXe+sbrPReZouQ==",
"dev": true
},
"@types/prettier": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.3.tgz",
@ -11142,6 +11148,11 @@
"resolved": "https://registry.npmjs.org/mapnik-vector-tile/-/mapnik-vector-tile-3.0.1.tgz",
"integrity": "sha512-w3/mKA8RUODR4qeVUyU8MhSqeuHqCLhCPdekqb+FBo+SRPXgxYOvffQBcu8mhEu6EK1n1Syrq26FcNcLfkYLEw=="
},
"markdown-to-jsx": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz",
"integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w=="
},
"mdn-data": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
@ -11607,9 +11618,9 @@
}
},
"node-abi": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.21.0.tgz",
"integrity": "sha512-smhrivuPqEM3H5LmnY3KU6HfYv0u4QklgAxfFyRNujKUzbUcYZ+Jc2EhukB9SRcD2VpqhxM7n/MIcp1Ua1/JMg==",
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.0.tgz",
"integrity": "sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg==",
"requires": {
"semver": "^5.4.1"
}
@ -11695,11 +11706,6 @@
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz",
"integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ=="
},
"noop-logger": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
"integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI="
},
"nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@ -12561,6 +12567,11 @@
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz",
"integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc="
},
"pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha1-J3NCNsKtP05QZJFaWTNOIAQKgo4="
},
"pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
@ -14222,9 +14233,9 @@
}
},
"prebuild-install": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.1.tgz",
"integrity": "sha512-M+cKwofFlHa5VpTWub7GLg5RLcunYIcLqtY5pKcls/u7xaAb8FrXZ520qY8rkpYy5xw90tYCyMO0MP5ggzR3Sw==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.3.tgz",
"integrity": "sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q==",
"requires": {
"detect-libc": "^1.0.3",
"expand-template": "^2.0.3",
@ -14233,7 +14244,6 @@
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^2.21.0",
"noop-logger": "^0.1.1",
"npmlog": "^4.0.1",
"pump": "^3.0.0",
"rc": "^1.2.7",
@ -15035,13 +15045,6 @@
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"dependencies": {
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
}
}
},
"react": {
@ -15970,14 +15973,14 @@
"dev": true
},
"sharp": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.1.tgz",
"integrity": "sha512-4mCGMEN4ntaVuFGwHx7FvkJQkIgbI+S+F9a3bI7ugdvKjPr4sF7/ibvlRKhJyzhoQi+ODM+XYY1de8xs7MHbfA==",
"version": "0.28.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.3.tgz",
"integrity": "sha512-21GEP45Rmr7q2qcmdnjDkNP04Ooh5v0laGS5FDpojOO84D1DJwUijLiSq8XNNM6e8aGXYtoYRh3sVNdm8NodMA==",
"requires": {
"color": "^3.1.3",
"detect-libc": "^1.0.3",
"node-addon-api": "^3.1.0",
"prebuild-install": "^6.1.1",
"node-addon-api": "^3.2.0",
"prebuild-install": "^6.1.2",
"semver": "^7.3.5",
"simple-get": "^3.1.0",
"tar-fs": "^2.1.1",
@ -15985,23 +15988,28 @@
},
"dependencies": {
"color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
"integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"requires": {
"color-convert": "^1.9.1",
"color-string": "^1.5.4"
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
}
},
"color-string": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz",
"integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
"integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
"requires": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node-addon-api": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",

View File

@ -30,8 +30,10 @@
"leaflet": "^1.7.1",
"lodash": "^4.17.21",
"mapnik": "^4.5.8",
"markdown-to-jsx": "^7.1.3",
"node-fs": "^0.1.7",
"nodemailer": "^6.4.11",
"pg-format": "^1.0.4",
"pg-promise": "^8.7.5",
"query-string": "^6.13.1",
"react": "^17.0.2",
@ -39,7 +41,7 @@
"react-leaflet": "^3.1.0",
"react-router-dom": "^5.2.0",
"serialize-javascript": "^5.0.1",
"sharp": "^0.28.1",
"sharp": "^0.28.3",
"use-throttle": "0.0.3"
},
"devDependencies": {
@ -53,6 +55,7 @@
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^12.12.53",
"@types/nodemailer": "^6.4.0",
"@types/pg-format": "^1.0.2",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"@types/react-router-dom": "^4.3.5",

View File

@ -0,0 +1,28 @@
import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
export type AggregationMethod = 'countTrue';
export interface AggregationConfig {
aggregateFieldName: keyof typeof buildingAttributesConfig;
aggregationMethod: AggregationMethod;
};
/**
* Configuration for building-user attribute aggregations.
* The config defines how attributes that are collected per building, per user are aggregated into per building attributes.
* An example is the building like mechanism:
*/
export const aggregationsConfig: { [key in keyof typeof buildingUserAttributesConfig]?: AggregationConfig[]} = {
community_like: [
{
aggregateFieldName: 'likes_total',
aggregationMethod: 'countTrue'
}
],
community_local_significance: [
{
aggregateFieldName: 'community_local_significance_total',
aggregationMethod: 'countTrue'
}
]
};

View File

@ -2,11 +2,26 @@ import { valueType } from '../../helpers';
/** Configuration for a single data field */
export interface DataFieldConfig {
/**
* Allow editing the field?
* Default: false
*/
perUser?: boolean;
/**
* Allow editing the field through the API?
*/
edit: boolean;
/**
* Should the editing of the field be allowed - but only when
* the change is a result of an edit of another field, from which this field is derived.
* Example: editing Land Use Group modifies Land Use Order, too, because LU Order is automatically derived from LU Group.
* But Land Use Order itself cannot be modified directly by users.
* Default: false
*/
derivedEdit?: boolean;
/**
* Allow verifying the field value?
* Default: false;
@ -32,7 +47,10 @@ export interface DataFieldConfig {
sqlCast?: 'json' | 'jsonb';
}
export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
export const buildingAttributesConfig = valueType<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
ref_toid: {
edit: false
},
ref_osm_id: {
edit: true,
},
@ -236,6 +254,7 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
},
current_landuse_order: {
edit: false,
derivedEdit: true,
verify: false,
},
@ -250,7 +269,54 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
asJson: true,
sqlCast: 'jsonb',
},
likes_total: {
edit: false,
derivedEdit: true,
verify: false
},
community_local_significance_total: {
edit: false,
derivedEdit: true,
verify: false
},
community_activities: {
edit: true,
verify: false
},
community_public_ownership: {
edit: true,
verify: true
},
community_public_ownership_sources: {
edit: true,
verify: false
}
});
export type Building = { [k in keyof typeof dataFieldsConfig]: any };
export type BuildingUpdate = Partial<Building>;
export const buildingUserAttributesConfig = valueType<DataFieldConfig>()({
community_like: {
perUser: true,
edit: true,
verify: false,
},
community_type_worth_keeping: {
perUser: true,
edit: true,
verify: false
},
community_type_worth_keeping_reasons: {
perUser: true,
edit: true,
verify: false
},
community_local_significance: {
perUser: true,
edit: true,
verify: false
}
});
export const allAttributesConfig = Object.assign({}, buildingAttributesConfig, buildingUserAttributesConfig);

View File

@ -1,8 +1,8 @@
import { JSONSchemaType } from 'ajv';
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
import { dataFieldsConfig } from './dataFields';
import { allAttributesConfig } from './dataFields';
export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
export const fieldSchemaConfig: { [key in keyof typeof allAttributesConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
demolished_buildings: {
type: 'array',
@ -63,4 +63,32 @@ export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJ
links: string[];
}[]>,
community_type_worth_keeping_reasons: {
type: 'object',
properties: {
external_design: {
type: 'boolean',
nullable: true
},
internal_design: {
type: 'boolean',
nullable: true
},
adaptable: {
type: 'boolean',
nullable: true
},
other: {
type: 'boolean',
nullable: true
}
},
additionalProperties: false
} as JSONSchemaType<{
external_design: boolean,
internal_design: boolean,
adaptable: boolean,
other: boolean
}>
} as const;

View File

@ -2,6 +2,7 @@ import express from 'express';
import { ApiUserError } from '../errors/api';
import { UserError } from '../errors/general';
import { parseBooleanExact } from '../../helpers';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building/base';
@ -39,8 +40,18 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const returnUserAttributes = parseBooleanExact(String(req.query.user_attributes));
let userDataOptions = null;
if(returnUserAttributes) {
if(!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
userDataOptions = { userId: req.session.user_id, userAttributes: true};
}
try {
const result = await buildingService.getBuildingById(buildingId);
const result = await buildingService.getBuildingById(buildingId, { userDataOptions });
res.send(result);
} catch(error) {
console.error(error);
@ -50,44 +61,43 @@ const getBuildingById = asyncController(async (req: express.Request, res: expres
// POST building attribute updates
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
let user_id;
if (req.session.user_id) {
user_id = req.session.user_id;
} else if (req.query.api_key) {
try {
const user = await userService.authAPIUser(String(req.query.api_key));
user_id = user.user_id;
} catch(err) {
console.error(err);
res.send({ error: 'Must be logged in' });
}
} else {
res.send({ error: 'Must be logged in' });
let userId: string;
try {
userId = req.session.user_id ?? (
req.query.api_key && await userService.authAPIUser(String(req.query.api_key))
);
} catch(error) {
console.error(error);
}
if (user_id) {
await updateBuilding(req, res, user_id);
if(!userId) {
return res.send({ error: 'Must be logged in' });
}
});
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const buildingUpdate = req.body;
const {
attributes = null,
user_attributes: userAttributes = null
} = req.body;
let updatedBuilding: object;
try {
updatedBuilding = await buildingService.editBuilding(buildingId, buildingUpdate, userId);
const resultUpdate = await buildingService.editBuilding(buildingId, userId, {attributes, userAttributes});
res.send({
attributes: resultUpdate.attributes,
user_attributes: resultUpdate.userAttributes,
revision_id: resultUpdate.revisionId
});
} catch(error) {
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
});
res.send(updatedBuilding);
}
// GET building UPRNs
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
@ -106,21 +116,19 @@ const getBuildingUPRNsById = asyncController(async (req: express.Request, res: e
}
});
// GET whether the user likes a building
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ like: false }); // not logged in, so cannot have liked
const getBuildingUserAttributesById = asyncController(async (req: express.Request, res: express.Response) => {
if(!req.session.user_id) {
return res.send({ error: 'Must be logged in'});
}
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
const userAttributes = await buildingService.getBuildingUserAttributesById(buildingId, req.session.user_id);
// any value returned means like
res.send({ like: like });
res.send(userAttributes);
} catch(error) {
res.send({ error: 'Database error' });
res.send({ error: 'Database error'});
}
});
@ -137,31 +145,6 @@ const getBuildingEditHistoryById = asyncController(async (req: express.Request,
}
});
// POST update to like/unlike building
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body;
let updatedBuilding: object;
try {
updatedBuilding = like ?
await buildingService.likeBuilding(buildingId, req.session.user_id) :
await buildingService.unlikeBuilding(buildingId, req.session.user_id);
} catch(error) {
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
res.send(updatedBuilding);
});
// GET building attributes (and values) as verified by user
const getUserVerifiedAttributes = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
@ -219,10 +202,9 @@ export default {
getBuildingById,
updateBuildingById,
getBuildingUPRNsById,
getBuildingLikeById,
updateBuildingLikeById,
getUserVerifiedAttributes,
verifyBuildingAttributes,
getBuildingEditHistoryById,
getLatestRevisionId
getLatestRevisionId,
getBuildingUserAttributesById
};

View File

@ -0,0 +1,26 @@
import { ITask } from 'pg-promise';
import format from 'pg-format';
import db from '../../db';
import { DatabaseError } from '../errors/general';
import { AggregationMethod } from '../config/aggregationsConfig';
export type AggregationMethodFunction = (buildingId: number, attributeName: string, t?: ITask<any>) => Promise<number>;
export async function aggregateCountTrue(buildingId: number, attributeName: string, t?: ITask<any>): Promise<number> {
try {
// use pg-format here instead of pg-promise parameterised queries as they don't support column name from parameter
// assume that there won't be more likes than Postgres int range and cast to int
// otherwise the count is returned as a bigint which has less support in node-postgres
const query = format(`SELECT count(*)::int as agg FROM building_user_attributes WHERE building_id = %L::int AND %I = true;`, buildingId, attributeName);
const { agg } = await (t || db).one(query);
return agg;
} catch(error) {
throw new DatabaseError(error);
}
}
export const aggregationMethods: Record<AggregationMethod, AggregationMethodFunction> = {
'countTrue': aggregateCountTrue
};

View File

@ -1,15 +1,17 @@
import { errors, ITask } from 'pg-promise';
import _ from 'lodash';
import db from '../../db';
import { dataFieldsConfig } from '../config/dataFields';
import { allAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
import { BaseBuilding, BuildingAttributes, BuildingUserAttributes } from '../models/building';
import { ArgumentError, DatabaseError } from '../errors/general';
export async function getBuildingData(
buildingId: number,
lockForUpdate: boolean = false,
t?: ITask<any>
) {
): Promise<BaseBuilding> {
let buildingData;
try {
buildingData = await (t || db).one(
@ -55,7 +57,7 @@ export async function insertEditHistoryRevision(
const columnConfigLookup = Object.assign(
{},
...Object.entries(dataFieldsConfig).filter(([, config]) => config.edit).map(([key, {
...Object.entries(allAttributesConfig).filter(([, config]) => config.edit || config.derivedEdit).map(([key, {
asJson = false,
sqlCast
}]) => ({ [key]: {
@ -70,14 +72,14 @@ export async function updateBuildingData(
forwardPatch: object,
revisionId: string,
t?: ITask<any>
): Promise<object> {
): Promise<BuildingAttributes> {
const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]);
const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig);
console.log('Setting', buildingId, sets);
try {
return await (t || db).one(
const buildingRow: BaseBuilding = await (t || db).one(
`UPDATE
buildings
SET
@ -90,7 +92,132 @@ export async function updateBuildingData(
`,
[revisionId, sets, buildingId]
);
delete buildingRow.building_id;
delete buildingRow.geometry_id;
delete buildingRow.revision_id;
return buildingRow as BuildingAttributes;
} catch(error) {
throw new DatabaseError(error);
}
}
/**
* Function that ensures a building-user attribute record exists.
* The record is created if there isn't one yet.
*/
async function ensureBuildingUserRecord(buildingId: number, userId: string, t: ITask<any>) {
try {
await t.one(
`SELECT * FROM building_user_attributes WHERE building_id = $1 AND user_id = $2;`,
[buildingId, userId]
);
} catch(error) {
if(error.code === errors.queryResultErrorCode.noData) {
try {
await t.none(
'INSERT INTO building_user_attributes (building_id, user_id) VALUES ($1, $2);',
[buildingId, userId]
);
} catch(error) {
throw new DatabaseError(error);
}
} else {
throw new DatabaseError(error);
}
}
}
type BuildingUserAttributesRow = BuildingUserAttributes & {
building_id: number;
user_id: string;
}
export async function updateBuildingUserData(
buildingId: number,
userId: string,
forwardPatch: object,
t?: ITask<any>
) : Promise<BuildingUserAttributes> {
await ensureBuildingUserRecord(buildingId, userId, t);
const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]);
const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig);
try {
const buildingUserRow: BuildingUserAttributesRow = await (t || db).one(
`UPDATE
building_user_attributes
SET
$1:raw
WHERE
building_id = $2
AND user_id = $3
RETURNING
*
;`,
[sets, buildingId, userId]
);
delete buildingUserRow.building_id;
delete buildingUserRow.user_id;
return buildingUserRow;
} catch(error) {
throw new DatabaseError(error);
}
}
async function buildingExists(buildingId: number, t?: ITask<any>) {
return (
await (t || db).oneOrNone(
'SELECT building_id FROM buildings WHERE building_id = $1',
[buildingId]
)
) !== null;
}
function makeDefaultUserData() {
return _.mapValues(buildingUserAttributesConfig, () => null);
}
export async function getBuildingUserData(
buildingId: number,
userId: string,
lockForUpdate: boolean = false,
t?: ITask<any>
): Promise<BuildingUserAttributes> {
try {
const buildingUserRow = await (t || db).oneOrNone(
`SELECT
*
FROM building_user_attributes
WHERE
building_id = $1
AND user_id = $2
${lockForUpdate ? ' FOR UPDATE' : ''};
`,
[buildingId, userId]
);
if(buildingUserRow) {
delete buildingUserRow.building_id;
delete buildingUserRow.user_id;
return buildingUserRow;
} else {
if(await buildingExists(buildingId, t)) {
return makeDefaultUserData();
} else {
throw new ArgumentError(`Building ID ${buildingId} does not exist`, 'buildingId');
}
}
} catch(error) {
if(error instanceof ArgumentError) {
throw error;
}
throw new DatabaseError(error);
}
}

View File

@ -1,47 +0,0 @@
import { errors, ITask } from 'pg-promise';
import db from '../../db';
import { DatabaseError, InvalidOperationError } from '../errors/general';
export async function getBuildingLikeCount(buildingId: number, t?: ITask<any>): Promise<number> {
try {
const result = await (t || db).one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
);
return result.likes;
} catch(error) {
throw new DatabaseError(error);
}
}
export async function addBuildingUserLike(buildingId: number, userId: string, t?: ITask<any>): Promise<void> {
try {
return await (t || db).none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
);
} catch(error) {
if(error.detail?.includes('already exists')) {
throw new InvalidOperationError('User already likes this building');
}
throw new DatabaseError(error);
}
}
export async function removeBuildingUserLike(buildingId: number, userId: string, t?: ITask<any>): Promise<void> {
let result;
try {
result = await t.result(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
);
} catch(error) {
throw new DatabaseError(error);
}
if (result.rowCount === 0) {
throw new InvalidOperationError("User doesn't like the building, cannot unlike");
}
}

View File

@ -0,0 +1,13 @@
import { ITask } from 'pg-promise';
import db from '../../db';
// Create a transaction mode (serializable, read-write):
const serializableMode = new db.$config.pgp.txMode.TransactionMode({
tiLevel: db.$config.pgp.txMode.isolationLevel.serializable,
readOnly: false
});
export function startUpdateTransaction<T>(cb: (t: ITask<any>) => Promise<T>): Promise<T> {
return db.tx({mode: serializableMode}, cb);
}

View File

@ -21,6 +21,15 @@ export class InvalidOperationError extends UserError {
}
}
export class InvalidFieldError extends UserError {
public fieldName: string;
constructor(message?: string, fieldName?: string) {
super(message);
this.name = 'InvalidFieldError';
this.fieldName = fieldName;
}
}
export class FieldTypeError extends UserError {
public fieldName: string;
constructor(message?: string, fieldName?: string) {

View File

@ -0,0 +1,24 @@
import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
export type BuildingAttributes = { [k in keyof typeof buildingAttributesConfig]: any };
export type BuildingUserAttributes = { [k in keyof typeof buildingUserAttributesConfig]: any };
export interface BuildingUpdate {
attributes?: Partial<BuildingAttributes>;
userAttributes?: Partial<BuildingUserAttributes>;
revisionId?: string;
};
export interface BuildingIdentifiers {
building_id: number;
geometry_id: number;
revision_id: string;
}
export type BaseBuilding = BuildingIdentifiers & BuildingAttributes;
export interface Building extends BaseBuilding {
user_attributes: BuildingUserAttributes;
edit_history: any[];
verified: any;
}

View File

@ -26,10 +26,6 @@ router.route('/:building_id.json')
// GET building UPRNs
router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById);
// GET/POST like building
router.route('/:building_id/like.json')
.get(buildingController.getBuildingLikeById)
.post(buildingController.updateBuildingLikeById);
// POST verify building attribute
router.route('/:building_id/verify.json')

View File

@ -4,52 +4,56 @@
*/
import _ from 'lodash';
import { pickFields } from '../../../helpers';
import { dataFieldsConfig } from '../../config/dataFields';
import * as buildingDataAccess from '../../dataAccess/building';
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
import { validateBuildingUpdate } from '../domainLogic/validateBuildingUpdate';
import { Building, BuildingUserAttributes } from '../../models/building';
import { getBuildingEditHistory } from './history';
import { updateBuildingData } from './save';
import { getBuildingVerifications } from './verify';
// 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.
export async function getBuildingById(id: number) {
try {
const building = await buildingDataAccess.getBuildingData(id);
export interface BuildingMetadataOptions {
editHistory?: boolean;
verified?: boolean;
building.edit_history = await getBuildingEditHistory(id);
building.verified = await getBuildingVerifications(building);
return building;
} catch(error) {
console.error(error);
return undefined;
userDataOptions?: {
userId: string;
userAttributes?: boolean;
}
}
const FIELD_EDIT_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit).map(([key]) => key));
export async function getBuildingById(
buildingId: number,
{
editHistory = true,
verified = true,
userDataOptions
}: BuildingMetadataOptions = {}
) {
const baseBuilding = await buildingDataAccess.getBuildingData(buildingId);
const building: Partial<Building> = {...baseBuilding};
export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
return await updateBuildingData(buildingId, userId, async () => {
validateBuildingUpdate(buildingId, building);
const processedBuilding = await processBuildingUpdate(buildingId, building);
if(editHistory) {
building.edit_history = await getBuildingEditHistory(buildingId);
}
// remove read-only fields from consideration
delete processedBuilding.building_id;
delete processedBuilding.revision_id;
delete processedBuilding.geometry_id;
if(verified) {
building.verified = await getBuildingVerifications(baseBuilding);
}
// return whitelisted fields to update
return pickFields(processedBuilding, FIELD_EDIT_WHITELIST);
});
if(userDataOptions && userDataOptions.userAttributes) {
building.user_attributes = await getBuildingUserAttributesById(buildingId, userDataOptions.userId);
}
return building;
}
export async function getBuildingUserAttributesById(buildingId: number, userId: string): Promise<BuildingUserAttributes> {
return buildingDataAccess.getBuildingUserData(buildingId, userId);
}
export * from './edit';
export * from './history';
export * from './like';
export * from './query';
export * from './uprn';
export * from './verify';

View File

@ -0,0 +1,106 @@
import _ from 'lodash';
import { BuildingAttributes, BuildingUpdate, BuildingUserAttributes } from '../../models/building';
import * as buildingDataAccess from '../../dataAccess/building';
import { startUpdateTransaction } from '../../dataAccess/transaction';
import { UserError } from '../../errors/general';
import { aggregateUserAttributes } from '../domainLogic/aggregateUserAttributes';
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
import { validateChangeSet } from '../domainLogic/validateUpdate';
import { expireBuildingTileCache } from './tileCache';
export async function editBuilding(
buildingId: number,
userId: string,
{ attributes, userAttributes } : BuildingUpdate
): Promise<BuildingUpdate> {
// Validate externally provided attributes
if(attributes) {
validateChangeSet(attributes, true);
}
// Validate externally provided user attributes
if(userAttributes) {
validateChangeSet(userAttributes, true);
}
const finalUpdate = await startUpdateTransaction(async (t) => {
let {
attributes: processedAttributes,
userAttributes: processedUserAttributes
} = await processBuildingUpdate(buildingId, { attributes, userAttributes }, t);
let resultUserAttributes: BuildingUserAttributes = null;
if(!_.isEmpty(processedUserAttributes)) {
validateChangeSet(processedUserAttributes, false);
resultUserAttributes = await buildingDataAccess
.updateBuildingUserData(buildingId, userId, processedUserAttributes, t);
}
processedAttributes = await aggregateUserAttributes(
buildingId,
userId,
{
attributes: processedAttributes,
userAttributes: processedUserAttributes
},
t
);
let resultAttributes: BuildingAttributes = null;
let revisionId: string;
if(processedAttributes) {
const oldAttributes = await buildingDataAccess.getBuildingData(buildingId, true, t);
const [forwardPatch, reversePatch] = compare(oldAttributes, processedAttributes);
if(!_.isEmpty(forwardPatch)) {
revisionId = await buildingDataAccess
.insertEditHistoryRevision(buildingId, userId, forwardPatch, reversePatch, t);
resultAttributes = await buildingDataAccess
.updateBuildingData(buildingId, forwardPatch, revisionId, t);
} else {
revisionId = oldAttributes.revision_id;
}
}
if (resultAttributes == null && resultUserAttributes == null) {
throw new UserError('No change provided');
}
return {
revisionId: revisionId,
attributes: resultAttributes,
userAttributes: resultUserAttributes
};
});
expireBuildingTileCache(buildingId);
return finalUpdate;
}
/**
* Compare old and new data objects, generate shallow merge patch of changed fields
* - forward patch is object with {keys: new_values}
* - reverse patch is object with {keys: old_values}
*
* @param {object} oldObj
* @param {object} newObj
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (!_.isEqual(oldObj[key], value)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse];
}

View File

@ -1,49 +0,0 @@
import db from '../../../db';
import * as likeDataAccess from '../../dataAccess/like';
import { updateBuildingData } from './save';
export async function getBuildingLikeById(buildingId: number, userId: string) {
try {
const res = await db.oneOrNone(
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
[buildingId, userId]
);
return res && res.like;
} catch(error) {
console.error(error);
return undefined;
}
}
export async function likeBuilding(buildingId: number, userId: string) {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return {
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
};
},
(t) => {
return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
},
);
}
export async function unlikeBuilding(buildingId: number, userId: string) {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return {
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
};
},
async (t) => {
return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
},
);
}

View File

@ -1,81 +0,0 @@
import { ITask } from 'pg-promise';
import _ from 'lodash';
import db from '../../../db';
import * as buildingDataAccess from '../../dataAccess/building';
import { UserError } from '../../errors/general';
import { expireBuildingTileCache } from './tileCache';
const TransactionMode = db.$config.pgp.txMode.TransactionMode;
const isolationLevel = db.$config.pgp.txMode.isolationLevel;
// Create a transaction mode (serializable, read-write):
const serializable = new TransactionMode({
tiLevel: isolationLevel.serializable,
readOnly: false
});
/**
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
* All db hooks get passed a transaction.
* @param buildingId The ID of the building to update
* @param userId The ID of the user updating the data
* @param getUpdateValue Function returning the set of attribute to update for the building
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
*/
export async function updateBuildingData(
buildingId: number,
userId: string,
getUpdateValue: (t: ITask<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
): Promise<object> {
return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t);
}
const update = await getUpdateValue(t);
const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches);
const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) {
throw new UserError('No change provided');
}
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
expireBuildingTileCache(buildingId);
return updatedData;
});
}
/**
* Compare old and new data objects, generate shallow merge patch of changed fields
* - forward patch is object with {keys: new_values}
* - reverse patch is object with {keys: old_values}
*
* @param {object} oldObj
* @param {object} newObj
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (!_.isEqual(oldObj[key], value)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse];
}

View File

@ -1,9 +1,12 @@
import { dataFieldsConfig } from '../../config/dataFields';
import { buildingAttributesConfig } from '../../config/dataFields';
import { BaseBuilding } from '../../models/building';
import * as buildingDataAccess from '../../dataAccess/building';
import * as verifyDataAccess from '../../dataAccess/verify';
import { DatabaseError } from '../../errors/general';
const FIELD_VERIFICATION_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.verify === true).map(([key]) => key));
function canVerify(key: string) {
return buildingAttributesConfig[key].verify === true;
}
export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
// get current building attribute values for comparison
@ -14,7 +17,7 @@ export async function verifyBuildingAttributes(buildingId: number, userId: strin
// loop through attribute => value pairs to mark as verified
for (let [key, value] of Object.entries(patch)) {
// check key in whitelist
if(FIELD_VERIFICATION_WHITELIST.has(key)) {
if(canVerify(key)) {
// check value against current from database - JSON.stringify as hack for "any" data type
if (JSON.stringify(value) == JSON.stringify(building[key])) {
try {
@ -49,17 +52,14 @@ export async function getUserVerifiedAttributes(buildingId: number, userId: stri
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
}
export async function getBuildingVerifications(building) {
export async function getBuildingVerifications(building: BaseBuilding) {
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
const verified = {};
for (const element of FIELD_VERIFICATION_WHITELIST) {
verified[element] = 0;
}
const verified: Record<string, number> = {};
for (const item of verifications) {
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
verified[item.attribute] += 1
verified[item.attribute] = verified[item.attribute] ?? 0 + 1;
}
}
return verified;

View File

@ -0,0 +1,23 @@
import { ITask } from 'pg-promise';
import { BuildingAttributes, BuildingUpdate } from '../../models/building';
import { aggregationsConfig } from '../../config/aggregationsConfig';
import { aggregationMethods } from '../../dataAccess/aggregate';
export async function aggregateUserAttributes(
buildingId: number,
userId: string,
{ attributes, userAttributes } : BuildingUpdate,
t?: ITask<any>
): Promise<Partial<BuildingAttributes>> {
const derivedAttributes: Partial<BuildingAttributes> = {};
for(let [key, aggregations] of Object.entries(aggregationsConfig)) {
if(key in userAttributes) {
for(let config of aggregations) {
derivedAttributes[config.aggregateFieldName] = await aggregationMethods[config.aggregationMethod](buildingId, key, t);
}
}
}
return Object.assign({}, attributes, derivedAttributes);
}

View File

@ -1,7 +1,8 @@
import _ from 'lodash';
import { ITask } from 'pg-promise';
import { hasAnyOwnProperty } from '../../../helpers';
import { Building, BuildingUpdate } from '../../config/dataFields';
import { BaseBuilding, BuildingAttributes, BuildingUpdate } from '../../models/building';
import { getBuildingData } from '../../dataAccess/building';
import { ArgumentError } from '../../errors/general';
@ -10,7 +11,11 @@ import { updateLandUse } from './landUse';
/**
* Process land use classifications - derive land use order from land use groups
*/
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
async function processCurrentLandUseClassifications(
buildingId: number,
buildingUpdate: Partial<BuildingAttributes>,
t?: ITask<any>
): Promise<any> {
const currentBuildingData = await getBuildingData(buildingId);
try {
@ -39,10 +44,14 @@ async function processCurrentLandUseClassifications(buildingId: number, building
/**
* Process Dynamics data - check field relationships and sort demolished buildings by construction date
*/
async function processDynamicsDemolishedBuildings(buildingId: number, buildingUpdate: BuildingUpdate): Promise<BuildingUpdate> {
async function processDynamicsDemolishedBuildings(
buildingId: number,
attributesUpdate: Partial<BuildingAttributes>,
t?: ITask<any>
): Promise<Partial<BuildingAttributes>> {
const currentBuildingData = await getBuildingData(buildingId);
const afterUpdate: Building = Object.assign({}, currentBuildingData, buildingUpdate);
const afterUpdate: BaseBuilding = Object.assign({}, currentBuildingData, attributesUpdate);
const hasDemolished: boolean = afterUpdate.dynamics_has_demolished_buildings;
const demolishedList: any[] = afterUpdate.demolished_buildings;
@ -57,24 +66,27 @@ async function processDynamicsDemolishedBuildings(buildingId: number, buildingUp
}
}
if(buildingUpdate.demolished_buildings != undefined) {
buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
if(attributesUpdate.demolished_buildings != undefined) {
attributesUpdate.demolished_buildings = attributesUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
}
return buildingUpdate;
return attributesUpdate;
}
/**
* Define any custom processing logic for specific building attributes
*/
export async function processBuildingUpdate(buildingId: number, buildingUpdate: BuildingUpdate): Promise<any> {
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
export async function processBuildingUpdate(buildingId: number, {attributes, userAttributes}: BuildingUpdate, t?: ITask<any>): Promise<BuildingUpdate> {
if(hasAnyOwnProperty(attributes, ['current_landuse_group'])) {
attributes = await processCurrentLandUseClassifications(buildingId, attributes, t);
}
if(hasAnyOwnProperty(buildingUpdate, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
buildingUpdate = await processDynamicsDemolishedBuildings(buildingId, buildingUpdate);
if(hasAnyOwnProperty(attributes, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
attributes = await processDynamicsDemolishedBuildings(buildingId, attributes, t);
}
return buildingUpdate;
return {
attributes,
userAttributes
};
}

View File

@ -1,21 +0,0 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { mapObject } from '../../../helpers';
import { FieldTypeError } from '../../errors/general';
import { fieldSchemaConfig } from '../../config/fieldSchemaConfig';
const ajv = new Ajv();
addFormats(ajv);
const compiledSchemas = mapObject(fieldSchemaConfig, ([, val]) => ajv.compile(val))
export function validateBuildingUpdate(buildingId: number, building: any) {
for(const field of Object.keys(building)) {
if(field in compiledSchemas) {
if(!compiledSchemas[field](building[field])) {
throw new FieldTypeError('Invalid format of data sent', field);
}
}
}
}

View File

@ -0,0 +1,47 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import _ from 'lodash';
import { InvalidFieldError, FieldTypeError } from '../../errors/general';
import { allAttributesConfig } from '../../config/dataFields';
import { BuildingAttributes, BuildingUserAttributes } from '../../models/building';
import { fieldSchemaConfig } from '../../config/fieldSchemaConfig';
const ajv = new Ajv();
addFormats(ajv);
const compiledSchemas = _.mapValues(fieldSchemaConfig, (val) => ajv.compile(val));
function isDefined(key: string) {
return allAttributesConfig[key] !== undefined;
}
function canEdit(key: string, allowDerived: boolean = false) {
const config = allAttributesConfig[key];
return config.edit || (allowDerived && config.derivedEdit);
}
export function validateFieldChange(field: string, value: any, isExternal: boolean = true) {
if(!isDefined(field)) {
throw new InvalidFieldError('Field does not exist', field);
}
const allowDerived = !isExternal;
if(!canEdit(field, allowDerived)) {
throw new InvalidFieldError('Field is not editable', field);
}
if(field in compiledSchemas) {
if(!compiledSchemas[field](value)) {
throw new FieldTypeError('Invalid format of data sent', field);
}
}
}
export function validateChangeSet(
attributes: Partial<BuildingAttributes> | Partial<BuildingUserAttributes>,
isExternal: boolean = true
) {
_.forIn(attributes, (value, fieldKey) => validateFieldChange(fieldKey, value, isExternal));
}

View File

@ -142,9 +142,9 @@ async function getNewUserAPIKey(id: string) {
}
}
async function authAPIUser(key: string) {
async function authAPIUser(key: string): Promise<string> {
try {
return await db.one(
const { user_id } = await db.one(
`SELECT
user_id
FROM
@ -155,6 +155,8 @@ async function authAPIUser(key: string) {
key
]
);
return user_id;
} catch(error) {
console.error('Error:', error);
return undefined;

View File

@ -15,7 +15,6 @@ hydrate(
<App
user={data.user}
building={data.building}
building_like={data.building_like}
user_verified={data.user_verified}
revisionId={data.latestRevisionId}
/>

View File

@ -0,0 +1,48 @@
import { BuildingAttributes, BuildingEdits, BuildingUserAttributes } from '../models/building';
import { apiPost } from '../apiHelpers';
import { buildingUserFields, dataFields } from '../config/data-fields-config';
export type UpdatedBuilding = Partial<BuildingAttributes> & Partial<BuildingUserAttributes> & {
revision_id: string;
};
function makeUpdateData(edits: BuildingEdits) {
const data = {
attributes: {},
user_attributes: {}
};
for (let [field, value] of Object.entries(edits)) {
if (dataFields[field]) {
data.attributes[field] = value;
} else if (buildingUserFields[field]) {
data.user_attributes[field] = value;
}
}
return data;
}
export async function sendBuildingUpdate(buildingId: number, edits: BuildingEdits): Promise<UpdatedBuilding> {
const requestData = makeUpdateData(edits);
const data = await apiPost(
`/api/buildings/${buildingId}.json`,
requestData
);
if (data.error) {
throw data.error;
} else {
const {
revision_id,
attributes,
user_attributes
} = data;
return {
revision_id,
...attributes,
...user_attributes
};
}
}

View File

@ -3,7 +3,14 @@ import { useCallback, useEffect, useState } from 'react';
import { Building, BuildingAttributeVerificationCounts } from '../models/building';
import { apiGet } from '../apiHelpers';
export function useBuildingData(buildingId: number, preloadedData: Building): [Building, (updatedBuilding: Building) => void, () => void] {
/**
*
* @param buildingId Requested building ID
* @param preloadedData Data preloaded through SSR, to return before the request is first sent
* @param includeUserAttributes Should the building-user attributes be included in the result? This requires login session cookies to be present
* @returns
*/
export function useBuildingData(buildingId: number, preloadedData: Building, includeUserAttributes: boolean = false): [Building, (updatedBuilding: Building) => void, () => void] {
const [buildingData, setBuildingData] = useState<Building>(preloadedData);
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
@ -14,12 +21,14 @@ export function useBuildingData(buildingId: number, preloadedData: Building): [B
return;
}
try {
const [building, buildingUprns] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json`),
let [building, buildingUprns] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json${includeUserAttributes ? '?user_attributes=true' : ''}`),
apiGet(`/api/buildings/${buildingId}/uprns.json`)
]);
building.uprns = buildingUprns.uprns;
building = Object.assign(building, {...building.user_attributes});
delete building.user_attributes;
setBuildingData(building);
} catch(error) {

View File

@ -32,7 +32,6 @@ import { NotFound } from './pages/not-found';
interface AppProps {
user?: User;
building?: Building;
building_like?: boolean;
user_verified?: UserVerified;
revisionId: string;
}
@ -83,7 +82,6 @@ export const App: React.FC<AppProps> = props => {
<Route exact path={mapAppPaths} >
<MapApp
building={props.building}
building_like={props.building_like}
user_verified={props.user_verified}
revisionId={props.revisionId}
/>

View File

@ -11,10 +11,8 @@ interface BuildingViewProps {
cat: Category;
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
user_verified?: any;
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
}

View File

@ -1,3 +1,4 @@
import Markdown from 'markdown-to-jsx';
import React from 'react';
import Tooltip from '../../components/tooltip';
@ -34,7 +35,9 @@ const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (prop
<div className="data-title">
<div className="data-title-text">
<label htmlFor={`${props.slug}${props.slugModifier ?? ''}`}>
{ props.title }
<Markdown>
{ props.title }
</Markdown>
</label>
</div>
<div className="data-title-actions icon-buttons">

View File

@ -1,52 +0,0 @@
import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import Tooltip from '../../components/tooltip';
import { Category } from '../../config/categories-config';
interface LikeDataEntryProps {
mode: 'view' | 'edit' | 'multi-edit';
userLike: boolean;
totalLikes: number;
onLike: (userLike: boolean) => void;
}
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
const data_string = JSON.stringify({like: true});
return (
<Fragment>
<div className="data-title">
<Tooltip text="People who like the building and think it contributes to the city." />
<div className="icon-buttons">
<NavLink
to={`/multi-edit/${Category.Community}?data=${data_string}`}
className="icon-button like">
Like more
</NavLink>
</div>
<label>Number of likes</label>
</div>
<p>
{
(props.totalLikes != null)?
(props.totalLikes === 1)?
`${props.totalLikes} person likes this building`
: `${props.totalLikes} people like this building`
: "0 people like this building so far - you could be the first!"
}
</p>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name="like"
checked={!!props.userLike}
disabled={props.mode === 'view'}
onChange={e => props.onLike(e.target.checked)}
/>
I like this building and think it contributes to the city!
</label>
</Fragment>
);
};
export default LikeDataEntry;

View File

@ -44,6 +44,17 @@ const ToggleButton: React.FC<ToggleButtonProps> = ({
);
}
const ClearButton = ({
onClick,
disabled
}) => {
return <div className="btn-group btn-group-toggle">
<label>
<button type="button" className="btn btn-outline-warning" onClick={onClick} disabled={disabled}>Clear</button>
</label>
</div>
}
interface LogicalDataEntryProps extends BaseDataEntryProps {
value: boolean;
disallowTrue?: boolean;
@ -53,7 +64,11 @@ interface LogicalDataEntryProps extends BaseDataEntryProps {
export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
function handleValueChange(e: React.ChangeEvent<HTMLInputElement>) {
props.onChange?.(props.slug, e.target.value === 'null' ? null : e.target.value === 'true');
props.onChange?.(props.slug, e.target.value === 'true');
}
function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
props.onChange?.(props.slug, null);
}
const isDisabled = props.mode === 'view' || props.disabled;
@ -76,16 +91,6 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
uncheckedClassName='btn-outline-dark'
onChange={handleValueChange}
>Yes</ToggleButton>
<ToggleButton
value="null"
checked={props.value == null}
disabled={isDisabled || props.disallowNull}
checkedClassName='btn-secondary active'
uncheckedClassName='btn-outline-dark'
onChange={handleValueChange}
>?</ToggleButton>
<ToggleButton
value="false"
checked={props.value === false}
@ -95,6 +100,10 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
onChange={handleValueChange}
>No</ToggleButton>
</div>
{
!isDisabled && props.value != null &&
<ClearButton onClick={handleClear} disabled={props.disallowNull}/>
}
</>
);
};

View File

@ -0,0 +1,57 @@
import React, { ChangeEvent } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface MultiSelectOption {
key: string;
label: string;
}
interface MultiSelectDataEntryProps extends BaseDataEntryProps {
value: {[key: string]: boolean};
options: (MultiSelectOption)[];
showTitle?: boolean; // TODO make it an option for all input types
}
export const MultiSelectDataEntry: React.FunctionComponent<MultiSelectDataEntryProps> = (props) => {
const slugWithModifier = props.slug + (props.slugModifier ?? '');
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const changedKey = e.target.name;
const checked = e.target.checked;
const newVal = {...props.value, [changedKey]: checked || null};
props.onChange(slugWithModifier, newVal);
}
return (
<>
{props.showTitle !== false &&
<DataTitleCopyable
slug={props.slug}
slugModifier={props.slugModifier}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
}
{
props.options.map(o => (
<label>
<input
type="checkbox"
disabled={props.mode === 'view' || props.disabled}
name={o.key}
checked={props.value && props.value[o.key]}
onChange={handleChange}
/>
{o.label}
</label>
))
}
</>
);
};

View File

@ -0,0 +1,63 @@
import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import { AggregationDescriptionConfig, buildingUserFields, dataFields } from '../../config/data-fields-config';
import { CopyProps } from '../data-containers/category-view-props';
import { DataTitleCopyable } from './data-title';
interface UserOpinionEntryProps {
slug: string;
title: string;
mode: 'view' | 'edit' | 'multi-edit';
userValue: boolean;
aggregateValue: number;
aggregationDescriptions: AggregationDescriptionConfig;
copy: CopyProps;
onChange: (key: string, value: boolean) => void;
}
const UserOpinionEntry: React.FunctionComponent<UserOpinionEntryProps> = (props) => {
return (
<>
<DataTitleCopyable
slug={props.slug}
title={props.title}
copy={props.copy}
/>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name={props.slug}
checked={!!props.userValue}
disabled={props.mode === 'view'}
onChange={e => props.onChange(props.slug, e.target.checked)}
/> Yes
</label>
<div>
<label>
<span style={{fontStyle: 'italic'}}>
{
(props.aggregateValue)?
(props.aggregateValue === 1)?
`1 person `
: `${props.aggregateValue} people `
: `0 people so far `
}
</span>
<span>
{
(props.aggregateValue)?
(props.aggregateValue === 1)?
props.aggregationDescriptions.one
: props.aggregationDescriptions.many
: props.aggregationDescriptions.zero
}
</span>
</label>
</div>
</>
);
};
export default UserOpinionEntry;

View File

@ -1,12 +1,14 @@
import React, { Fragment } from 'react';
import { NavLink, Redirect } from 'react-router-dom';
import Confetti from 'canvas-confetti';
import _ from 'lodash';
import { apiPost } from '../apiHelpers';
import { sendBuildingUpdate } from '../api-data/building-update';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers';
import { Building, BuildingAttributes, UserVerified } from '../models/building';
import { Building, BuildingEdits, BuildingUserAttributes, UserVerified } from '../models/building';
import { User } from '../models/user';
import ContainerHeader from './container-header';
@ -26,10 +28,8 @@ interface DataContainerProps {
user?: User;
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
user_verified?: any;
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
}
@ -39,7 +39,7 @@ interface DataContainerState {
keys_to_copy: {[key: string]: boolean};
currentBuildingId: number;
currentBuildingRevisionId: number;
buildingEdits: Partial<Building>;
buildingEdits: BuildingEdits;
}
export type DataContainerType = React.ComponentType<DataContainerProps>;
@ -68,7 +68,6 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
this.handleChange = this.handleChange.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleVerify = this.handleVerify.bind(this);
this.handleSaveAdd = this.handleSaveAdd.bind(this);
@ -78,7 +77,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
static getDerivedStateFromProps(props, state) {
static getDerivedStateFromProps(props, state): DataContainerState {
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
@ -122,9 +121,8 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
}
isEdited() {
const edits = this.state.buildingEdits;
// check if the edits object has any fields
return Object.entries(edits).length !== 0;
return !_.isEmpty(this.state.buildingEdits);
}
clearEdits() {
@ -166,46 +164,15 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
this.clearEdits();
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {*} event
*/
async handleLike(like: boolean) {
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}/like.json`,
{like: like}
);
if (data.error) {
this.setState({error: data.error});
} else {
// like endpoint returns whole building data so we can update both
this.props.onBuildingUpdate(this.props.building.building_id, data);
this.props.onBuildingLikeUpdate(this.props.building.building_id, like);
}
} catch(err) {
this.setState({error: err});
}
}
async doSubmit(edits: Partial<BuildingAttributes>) {
async doSubmit(edits: Partial<Building & BuildingUserAttributes>) {
this.setState({error: undefined});
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}.json`,
edits
);
if (data.error) {
this.setState({error: data.error});
} else {
this.props.onBuildingUpdate(this.props.building.building_id, data);
}
} catch(err) {
this.setState({error: err});
const buildingUpdate = await sendBuildingUpdate(this.props.building.building_id, edits);
const updatedBuilding = Object.assign({}, this.props.building, buildingUpdate);
this.props.onBuildingUpdate(this.props.building.building_id, updatedBuilding);
} catch(error) {
this.setState({ error });
}
}
@ -351,12 +318,10 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
<WrappedComponent
intro={this.props.intro}
building={this.props.building}
building_like={this.props.building_like}
mode={this.props.mode}
edited={false}
copy={copy}
onChange={undefined}
onLike={undefined}
onVerify={undefined}
onSaveAdd={undefined}
onSaveChange={undefined}
@ -404,12 +369,10 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
<WrappedComponent
intro={this.props.intro}
building={currentBuilding}
building_like={this.props.building_like}
mode={this.props.mode}
edited={edited}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
onVerify={this.handleVerify}
onSaveAdd={this.handleSaveAdd}
onSaveChange={this.handleSaveChange}

View File

@ -1,4 +1,4 @@
import { Building } from '../../models/building';
import { Building, BuildingAttributes } from '../../models/building';
interface CopyProps {
copying: boolean;
@ -10,12 +10,10 @@ interface CopyProps {
interface CategoryViewProps {
intro: string;
building: Building;
building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit';
edited: boolean;
copy: CopyProps;
onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void;
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
/* Special handler for adding and immediately saving a new item of an array-like attribute */

View File

@ -0,0 +1,5 @@
.community-opinion-pane {
padding-top: 0.5em;
padding-bottom: 0.5em;
border-bottom: 1px solid gray;
}

View File

@ -1,41 +1,148 @@
import React, { Fragment } from 'react';
import React from 'react';
import withCopyEdit from '../data-container';
import LikeDataEntry from '../data-components/like-data-entry';
import UserOpinionEntry from '../data-components/user-opinion-data-entry';
import { MultiSelectDataEntry } from '../data-components/multi-select-data-entry';
import { CategoryViewProps } from './category-view-props';
import InfoBox from '../../components/info-box';
import { LogicalDataEntry } from '../data-components/logical-data-entry/logical-data-entry';
import { buildingUserFields, dataFields } from '../../config/data-fields-config';
import './community.css';
import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import { MultiDataEntry } from '../data-components/multi-data-entry/multi-data-entry';
/**
* Community view/edit section
*/
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<LikeDataEntry
userLike={props.building_like}
totalLikes={props.building.likes_total}
mode={props.mode}
onLike={props.onLike}
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
const worthKeepingReasonsNonEmpty = Object.values(props.building.community_type_worth_keeping_reasons ?? {}).some(x => x);
return <>
<InfoBox type='warning'>
We are testing a new feature in this section! Switch between different colour maps by using the dropdown in the legend pane.
</InfoBox>
<div className='community-opinion-pane'>
<InfoBox>
Can you share your opinion on how well the building works?
</InfoBox>
<UserOpinionEntry
slug='community_like'
title={buildingUserFields.community_like.title}
userValue={props.building.community_like}
aggregateValue={props.building.likes_total}
aggregationDescriptions={dataFields.likes_total.aggregationDescriptions}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
<LogicalDataEntry
slug='community_type_worth_keeping'
title={buildingUserFields.community_type_worth_keeping.title}
value={props.building.community_type_worth_keeping}
disallowFalse={worthKeepingReasonsNonEmpty}
disallowNull={worthKeepingReasonsNonEmpty}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
<li>Is this a publicly owned building?</li>
{
// "slug": "community_publicly_owned",
// "type": "checkbox"
props.building.community_type_worth_keeping !== false &&
<MultiSelectDataEntry
slug='community_type_worth_keeping_reasons'
title={buildingUserFields.community_type_worth_keeping_reasons.title}
value={props.building.community_type_worth_keeping_reasons}
disabled={!props.building.community_type_worth_keeping}
onChange={props.onSaveChange}
options={
Object.entries(buildingUserFields.community_type_worth_keeping_reasons.fields)
.map(([key, definition]) => ({
key,
label: definition.title
}))
}
mode={props.mode}
/>
}
<li>Has this building ever been used for community or public services activities?</li>
{
// "slug": "community_past_public",
// "type": "checkbox"
}
<li>Would you describe this building as a community asset?</li>
{
// "slug": "community_asset",
// "type": "checkbox"
}
</ul>
</Fragment>
);
<UserOpinionEntry
slug='community_local_significance'
title={buildingUserFields.community_local_significance.title}
userValue={props.building.community_local_significance}
aggregateValue={props.building.community_local_significance_total}
aggregationDescriptions={dataFields.community_local_significance_total.aggregationDescriptions}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
/>
</div>
<InfoBox>Can you help add information about public ownership of the building?</InfoBox>
<LogicalDataEntry
slug='community_activities'
title={dataFields.community_activities.title}
tooltip={dataFields.community_activities.tooltip}
value={props.building.community_activities}
onChange={props.onChange}
mode={props.mode}
/>
{/* TODO: dates */}
{
// props.building.community_activities === true &&
// <FieldRow>
// <div>
// </div>
// <div>
// </div>
// </FieldRow>
}
<SelectDataEntry
slug='community_public_ownership'
title={dataFields.community_public_ownership.title}
value={props.building.community_public_ownership}
options={[
'State-owned',
'Charity-owned',
'Community-owned/cooperative',
'Owned by other non-profit body',
'Not in public/community ownership',
]}
onChange={props.onChange}
mode={props.mode}
copy={props.copy}
/>
<Verification
slug="community_public_ownership"
allow_verify={props.user !== undefined && props.building.community_public_ownership !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("community_public_ownership")}
user_verified_as={props.user_verified.community_public_ownership}
verified_count={props.building.verified.community_public_ownership}
/>
<MultiDataEntry
slug='community_public_ownership_sources'
title={dataFields.community_public_ownership_sources.title}
isUrl={true}
placeholder={'https://...'}
editableEntries={true}
value={props.building.community_public_ownership_sources}
onChange={props.onChange}
mode={props.mode}
/>
</>
};
const CommunityContainer = withCopyEdit(CommunityView);
export default CommunityContainer;

View File

@ -4,10 +4,9 @@ import { Link } from 'react-router-dom';
import { useMultiEditData } from '../hooks/use-multi-edit-data';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { dataFields } from '../config/data-fields-config';
import { allFieldsConfig } from '../config/data-fields-config';
import DataEntry from './data-components/data-entry';
import { Category } from '../config/categories-config';
interface MultiEditProps {
category: string;
@ -16,34 +15,24 @@ interface MultiEditProps {
const MultiEdit: React.FC<MultiEditProps> = (props) => {
const [data, error] = useMultiEditData();
const isLike = props.category === Category.Community;
return (
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">{
isLike ?
<>Like Me!</> :
<>Copy {props.category} data</>
}</h2>
<h2 className="h2">Paste {props.category} data</h2>
</header>
<div className="section-body">
<form>
{
error ?
<ErrorBox msg={error} /> :
<InfoBox msg={
isLike ?
'Click all the buildings that you like and think contribute to the city!' :
'Click buildings one at a time to colour using the data below'
} />
<InfoBox msg='Click buildings one at a time to colour using the data below' />
}
{
!isLike && data &&
data &&
Object.keys(data).map((key => (
<DataEntry
key={key}
title={dataFields[key]?.title ?? `Unknown field (${key})`}
title={allFieldsConfig[key]?.title ?? `Unknown field (${key})`}
slug={key}
disabled={true}
value={data[key]}

View File

@ -2,15 +2,16 @@ import React from 'react';
interface InfoBoxProps {
msg?: string;
type?: 'info' | 'warning'
}
const InfoBox: React.FC<InfoBoxProps> = (props) => (
const InfoBox: React.FC<InfoBoxProps> = ({msg, children, type = 'info'}) => (
<>
{
(props.msg || props.children) &&
<div className="alert alert-info" role="alert">
{ props.msg ?? '' }
{ props.children }
(msg || children) &&
<div className={`alert alert-${type}`} role="alert">
{ msg ?? '' }
{ children }
</div>
}
</>

View File

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import './tooltip.css';
import { InfoIcon } from './icons';
import Markdown from 'markdown-to-jsx';
interface TooltipProps {
text: string;
@ -79,7 +80,9 @@ class Tooltip extends Component<TooltipProps, TooltipState> {
<div className="tooltip bs-tooltip-bottom">
<div className="arrow"></div>
<div className="tooltip-inner">
{tooltipTextToComponents(this.props.text)}
<Markdown>
{this.props.text}
</Markdown>
</div>
</div>
)

View File

@ -19,7 +19,7 @@ export enum Category {
/**
* This is the sole configuration variable that defines the order of the categories
* in the category grid. The order in the enum defition or the other configs does
* in the category grid. The order in the enum definition or the other configs does
* not affect the order of the grid.
*/
export const categoriesOrder: Category[] = [

View File

@ -23,8 +23,8 @@ export interface CategoryMapDefinition {
export const defaultMapCategory = Category.Age;
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
[Category.Age]: {
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} = {
[Category.Age]: [{
mapStyle: 'date_year',
legend: {
title: 'Age',
@ -46,8 +46,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#d0c291', text: '<1700' },
]
},
},
[Category.Size]: {
}],
[Category.Size]: [{
mapStyle: 'size_height',
legend: {
title: 'Height to apex',
@ -62,15 +62,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#980043', text: '≥152'}
]
},
},
[Category.Team]: {
}],
[Category.Team]: [{
mapStyle: undefined,
legend: {
title: 'Team',
elements: []
},
},
[Category.Construction]: {
}],
[Category.Construction]: [{
mapStyle: 'construction_core_material',
legend: {
title: 'Construction',
@ -85,8 +85,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#c48a85", text: "Other Man-Made Material" }
]
},
},
[Category.Location]: {
}],
[Category.Location]: [{
mapStyle: 'location',
legend: {
title: 'Location',
@ -99,23 +99,52 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#bae4bc', text: '<20%' }
]
},
},
[Category.Community]: {
mapStyle: 'likes',
legend: {
title: 'Like Me',
elements: [
{ color: '#bd0026', text: '👍👍👍👍 100+' },
{ color: '#e31a1c', text: '👍👍👍 5099' },
{ color: '#fc4e2a', text: '👍👍 2049' },
{ color: '#fd8d3c', text: '👍👍 1019' },
{ color: '#feb24c', text: '👍 39' },
{ color: '#fed976', text: '👍 2' },
{ color: '#ffe8a9', text: '👍 1'}
]
}],
[Category.Community]: [
{
mapStyle: 'likes',
legend: {
title: 'Like Me',
elements: [
{ color: '#bd0026', text: '👍👍👍👍 100+' },
{ color: '#e31a1c', text: '👍👍👍 5099' },
{ color: '#fc4e2a', text: '👍👍 2049' },
{ color: '#fd8d3c', text: '👍👍 1019' },
{ color: '#feb24c', text: '👍 39' },
{ color: '#fed976', text: '👍 2' },
{ color: '#ffe8a9', text: '👍 1'}
]
}
},
{
mapStyle: 'community_local_significance_total',
legend: {
title: 'Local Significance',
description: 'People who think the building should be locally listed',
elements: [
{ color: '#bd0026', text: '100+' },
{ color: '#e31a1c', text: '5099' },
{ color: '#fc4e2a', text: '2049' },
{ color: '#fd8d3c', text: '1019' },
{ color: '#feb24c', text: '39' },
{ color: '#fed976', text: '2' },
{ color: '#ffe8a9', text: '1'}
]
}
},
{
mapStyle: 'community_in_public_ownership',
legend: {
title: 'Public Ownership',
description: 'Is the building in some form of public/community ownership',
elements: [
{color: '#1166ff', text: 'Yes'},
{color: '#ffaaa0', text: 'No'}
]
}
}
},
[Category.Planning]: {
],
[Category.Planning]: [{
mapStyle: 'planning_combined',
legend: {
title: 'Statutory protections',
@ -128,8 +157,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#858ed4', text: 'Locally listed'},
]
},
},
[Category.Sustainability]: {
}],
[Category.Sustainability]: [{
mapStyle: 'sust_dec',
legend: {
title: 'Sustainability',
@ -144,8 +173,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#e31d23", text: 'G' },
]
},
},
[Category.Type]: {
}],
[Category.Type]: [{
mapStyle: 'building_attachment_form',
legend: {
title: 'Type',
@ -156,8 +185,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#226291", text: "Mid-Terrace" }
]
},
},
[Category.LandUse]: {
}],
[Category.LandUse]: [{
mapStyle: 'landuse',
legend: {
title: 'Land Use',
@ -177,15 +206,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#ffffff', text: 'Vacant & Derelict' }
]
},
},
[Category.Streetscape]: {
}],
[Category.Streetscape]: [{
mapStyle: undefined,
legend: {
title: 'Streetscape',
elements: []
},
},
[Category.Dynamics]: {
}],
[Category.Dynamics]: [{
mapStyle: 'dynamics_demolished_count',
legend: {
title: 'Dynamics',
@ -218,6 +247,6 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
}
],
},
}
}]
};

View File

@ -1,5 +1,12 @@
import { Category } from './categories-config';
export interface AggregationDescriptionConfig {
zero: string;
one: string;
many: string;
}
/**
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
@ -35,6 +42,12 @@ export interface DataFieldDefinition {
*/
items?: { [key: string]: Omit<DataFieldDefinition, 'category'> };
/**
* If the defined type is a dictionary, this describes the types of the dictionary's fields
*/
fields?: { [key: string]: Omit<DataFieldDefinition, 'category'>}
/**
* The example is used to determine the runtime type in which the attribute data is stored (e.g. number, string, object)
* This gives the programmer auto-complete of all available building attributes when implementing a category view.
@ -45,8 +58,71 @@ export interface DataFieldDefinition {
* E.g. for building attachment form, you could use "Detached" as example
*/
example: any;
/**
* Whether the field is a field that has an independent value for each user.
* For example, user building likes are one of such fields.
* By default this is false - fields are treated as not user-specific.
*/
perUser?: boolean;
/**
* Only for fields that are aggregations of a building-user field.
* specify what text should be added to the number of users calculated by the aggregation.
* E.g. for user likes, if zero="like this building" then for a building with 0 likes,
* the result will be "0 people like this building"
*/
aggregationDescriptions?: AggregationDescriptionConfig;
}
export const buildingUserFields = {
community_like: {
perUser: true,
category: Category.Community,
title: "Do you like this building and think it contributes to the city?",
example: true,
},
community_type_worth_keeping: {
perUser: true,
category: Category.Community,
title: "Do you think this **type** of building is generally worth keeping?",
example: true,
},
community_type_worth_keeping_reasons: {
perUser: true,
category: Category.Community,
title: 'Why is this type of building worth keeping?',
fields: {
external_design: {
title: "because the external design contributes to the streetscape"
},
internal_design: {
title: 'because the internal design works well'
},
adaptable: {
title: 'because the building is adaptable / can be reused to make the city more sustainable'
},
other: {
title: 'other'
}
},
example: {
external_design: true,
internal_design: true,
adaptable: false,
other: false
}
},
community_local_significance: {
perUser: true,
category: Category.Community,
title: "Do you think this building should be recorded as one of special local significance?",
example: true
}
};
export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
location_name: {
category: Category.Location,
@ -309,7 +385,7 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
sust_aggregate_estimate_epc: {
category: Category.Sustainability,
title: "EPC Rating",
tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented",
tooltip: "(Energy Performance Certificate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented",
example: "",
},
sust_retrofit_date: {
@ -444,6 +520,47 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
category: Category.Community,
title: "Total number of likes",
example: 100,
tooltip: "People who like the building and think it contributes to the city.",
aggregationDescriptions: {
zero: "like this building",
one: "likes this building",
many: "like this building"
}
},
community_local_significance_total: {
category: Category.Community,
title: "People who think the building should be recorded as one of local significance",
example: 100,
aggregationDescriptions: {
zero: "think this building should be classified as a locally listed heritage asset",
one: "thinks this building should be classified as a locally listed heritage asset",
many: "think this building should be classified as a locally listed heritage asset"
}
},
community_activities: {
category: Category.Community,
title: "Has the building ever been used for community activities?",
tooltip: "E.g. youth club, place of worship, GP surgery, pub",
example: true
},
// community_activities_dates: {
// category: Category.Community,
// title: "When was this building used for community activities?"
// },
community_public_ownership: {
category: Category.Community,
title: "Is the building in public/community ownership?",
example: "Not in public/community ownership"
},
community_public_ownership_sources: {
category: Category.Community,
title: "Community ownership source link",
example: ["https://example.com"]
},
dynamics_has_demolished_buildings: {
@ -486,3 +603,5 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
]
}
};
export const allFieldsConfig = {...dataFields, ...buildingUserFields};

View File

@ -8,6 +8,8 @@ export type BuildingMapTileset = 'date_year' |
'construction_core_material' |
'location' |
'likes' |
'community_local_significance_total' |
'community_in_public_ownership' |
'planning_combined' |
'sust_dec' |
'building_attachment_form' |

View File

@ -2,14 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import loadable from '@loadable/component';
import { useRevisionId } from './hooks/use-revision';
import { useBuildingData } from './hooks/use-building-data';
import { useBuildingLikeData } from './hooks/use-building-like-data';
import { useUserVerifiedData } from './hooks/use-user-verified-data';
import { useRevisionId } from './api-data/use-revision';
import { useBuildingData } from './api-data/use-building-data';
import { useUserVerifiedData } from './api-data/use-user-verified-data';
import { useUrlBuildingParam } from './nav/use-url-building-param';
import { useUrlCategoryParam } from './nav/use-url-category-param';
import { useUrlModeParam } from './nav/use-url-mode-param';
import { apiPost } from './apiHelpers';
import BuildingView from './building/building-view';
import Categories from './building/categories';
import { EditHistory } from './building/edit-history/edit-history';
@ -22,6 +20,8 @@ import { useLastNotEmpty } from './hooks/use-last-not-empty';
import { Category } from './config/categories-config';
import { defaultMapCategory } from './config/category-maps-config';
import { useMultiEditData } from './hooks/use-multi-edit-data';
import { useAuth } from './auth-context';
import { sendBuildingUpdate } from './api-data/building-update';
/**
* Load and render ColouringMap component on client-side only.
@ -35,13 +35,12 @@ import { useMultiEditData } from './hooks/use-multi-edit-data';
* to all modules that import leaflet or react-leaflet.
*/
const ColouringMap = loadable(
() => import('./map/map'),
{ ssr: false }
async () => (await import('./map/map')).ColouringMap,
{ ssr: false }
);
interface MapAppProps {
building?: Building;
building_like?: boolean;
revisionId?: string;
user_verified?: object;
}
@ -61,6 +60,7 @@ function setOrToggle<T>(currentValue: T, newValue: T): T {
}
export const MapApp: React.FC<MapAppProps> = props => {
const { user } = useAuth();
const [categoryUrlParam] = useUrlCategoryParam();
const [currentCategory, setCategory] = useState<Category>();
@ -70,8 +70,7 @@ export const MapApp: React.FC<MapAppProps> = props => {
const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam('view', displayCategory);
const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building);
const [buildingLike, updateBuildingLike] = useBuildingLikeData(selectedBuildingId, props.building_like);
const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building, user != undefined);
const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified);
const [revisionId, updateRevisionId] = useRevisionId(props.revisionId);
@ -94,20 +93,9 @@ export const MapApp: React.FC<MapAppProps> = props => {
const buildingId = building?.building_id;
if(buildingId != undefined && multiEditError == undefined) {
const isLike = currentCategory === Category.Community;
const endpoint = isLike ?
`/api/buildings/${buildingId}/like.json`:
`/api/buildings/${buildingId}.json`;
const payload = isLike ? {like: true} : multiEditData;
try {
const res = await apiPost(endpoint, payload);
if(res.error) {
console.error({ error: res.error });
} else {
updateRevisionId(res.revision_id);
}
const updatedBuilding = await sendBuildingUpdate(buildingId, multiEditData);
updateRevisionId(updatedBuilding.revision_id);
} catch(error) {
console.error({ error });
}
@ -124,13 +112,6 @@ export const MapApp: React.FC<MapAppProps> = props => {
}
}, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
const handleBuildingLikeUpdate = useCallback((buildingId: number, updatedData: boolean) => {
// only update current building data if the IDs match
if(buildingId === selectedBuildingId) {
updateBuildingLike(updatedData);
}
}, [selectedBuildingId, updateBuildingLike]);
const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => {
// only update current building data if the IDs match
if(buildingId === selectedBuildingId) {
@ -162,10 +143,8 @@ export const MapApp: React.FC<MapAppProps> = props => {
mode={viewEditMode}
cat={displayCategory}
building={building}
building_like={buildingLike}
user_verified={userVerified ?? {}}
onBuildingUpdate={handleBuildingUpdate}
onBuildingLikeUpdate={handleBuildingLikeUpdate}
onUserVerifiedUpdate={handleUserVerifiedUpdate}
/>
</Route>

View File

@ -56,6 +56,16 @@
}
}
.map-legend .style-select {
background-color: inherit;
padding: 0.5rem 0.25rem;
margin: 0.25rem 0.5rem;
width: auto;
font-size: 18px;
border: 1px solid;
border-radius: 4px;
}
.map-legend .h4,
.map-legend p,
.data-legend {

View File

@ -1,116 +1,115 @@
import React from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import './legend.css';
import { DownIcon, UpIcon } from '../components/icons';
import { Logo } from '../components/logo';
import { LegendConfig } from '../config/category-maps-config';
import { CategoryMapDefinition, LegendConfig } from '../config/category-maps-config';
import { BuildingMapTileset } from '../config/tileserver-config';
interface LegendProps {
legendConfig: LegendConfig;
mapColourScaleDefinitions: CategoryMapDefinition[];
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
interface LegendState {
collapseList: boolean;
}
export const Legend : FC<LegendProps> = ({
mapColourScaleDefinitions,
mapColourScale,
onMapColourScale
}) => {
const [collapseList, setCollapseList] = useState(false);
class Legend extends React.Component<LegendProps, LegendState> {
constructor(props) {
super(props);
this.state = {collapseList: false};
this.handleClick = this.handleClick.bind(this);
this.onResize= this.onResize.bind(this);
}
const handleToggle = useCallback(() => {
setCollapseList(!collapseList);
}, [collapseList]);
const onResize = useCallback(({target}) => {
setCollapseList((target.outerHeight < 670 || target.outerWidth < 768))
}, []);
handleClick() {
this.setState(state => ({
collapseList: !state.collapseList
}));
}
useEffect(() => {
window.addEventListener('resize', onResize);
if(window?.outerHeight) {
componentDidMount() {
window.addEventListener('resize', this.onResize);
if (window && window.outerHeight) {
// if we're in the browser, pass in as though from event to initialise
this.onResize({target: window});
onResize({target: window});
}
}
return () => {
window.removeEventListener('resize', onResize);
}
}, [onResize]);
const legendConfig = mapColourScaleDefinitions.find(def => def.mapStyle === mapColourScale)?.legend;
componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}
const {
title = undefined,
elements = [],
description = undefined,
disclaimer = undefined
} = legendConfig ?? {};
onResize(e) {
this.setState({collapseList: (e.target.outerHeight < 670 || e.target.outerWidth < 768)}); // magic number needs to be consistent with CSS expander-button media query
}
render() {
const {
title = undefined,
elements = [],
description = undefined,
disclaimer = undefined
} = this.props.legendConfig ?? {};
return (
<div className="map-legend">
<Logo variant="default" />
{
return (
<div className="map-legend">
<Logo variant="default" />
{
mapColourScaleDefinitions.length > 1 ?
<select className='style-select' onChange={e => onMapColourScale(e.target.value as BuildingMapTileset)}>
{
mapColourScaleDefinitions.map(def =>
<option key={def.mapStyle} value={def.mapStyle}>{def.legend.title}</option>
)
}
</select> :
title && <h4 className="h4">{title}</h4>
}
{
elements.length > 0 &&
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
{
this.state.collapseList ?
<UpIcon /> :
<DownIcon />
}
</button>
}
{
description && <p>{description}</p>
}
{
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
{
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
}
{
elements.map((item) => {
let key: string,
content: React.ReactElement;
if('subtitle' in item) {
key = item.subtitle;
content = <h6>{item.subtitle}</h6>;
} else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text }
</>;
}
return (
<li key={key}>
{content}
</li>
);
})
}
</ul>
}
</div>
);
}
}
export default Legend;
}
{
elements.length > 0 &&
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={handleToggle} >
{
collapseList ?
<UpIcon /> :
<DownIcon />
}
</button>
}
{
description && <p>{description}</p>
}
{
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={collapseList ? 'collapse data-legend' : 'data-legend'} >
{
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
}
{
elements.map((item) => {
let key: string,
content: React.ReactElement;
if('subtitle' in item) {
key = item.subtitle;
content = <h6>{item.subtitle}</h6>;
} else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text }
</>;
}
return (
<li key={key}>
{content}
</li>
);
})
}
</ul>
}
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { Component, Fragment, useEffect } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { AttributionControl, MapContainer, ZoomControl, useMapEvent, Pane, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
@ -18,9 +18,10 @@ import { BuildingDataLayer } from './layers/building-data-layer';
import { BuildingNumbersLayer } from './layers/building-numbers-layer';
import { BuildingHighlightLayer } from './layers/building-highlight-layer';
import Legend from './legend';
import { Legend } from './legend';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import { BuildingMapTileset } from '../config/tileserver-config';
interface ColouringMapProps {
selectedBuildingId: number;
@ -30,124 +31,125 @@ interface ColouringMapProps {
onBuildingAction: (building: Building) => void;
}
interface ColouringMapState {
theme: MapTheme;
position: [number, number];
zoom: number;
}
export const ColouringMap : FC<ColouringMapProps> = ({
category,
mode,
revisionId,
onBuildingAction,
selectedBuildingId,
children
}) => {
/**
* Map area
*/
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
constructor(props) {
super(props);
this.state = {
theme: 'light',
...initialMapViewport
};
this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this);
this.themeSwitch = this.themeSwitch.bind(this);
}
const [theme, setTheme] = useState<MapTheme>('light');
const [position, setPosition] = useState(initialMapViewport.position);
const [zoom, setZoom] = useState(initialMapViewport.zoom);
handleLocate(lat: number, lng: number, zoom: number){
this.setState({
position: [lat, lng],
zoom: zoom
});
}
const [mapColourScale, setMapColourScale] = useState<BuildingMapTileset>();
handleClick(e) {
const { lat, lng } = e.latlng;
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
.then(data => {
const building = data?.[0];
this.props.onBuildingAction(building);
}).catch(err => console.error(err));
}
const handleLocate = useCallback(
(lat: number, lng: number, zoom: number) => {
setPosition([lat, lng]);
setZoom(zoom);
},
[]
);
themeSwitch(e) {
e.preventDefault();
const newTheme = (this.state.theme === 'light')? 'night' : 'light';
this.setState({theme: newTheme});
}
const handleClick = useCallback(
async (e) => {
const {lat, lng} = e.latlng;
const data = await apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`);
const building = data?.[0];
onBuildingAction(building);
},
[onBuildingAction],
)
render() {
const categoryMapDefinition = categoryMapsConfig[this.props.category];
const themeSwitch = useCallback(
(e) => {
e.preventDefault();
const newTheme = (theme === 'light')? 'night' : 'light';
setTheme(newTheme);
},
[theme],
)
const tileset = categoryMapDefinition.mapStyle;
const categoryMapDefinitions = useMemo(() => categoryMapsConfig[category], [category]);
const hasSelection = this.props.selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
useEffect(() => {
if(!categoryMapDefinitions.some(def => def.mapStyle === mapColourScale)) {
setMapColourScale(categoryMapDefinitions[0].mapStyle);
}
}, [categoryMapDefinitions, mapColourScale]);
return (
<div className="map-container">
<MapContainer
center={initialMapViewport.position}
zoom={initialMapViewport.zoom}
minZoom={9}
maxZoom={18}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
const hasSelection = selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(mode);
return (
<div className="map-container">
<MapContainer
center={initialMapViewport.position}
zoom={initialMapViewport.zoom}
minZoom={9}
maxZoom={18}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
>
<ClickHandler onClick={handleClick} />
<MapBackgroundColor theme={theme} />
<MapViewport position={position} zoom={zoom} />
<Pane
key={theme}
name={'cc-base-pane'}
style={{zIndex: 50}}
>
<ClickHandler onClick={this.handleClick} />
<MapBackgroundColor theme={this.state.theme} />
<MapViewport position={this.state.position} zoom={this.state.zoom} />
<CityBaseMapLayer theme={theme} />
<BuildingBaseLayer theme={theme} />
</Pane>
<Pane
key={this.state.theme}
name={'cc-base-pane'}
style={{zIndex: 50}}
>
<CityBaseMapLayer theme={this.state.theme} />
<BuildingBaseLayer theme={this.state.theme} />
</Pane>
{
mapColourScale &&
<BuildingDataLayer
tileset={mapColourScale}
revisionId={revisionId}
/>
}
<Pane
name='cc-overlay-pane'
style={{zIndex: 300}}
>
<CityBoundaryLayer />
<BuildingNumbersLayer revisionId={revisionId} />
{
tileset &&
<BuildingDataLayer
tileset={tileset}
revisionId={this.props.revisionId}
selectedBuildingId &&
<BuildingHighlightLayer
selectedBuildingId={selectedBuildingId}
baseTileset={mapColourScale}
/>
}
</Pane>
<Pane
name='cc-overlay-pane'
style={{zIndex: 300}}
>
<CityBoundaryLayer />
<BuildingNumbersLayer revisionId={this.props.revisionId} />
{
this.props.selectedBuildingId &&
<BuildingHighlightLayer
selectedBuildingId={this.props.selectedBuildingId}
baseTileset={tileset}
/>
}
</Pane>
<ZoomControl position="topright" />
<AttributionControl prefix=""/>
</MapContainer>
{
this.props.mode !== 'basic' &&
<Fragment>
{
!hasSelection &&
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend legendConfig={categoryMapDefinition?.legend} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} />
</Fragment>
}
</div>
);
}
<ZoomControl position="topright" />
<AttributionControl prefix=""/>
</MapContainer>
{
mode !== 'basic' &&
<>
{
!hasSelection &&
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend mapColourScaleDefinitions={categoryMapDefinitions} mapColourScale={mapColourScale} onMapColourScale={setMapColourScale}/>
<ThemeSwitcher onSubmit={themeSwitch} currentTheme={theme} />
<SearchBox onLocate={handleLocate} />
</>
}
</div>
);
}
function ClickHandler({ onClick }: {onClick: (e) => void}) {
@ -180,5 +182,3 @@ function MapViewport({
return null;
}
export default ColouringMap;

View File

@ -1,4 +1,6 @@
import { dataFields } from '../config/data-fields-config';
import { buildingUserFields, dataFields } from '../config/data-fields-config';
type AttributesBasedOnExample<T extends Record<string, {example: any}>> = {[key in keyof T]: T[key]['example']};
/**
* A type representing the types of a building's attributes.
@ -6,16 +8,19 @@ import { dataFields } from '../config/data-fields-config';
* If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here,
* that means an example field is most probably missing on one of the config definitions in dataFieldsConfig.
*/
export type BuildingAttributes = {[key in keyof typeof dataFields]: (typeof dataFields)[key]['example']};
export type BuildingAttributes = AttributesBasedOnExample<typeof dataFields>;
export type BuildingUserAttributes = AttributesBasedOnExample<typeof buildingUserFields>;
export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number};
export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]};
export interface Building extends BuildingAttributes {
export interface Building extends BuildingAttributes, BuildingUserAttributes {
building_id: number;
geometry_id: number;
revision_id: string;
verified: BuildingAttributeVerificationCounts;
}
export type BuildingEdits = Partial<BuildingAttributes & BuildingUserAttributes>;

View File

@ -5,9 +5,13 @@ import InfoBox from '../components/info-box';
const CodeOfConductPage = () => (
<article>
<section className="main-col">
<h1 className="h2">Code of Conduct</h1>
<h1 className="h2">Contributor Code of Conduct</h1>
<InfoBox msg="Draft code of conduct for discussion" />
<p>
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
available at <a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct.html">
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html</a>
</p>
<h2 className="h3">Our Pledge</h2>
@ -42,7 +46,6 @@ const CodeOfConductPage = () => (
<li>Trolling, insulting/derogatory comments, and personal or political attacks</li>
<li>Public or private harassment</li>
<li>Publishing others' private information, such as a physical or electronic address, without explicit permission</li>
<li>Other conduct which could reasonably be considered inappropriate in a professional setting</li>
</ul>
@ -75,7 +78,8 @@ const CodeOfConductPage = () => (
<p>
Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported by
contacting the project team at <a
href="mailto:team@colouring.london">team@colouring.london</a>. All complaints will
href="mailto:team@colouring.london">team@colouring.london</a> or <a
href="dataprotection@turing.ac.uk">dataprotection@turing.ac.uk</a>. All complaints will
be reviewed and investigated and will result in a response that is deemed necessary and
appropriate to the circumstances. The project team is obligated to maintain
confidentiality with regard to the reporter of an incident. Further details of
@ -88,13 +92,6 @@ const CodeOfConductPage = () => (
members of the project's leadership.
</p>
<h2 className="h3">Attribution</h2>
<p>
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
available at <a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct.html">
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html</a>
</p>
<p>
For answers to common questions about this code of conduct, see <a
href="https://www.contributor-covenant.org/faq">https://www.contributor-covenant.org/faq</a>

View File

@ -6,33 +6,71 @@ const ContributorAgreementPage : React.SFC<any> = () => (
<section className='main-col'>
<h1>Contributor Agreement</h1>
<h2 className='h2'>Open data</h2>
<h2 className='h2'>Contributor responsibilities</h2>
<p>
Colouring London contributions are open data, licensed under the <a href="http://opendatacommons.org/licenses/odbl/">Open Data Commons Open Database License</a> (ODbL) by Colouring London contributors.
We ask all our contributors to:
</p>
<ul>
<li>
adhere to our Code of Conduct
</li>
<li>
never knowingly add data that derives from a restricted, copyrighted, malicious or illegal source
</li>
<li>
help us create an open data platform that supports the development of sustainable, resilient, inclusive, and equitable cities, and encourages the use of data for the public good
</li>
<li>
add sources wherever possible, to benefit others
</li>
<li>
verify data, whenever possible, to benefit others
</li>
<li>
ensure our open licencing terms are fully adhered to with regard to our open data, and our open code
</li>
<li>
provide us with as little personal data as possible
</li>
<li>
take full responsibility for assessing the reliability of Colouring London data and its suitability for any intended use (see also our 'Data Accuracy Agreement')
</li>
<li>
provide feedback on actual or potential privacy and security concerns
</li>
</ul>
<h2 className='h2'>Additional notes for contributors</h2>
<h3 className='h3'>Open data</h3>
<p>
You are free to copy, distribute, transmit and adapt our data, as long as you credit Colouring London and our contributors.If you alter or build upon our data, you may distribute the result only under the same licence.
Colouring London is an open data project. Open data are licensed under the Open Data Commons Open Database License (<a href="https://opendatacommons.org/licenses/odbl/">https://opendatacommons.org/licenses/odbl/</a>) by Colouring London contributors. Under this licence you are free to copy, distribute, transmit and adapt our data, as long as you credit Colouring London and our contributors. If you alter or build upon our data, you may distribute the result only under the same licence. Our open platform code are available under a GNU, General Public Licence (<a href="https://www.gnu.org/licenses/gpl-3.0.en.html">https://www.gnu.org/licenses/gpl-3.0.en.html</a>).
</p>
<h2 className='h2'>Your contributions</h2>
<h3 className='h3'>What you are contributing to</h3>
<p>
Colouring London is a free knowledge exchange platform and open database designed for public use. It has been set up to support a whole-of-society approach to improving the sustainability, resilience and inclusivity of cities. Colouring London is also the prototype platform for the international Colouring Cities Research Programme (CCRP) run at the Alan Turing Institute. Its design is guided by principles set out in the United Nations New Urban Agenda, the Open Data Charter, the General Data Protection Regulation (GDPR), The Gemini Principles, the Open Data Institute's recommendations on personal data and data infrastructure, and specific Articles within the Declaration of Human rights. These are discussed on our <a href="https://github.com/colouring-london/colouring-london/issues/687">'Data ethics'</a> page, where we also use the Open Data Institute's data ethics canvas to answer questions on how we use and manage our data. We capture spatial statistics and do not collect text or images, though images may be integrated in the future. The type of spatial data we collect can be viewed by clicking on each data category, on 'Info' buttons and on the 'Building data categories' page. We are also planning a 'Showcase section' to enable platform users to share, and view, ways in which Colouring London data are used.
</p>
<h3 className='h3'>Diversity and inclusivity</h3>
<p>
Colouring London emphasises local and community knowledge. Contributors use a variety of sources and local and expert knowledge of buildings to contribute data and verify that Colouring London is accurate and up to date.
We are very grateful for all constructive contributions provided by our contributors. Our platform is designed for everyone, and we are working to make it as inclusive, welcoming and accessible as possible. We respect and actively seek diversity of contributors and audiences, and celebrate diversity of knowledge. We use colour, crowdsourcing, and non-technical language to reduce barriers to the contribution of statistical information and to make the process rewarding and interesting. Diversity of age, gender, skills and abilities, and cultural background is also essential to allow us, as communities, to make our cities and towns more inclusive, equitable, sustainable and resilient places. Our collective knowledge on the composition, dynamic behaviour and energy performance of our stocks, and how well our buildings work, is critical to inform policies on what buildings we should reuse, demolish and build anew, to accelerate retrofit, and to better understand stocks as dynamic systems, so that they may be improved.
</p>
<h3 className='h3'>Copyright and data accuracy and quality</h3>
<p>
When you contribute to Colouring London, you make your contributions available as open data for anyone to copy, distribute, transmit and adapt in line with the licence.
We are unable to accept any data derived from copyrighted or restricted sources, other than those covered by fair use, nor from illegal sources, and we ask contributors to carefully check sources prior to upload. We are also unable to take responsibility for the quality of datasets as it is not feasible to check each data entry, and as different degrees of accuracy and precision will be required by different users, depending on what they are using the data for (e.g. a school project or scientific paper). However our aim is to make our data as reliable and useful as possible. We therefore ask contributors to include sources and to verify other data entries wherever possible.
</p>
<p>
We are unable to accept any data derived from copyright or restricted sources, other than as covered by fair use.
</p>
<p>
We encourage full attribution of data sources where appropriate - more details on potential sources are documented with suggestions for each <a href="https://www.pages.colouring.london/buildingcategories">data category</a>.
</p>
<p>
When you make a contribution to Colouring London, you are creating a permanent, public record of all data added, removed, or changed by you.The database records the username and ID of the user making the edit, along with the time and date of the change.All of this information is also made publicly available through the website and through bulk downloads of the edit history.
For information on data privacy and security please see our Privacy and Security page.
</p>
<div className="buttons-container">
<Link to="sign-up.html" className="btn btn-outline-dark">Back to sign up</Link>
</div>

View File

@ -5,7 +5,10 @@ const DataAccuracyPage = () => (
<section className="main-col">
<h1 className="h2">Data Accuracy Agreement</h1>
<p>
The data are provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, accuracy, fitness for a particular purpose and non-infringement. In no event shall UCL be liable for any reliance that you place on or how you use the data nor any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the data or the use or other dealings in the data'.
Colouring London data are provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, accuracy, fitness for a particular purpose and non-infringement. In no event shall the Alan Turing Institute be liable for any reliance that you place on or how you use the data nor any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the data or the use or other dealings in the data.
</p>
<p>
Colouring London data are crowdsourced from multiple sources and may contain errors. Though we cannot comment on data accuracy, we try to include as many features as possible to help users assess their reliability and suitability for specific types of use (be this a school project or scientific paper). As information on sources is very important, contributors are asked to add these, and to verify data, wherever possible.
</p>
</section>
</article>

View File

@ -4,9 +4,10 @@ import { Link } from 'react-router-dom';
const PrivacyPolicyPage: React.SFC<any> = () => (
<article>
<section className='main-col'>
<h1 className='h1'>Colouring London Privacy Policy </ h1>
<h1 className='h1'>Privacy Policy & Platform Security</ h1>
<h2 className='h2'>Colouring London Privacy Policy with respect to personal data</h2>
<p>
This privacy policy explains how Colouring London uses the personal data we collect from you when you use our website. Colouring London is a research project developed by the Bartlett Centre for Advanced Spatial Analysis (CASA) at UCL. Colouring London is registered for data protection purposes with the UCL data protection office.
This privacy policy explains how Colouring London uses the personal data we collect from you when you use our website. Colouring London is a research project initially developed by the Bartlett Centre for Advanced Spatial Analysis (CASA) at UCL, and now run at The Alan Turing Institute. Colouring London is registered for data protection purposes with The Alan Turing Institute data protection office.
</p>
<h2 className='h2'>What data do we collect?</h2>
@ -32,12 +33,12 @@ const PrivacyPolicyPage: React.SFC<any> = () => (
<h2 className='h2'>What is the legal basis for processing your data?</h2>
<p>
Data protection laws require us to meet certain conditions before we are allowed to use your data in the manner described in this notice, including having a legal basis for the processing. Colouring London, as a research project, is processing your personal data in pursuance of performing a task in the public interest. For further details on the public task legal basis for processing, please see UCLs Statement of Tasks in the Public Interest, available <a href='https://www.ucl.ac.uk/legal-services/sites/legal-services/files/ucl_statement_of_tasks_in_the_public_interest_-_august_2018.pdf'>here</a>
Data protection laws require us to meet certain conditions before we are allowed to use your data in the manner described in this notice, including having a legal basis for the processing. Colouring London, as a research project, is processing your personal data in pursuance of its legitimate interests.
</p>
<h2 className='h2'>How do we store your data?</h2>
<p>
Colouring London stores your data at UCL in London behind the organisations firewall in a secure database using industry standard practices.
Colouring London stores your data at The Alan Turing Institute in London behind the organisations firewall in a secure database using industry standard practices.
</p>
<h2 className='h2'>How do we use cookies?</h2>
@ -95,20 +96,29 @@ const PrivacyPolicyPage: React.SFC<any> = () => (
<h2 className='h2'>Changes to this privacy policy</h2>
<p>
Changes to this privacy policy will be notified via the Colouring London website. This privacy policy was last updated on 2 October 2019 to add a paragraph on password reset emails. The previous update was on 13 August 2019.
Changes to this privacy policy will be notified via the Colouring London website. This privacy policy was last updated on 4th November 2021. Previous update 2nd October 2019 following change ownership from UCL to The Alan Turing Institute.
</p>
<h2 className='h2'>Who do I contact with questions?</h2>
<p>
If you have any questions about your personal data and Colouring London that are not answered by this privacy notice then please consult UCL's data protection web pages here, where further guidance and relevant UCL policy documentation can be found.
If you wish to complain about our use of your personal data or exercise any of your rights, please contact the Turing's Data Protection Officer: <a href="dataprotection@turing.ac.uk">dataprotection@turing.ac.uk</a> or Data Protection Officer, The Alan Turing Institute, 96 Euston Road, London NW1 2DB.
</p>
<p>
If you need further assistance in the first instance, please email <a href='mailto:casa@ucl.ac.uk'>casa@ucl.ac.uk</a>. If you wish to complain about our use of your personal data or exercise any of your rights, please contact UCL's Data Protection Officer: <a href='mailto:data-protection@ucl.ac.uk'>data-protection@ucl.ac.uk</a> or Data Protection Officer, UCL Gower Street, London WC1E 6BT.
If we are unable to adequately address any concerns you may have about the way in which we use your data, you have the right to lodge a formal complaint with the UK Information Commissioner's Office. Full details may be accessed on the complaints section of the Information Commissioner's Office website.
</p>
<h2 className='h2'>Further information on privacy and security</h2>
<p>
Please note when you make a contribution to Colouring London, you are creating a permanent, public record of all data added, removed, or changed by you. The database records the username and ID of the user making the edit, along with the time and date of the change. All of this information is also made publicly available through the website and through bulk downloads of the edit history. User names of contributors providing the highest number of edits are also included in our Leaderboards.
</p>
<p>
If we are unable to adequately address any concerns you may have about the way in which we use your data, you have the right to lodge a formal complaint with the UK Information Commissioner's Office. Full details may be accessed on the complaints section of the Information Commissioner's Office <a href='https://ico.org.uk'>website</a>.
Please also note that when you contribute to Colouring London, you make your contributions available as open data for anyone to copy, distribute, transmit and adapt in line with the licence, and to use as they see fit. Though we rigorously assess each data type, to help protect building occupiers' privacy and security we welcome any ideas for improvements.
</p>
<p>
Progress on Colouring London features specifically designed to address ethical issues, including these relating to security and privacy, can be tracked and commented on using our GitHub site, at <a href="https://github.com/colouring-london/colouring-london/issues/687">#687</a>. If you have any immediate concerns regarding security or privacy please contact Turing's data protection team at <a href="dataprotection@turing.ac.uk">dataprotection@turing.ac.uk</a>.
</p>
<div className="buttons-container">

View File

@ -6,7 +6,6 @@ import serialize from 'serialize-javascript';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById,
getLatestRevisionId,
getUserVerifiedAttributes
@ -34,11 +33,13 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
}
try {
let [user, building, uprns, buildingLike, userVerified, latestRevisionId] = await Promise.all([
let [user, building, uprns, userVerified, latestRevisionId] = await Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingById(
buildingId,
{ userDataOptions: userId ? { userId, userAttributes: true } : null }
) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
getLatestRevisionId()
]);
@ -48,7 +49,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
}
data.user = user;
data.building = building;
data.building_like = buildingLike;
data.user_verified = userVerified;
if (data.building != null) {
data.building.uprns = uprns;
@ -59,7 +59,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
data.user_verified = {}
data.latestRevisionId = 0;
context.status = 500;
@ -73,7 +72,6 @@ function renderHTML(context, data, req, res) {
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
user_verified={data.userVerified}
/>

View File

@ -84,6 +84,27 @@ const LAYER_QUERIES = {
buildings
WHERE
likes_total > 0`,
community_local_significance_total: `
SELECT
geometry_id,
community_local_significance_total
FROM
buildings
WHERE
community_local_significance_total > 0
`,
community_in_public_ownership: `
SELECT
geometry_id,
CASE
WHEN community_public_ownership = 'Not in public/community ownership' THEN false
ELSE true
END AS in_public_ownership
FROM
buildings
WHERE
community_public_ownership IS NOT NULL
`,
planning_combined: `
SELECT
geometry_id,

View File

@ -2,8 +2,8 @@
This document is a checklist for adding a new building attribute to the system. It's split into three 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.
The third section would appply to any data which can be ammended via the API.
When adding a new attribute a set of seed data should be identified, the base data set formany fields is Polly Hudsons PhD data set. This data set is required to;
The third section would apply to any data which can be amended via the API.
When adding a new attribute a set of seed data should be identified, the base data set for many fields is Polly Hudsons PhD data set. This data set is required to;
- Test visualisation elements (map styling)
- Provide some data for users to relate to and encourage them to fill in the field
- Test the API and database elements.

View File

@ -1,6 +1,6 @@
# Setting up a Hyper-V Virtual Machine
#### Prequisites
#### Prerequisites
This guide is written for Hyper-V running on MS Windows Server, in this case Windows Server Datacenter 2019.
@ -46,7 +46,7 @@ At the next screen configure the network adapter. This will typically be a Ether
---
At the next screen configure the virtual hard disk. It makes sense to create a dedicated virtual hard disk and to store it in a sensible location. The size choosen *is important* as (unlike the RAM value) it is not trivial to change this later (see diagram below).
At the next screen configure the virtual hard disk. It makes sense to create a dedicated virtual hard disk and to store it in a sensible location. The size chosen *is important* as (unlike the RAM value) it is not trivial to change this later (see diagram below).
![](./images/hyper-v/hyper_v_6.png)

View File

@ -1,13 +0,0 @@
-- Remove community fields
-- Ownership type, enumerate type from:
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type;
-- Ownerhsip perception, would you describe this as a community asset?
-- Boolean yes / no
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception;
-- Historic ownership type / perception
-- Has this building ever been used for community or public services activities?
-- Boolean yes / no
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic;

View File

@ -1,28 +0,0 @@
-- Remove community fields
-- Ownership type, enumerate type from:
--
CREATE TYPE ownership_type
AS ENUM ('Private individual',
'Private company',
'Private offshore ownership',
'Publicly owned',
'Institutionally owned');
ALTER TABLE buildings
ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual';
-- Ownerhsip perception, would you describe this as a community asset?
-- Boolean yes / no
-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which
ALTER TABLE buildings
ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null;
-- Historic ownership type / perception
-- Has this building ever been used for community or public services activities?
-- Boolean yes / no
ALTER TABLE buildings
ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS building_user_attributes;

View File

@ -0,0 +1,15 @@
CREATE TABLE public.building_user_attributes (
building_id INTEGER REFERENCES buildings,
user_id UUID REFERENCES users,
PRIMARY KEY (building_id, user_id),
community_like BOOLEAN NOT NULL DEFAULT(FALSE)
);
CREATE INDEX IF NOT EXISTS user_attrib_building_id_idx ON building_user_attributes (building_id);
CREATE INDEX IF NOT EXISTS user_attrib_building_id_user_id_idx ON building_user_attributes (building_id, user_id);
INSERT INTO building_user_attributes (building_id, user_id, community_like)
select building_id, user_id, TRUE as community_like
from building_user_likes;

View File

@ -0,0 +1,37 @@
-- -- Remove community fields
-- -- Ownership type, enumerate type from:
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type;
-- -- Ownerhsip perception, would you describe this as a community asset?
-- -- Boolean yes / no
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception;
-- -- Historic ownership type / perception
-- -- Has this building ever been used for community or public services activities?
-- -- Boolean yes / no
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_type_worth_keeping;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_type_worth_keeping_reasons;
ALTER TABLE building_user_attributes
DROP COLUMN IF EXISTS community_local_significance;
ALTER TABLE buildings
DROP COLUMN IF EXISTS community_local_significance_total;
ALTER TABLE buildings
DROP COLUMN IF EXISTS community_activities;
ALTER TABLE buildings
DROP COLUMN IF EXISTS community_public_ownership;
DROP TYPE IF EXISTS public_ownership_type;
ALTER TABLE buildings
DROP COLUMN IF EXISTS community_public_ownership_sources;

View File

@ -0,0 +1,58 @@
-- Remove community fields
-- Ownership type, enumerate type from:
--
-- CREATE TYPE ownership_type
-- AS ENUM ('Private individual',
-- 'Private company',
-- 'Private offshore ownership',
-- 'Publicly owned',
-- 'Institutionally owned');
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual';
-- Ownerhsip perception, would you describe this as a community asset?
-- Boolean yes / no
-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null;
-- Historic ownership type / perception
-- Has this building ever been used for community or public services activities?
-- Boolean yes / no
-- ALTER TABLE buildings
-- ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null;
ALTER TABLE building_user_attributes
ADD COLUMN community_type_worth_keeping BOOLEAN NULL;
ALTER TABLE building_user_attributes
ADD COLUMN community_type_worth_keeping_reasons JSONB DEFAULT '{}'::JSONB;
ALTER TABLE building_user_attributes
ADD COLUMN community_local_significance BOOLEAN DEFAULT false;
ALTER TABLE buildings
ADD COLUMN community_local_significance_total INT DEFAULT 0;
ALTER TABLE buildings
ADD COLUMN community_activities BOOLEAN NULL;
CREATE TYPE public_ownership_type
AS ENUM (
'State-owned',
'Charity-owned',
'Community-owned/cooperative',
'Owned by other non-profit body',
'Not in public/community ownership'
);
ALTER TABLE buildings
ADD COLUMN community_public_ownership public_ownership_type;
ALTER TABLE buildings
ADD COLUMN community_public_ownership_sources VARCHAR[];