diff --git a/pyproject.toml b/pyproject.toml index 666786e4b..0c180f03b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,11 +34,13 @@ dependencies = [ "django-probes==1.7.0", "Pillow==10.3.0", "psycopg==3.1.18", + "pydantic==2.7.0", "requests==2.31.0", "rcssmin==1.1.2", "rjsmin==1.2.2", "social-auth-core==4.5.4", "social-auth-app-django==5.4.1", + "websockets==12.0", ] [project.optional-dependencies] @@ -60,6 +62,7 @@ test = [ "pytest-django==4.8.0", "pytest-playwright==0.5.0", "pytest-xdist>=3.5.0,<4", + "pytest-xprocess>=1.0.1", ] docker = [ "uwsgi==2.0.25.1", diff --git a/umap/models.py b/umap/models.py index 6e942fb07..5efe3aba0 100644 --- a/umap/models.py +++ b/umap/models.py @@ -251,9 +251,14 @@ def get_anonymous_edit_url(self): path = reverse("map_anonymous_edit_url", kwargs={"signature": signature}) return settings.SITE_URL + path + def is_owner(self, user=None, request=None): + if user and self.owner == user: + return True + return self.is_anonymous_owner(request) + def is_anonymous_owner(self, request): if not request or self.owner: - # edit cookies are only valid while map hasn't owner + # edit cookies are only valid while the map doesn't have owner return False key, value = self.signed_cookie_elements try: diff --git a/umap/settings/__init__.py b/umap/settings/__init__.py index 914e8dc6f..e12998dff 100644 --- a/umap/settings/__init__.py +++ b/umap/settings/__init__.py @@ -43,3 +43,6 @@ globals()["STATICFILES_DIRS"].insert(0, value) else: globals()[key] = value + +# Expose these settings for consumption by e.g. django.settings.configure. +settings_as_dict = {k: v for k, v in globals().items() if k.isupper()} diff --git a/umap/settings/base.py b/umap/settings/base.py index f9fb2c4e0..63c42e1dd 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -306,3 +306,10 @@ }, }, } + +# WebSocket configuration + +WEBSOCKET_ENABLED = env.bool("WEBSOCKET_ENABLED", default=False) +WEBSOCKET_HOST = env("WEBSOCKET_HOST", default="localhost") +WEBSOCKET_PORT = env.int("WEBSOCKET_PORT", default=8001) +WEBSOCKET_URI = env("WEBSOCKET_URI", default="ws://localhost:8001") diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index f665ef524..dc08b4c11 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -7,24 +7,26 @@ import * as Utils from './utils.js' import { SCHEMA } from './schema.js' import { Request, ServerRequest, RequestError, HTTPError, NOKError } from './request.js' import Orderable from './orderable.js' - +import { SyncEngine } from './sync/engine.js' // Import modules and export them to the global scope. // For the not yet module-compatible JS out there. +// By alphabetic order window.U = { - URLs, - Request, - ServerRequest, - RequestError, - HTTPError, - NOKError, Browser, - Facets, - Panel, + Caption, EditPanel, + Facets, FullPanel, - Utils, - SCHEMA, + HTTPError, + NOKError, Orderable, - Caption, + Panel, + Request, + RequestError, + SCHEMA, + ServerRequest, + SyncEngine, + URLs, + Utils, } diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index 3db6f7260..fe21f78b3 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -1,28 +1,55 @@ import { translate } from './i18n.js' -// Possible impacts -// ['ui', 'data', 'limit-bounds', 'datalayer-index', 'remote-data', 'background'] +/** + * This SCHEMA defines metadata about properties. + * + * This is here in order to have a centered place where all properties are specified. + * + * Each property defines: + * + * - `type`: The type of the data + * - `impacts`: A list of impacts than happen when this property is updated, among + * 'ui', 'data', 'limit-bounds', 'datalayer-index', 'remote-data', + * 'background' 'sync'. + * - `belongsTo`: A list of conceptual objects this property belongs to, among + * 'map', 'feature', 'datalayer'. + * + * - Extra keys are being passed to the FormBuilder automatically. + */ +// This is sorted alphabetically export const SCHEMA = { browsable: { - impacts: ['ui'], type: Boolean, + impacts: ['ui'], + belongsTo: ['datalayer'], }, captionBar: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display a caption bar?'), default: false, }, + captionControl: { + type: Boolean, + impacts: ['ui'], + belongsTo: ['map'], + nullable: true, + label: translate('Display the caption control'), + default: true, + }, captionMenus: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display caption menus?'), default: true, }, color: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'ColorPicker', label: translate('color'), helpEntries: 'colorValue', @@ -32,14 +59,17 @@ export const SCHEMA = { choropleth: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, cluster: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, dashArray: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('dash array'), helpEntries: 'dashArray', inheritable: true, @@ -47,6 +77,7 @@ export const SCHEMA = { datalayersControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, handler: 'DataLayersControl', label: translate('Display the data layers control'), @@ -55,6 +86,7 @@ export const SCHEMA = { defaultView: { type: String, impacts: [], // no need to update the ui, only useful when loading the map + belongsTo: ['map'], label: translate('Default view'), choices: [ ['center', translate('Saved center and zoom')], @@ -67,27 +99,32 @@ export const SCHEMA = { description: { type: 'Text', impacts: ['ui'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('description'), helpEntries: 'textFormatting', }, displayOnLoad: { type: Boolean, impacts: [], + belongsTo: ['datalayer'], }, displayPopupFooter: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display popup footer?'), default: false, }, easing: { type: Boolean, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], default: false, }, editinosmControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the control to open OpenStreetMap editor'), default: null, @@ -95,6 +132,7 @@ export const SCHEMA = { embedControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the embed control'), default: true, @@ -102,10 +140,12 @@ export const SCHEMA = { facetKey: { type: String, impacts: ['ui'], + belongsTo: ['map', 'datalayer'], }, fill: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('fill'), helpEntries: 'fill', inheritable: true, @@ -114,6 +154,7 @@ export const SCHEMA = { fillColor: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'ColorPicker', label: translate('fill color'), helpEntries: 'fillColor', @@ -122,6 +163,7 @@ export const SCHEMA = { fillOpacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -132,27 +174,37 @@ export const SCHEMA = { filterKey: { type: String, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], }, fromZoom: { type: Number, impacts: [], // not needed + belongsTo: ['map', 'datalayer'], label: translate('From zoom'), helpText: translate('Optional.'), }, fullscreenControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the fullscreen control'), default: true, }, + geometry: { + type: Object, + impacts: ['data'], + belongsTo: ['feature'], + }, heat: { type: Object, impacts: ['data'], + belongsTo: ['datalayer'], }, iconClass: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Icon shape'), inheritable: true, choices: [ @@ -166,6 +218,7 @@ export const SCHEMA = { iconOpacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -176,6 +229,7 @@ export const SCHEMA = { iconUrl: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], handler: 'IconUrl', label: translate('Icon symbol'), inheritable: true, @@ -183,11 +237,12 @@ export const SCHEMA = { inCaption: { type: Boolean, impacts: ['ui'], + belongsTo: ['datalayer'], }, - interactive: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Allow interactions'), helpEntries: 'interactive', inheritable: true, @@ -196,6 +251,7 @@ export const SCHEMA = { labelDirection: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Label direction'), inheritable: true, choices: [ @@ -210,12 +266,14 @@ export const SCHEMA = { labelInteractive: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Labels are clickable'), inheritable: true, }, labelKey: { type: String, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], helpEntries: 'labelKey', placeholder: translate('Default: name'), label: translate('Label key'), @@ -224,50 +282,59 @@ export const SCHEMA = { licence: { type: String, impacts: ['ui'], + belongsTo: ['map', 'datalayer'], label: translate('licence'), }, limitBounds: { type: Object, impacts: ['limit-bounds'], + belongsTo: ['map'], }, locateControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the locate control'), }, longCredit: { type: 'Text', impacts: ['ui'], + belongsTo: ['map'], label: translate('Long credits'), helpEntries: ['longCredit', 'textFormatting'], }, measureControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the measure control'), }, miniMap: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display a minimap?'), default: false, }, moreControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display the «more» control?'), default: true, }, name: { type: String, impacts: ['ui', 'data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('name'), }, onLoadPanel: { type: String, impacts: [], // This is what happens during the map instantiation + belongsTo: ['map'], label: translate('Do you want to display a panel on load?'), choices: [ ['none', translate('None')], @@ -281,6 +348,7 @@ export const SCHEMA = { opacity: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0.1, max: 1, step: 0.1, @@ -290,6 +358,8 @@ export const SCHEMA = { }, outlink: { type: String, + impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Link to…'), helpEntries: 'outlink', placeholder: 'http://...', @@ -297,7 +367,8 @@ export const SCHEMA = { }, outlinkTarget: { type: String, - impacts: [], + impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Open link in…'), inheritable: true, default: 'blank', @@ -310,22 +381,26 @@ export const SCHEMA = { overlay: { type: Object, impacts: ['background'], + belongsTo: ['map'], }, permanentCredit: { type: 'Text', impacts: ['ui'], + belongsTo: ['map'], label: translate('Permanent credits'), helpEntries: ['permanentCredit', 'textFormatting'], }, permanentCreditBackground: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Permanent credits background'), default: true, }, popupContentTemplate: { type: 'Text', impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup content template'), helpEntries: ['dynamicProperties', 'textFormatting'], placeholder: '# {name}', @@ -335,6 +410,7 @@ export const SCHEMA = { popupShape: { type: String, impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup shape'), inheritable: true, choices: [ @@ -347,6 +423,7 @@ export const SCHEMA = { popupTemplate: { type: String, impacts: [], // not needed + belongsTo: ['map', 'datalayer', 'feature'], label: translate('Popup content style'), inheritable: true, choices: [ @@ -361,27 +438,25 @@ export const SCHEMA = { remoteData: { type: Object, impacts: ['remote-data'], + belongsTo: ['datalayer'], }, scaleControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Do you want to display the scale control?'), default: true, }, scrollWheelZoom: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], label: translate('Allow scroll wheel zoom?'), }, - captionControl: { - type: Boolean, - nullable: true, - label: translate('Display the caption control'), - default: true, - }, searchControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the search control'), default: true, @@ -389,28 +464,33 @@ export const SCHEMA = { shortCredit: { type: String, impacts: ['ui'], + belongsTo: ['map'], label: translate('Short credits'), helpEntries: ['shortCredit', 'textFormatting'], }, showLabel: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], nullable: true, label: translate('Display label'), inheritable: true, default: false, }, slideshow: { + belongsTo: ['map'], type: Object, impacts: ['ui'], }, slugKey: { type: String, impacts: [], + belongsTo: ['map', 'datalayer', 'feature'], }, smoothFactor: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 0, max: 10, step: 0.5, @@ -422,44 +502,60 @@ export const SCHEMA = { sortKey: { type: String, impacts: ['datalayer-index', 'data'], + belongsTo: ['map', 'datalayer'], }, starControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the star map button'), }, stroke: { type: Boolean, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], label: translate('stroke'), helpEntries: 'stroke', inheritable: true, default: true, }, + syncEnabled: { + type: Boolean, + impacts: ['sync', 'ui'], + belongsTo: ['map'], + label: translate('Enable real-time collaboration'), + helpEntries: 'sync', + default: false, + }, tilelayer: { type: Object, impacts: ['background'], + belongsTo: ['map'], }, tilelayersControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the tile layers control'), }, toZoom: { type: Number, impacts: [], // not needed + belongsTo: ['map', 'datalayer'], label: translate('To zoom'), helpText: translate('Optional.'), }, type: { type: 'String', impacts: ['data'], + belongsTo: ['datalayer'], }, weight: { type: Number, impacts: ['data'], + belongsTo: ['map', 'datalayer', 'feature'], min: 1, max: 20, step: 1, @@ -470,10 +566,12 @@ export const SCHEMA = { zoom: { type: Number, impacts: [], // default zoom, doesn't need to be updated + belongsTo: ['map'], }, zoomControl: { type: Boolean, impacts: ['ui'], + belongsTo: ['map'], nullable: true, label: translate('Display the zoom control'), default: true, @@ -481,9 +579,15 @@ export const SCHEMA = { zoomTo: { type: Number, impacts: [], // not need to update the view + belongsTo: ['map', 'datalayer', 'feature'], placeholder: translate('Inherit'), helpEntries: 'zoomTo', label: translate('Default zoom level'), inheritable: true, }, + _latlng: { + type: Object, + impacts: ['data'], + belongsTo: ['feature'], + }, } diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js new file mode 100644 index 000000000..0f9f73ecf --- /dev/null +++ b/umap/static/umap/js/modules/sync/engine.js @@ -0,0 +1,93 @@ +import { WebSocketTransport } from './websocket.js' +import { MapUpdater, DataLayerUpdater, FeatureUpdater } from './updaters.js' + +export class SyncEngine { + constructor(map) { + this.map = map + this.receiver = new MessagesDispatcher(this.map) + this._initialize() + } + _initialize() { + this.transport = undefined + const noop = () => {} + // by default, all operations do nothing, until the engine is started. + this.upsert = this.update = this.delete = noop + } + + async authenticate(tokenURI, server) { + const [response, _, error] = await this.server.get(tokenURI) + if (!error) { + this.sync.start(tokenURI, response.token) + } + } + + start(webSocketURI, authToken) { + this.transport = new WebSocketTransport(webSocketURI, authToken, this.receiver) + this.sender = new MessagesSender(this.transport) + this.upsert = this.sender.upsert.bind(this.sender) + this.update = this.sender.update.bind(this.sender) + this.delete = this.sender.delete.bind(this.sender) + } + + stop() { + if (this.transport) this.transport.close() + this._initialize() + } +} + +export class MessagesDispatcher { + constructor(map) { + this.map = map + this.updaters = { + map: new MapUpdater(this.map), + feature: new FeatureUpdater(this.map), + datalayer: new DataLayerUpdater(this.map), + } + } + + getUpdater(subject, metadata) { + if (Object.keys(this.updaters).includes(subject)) { + return this.updaters[subject] + } + throw new Error(`Unknown updater ${subject}, ${metadata}`) + } + + dispatch({ kind, ...payload }) { + if (kind == 'operation') { + let updater = this.getUpdater(payload.subject, payload.metadata) + updater.applyMessage(payload) + } else { + throw new Error(`Unknown dispatch kind: ${kind}`) + } + } +} + +/** + * Sends the message to the other party (using the specified transport): + * + * - `subject` is the type of object this is referering to (map, feature, layer) + * - `metadata` contains information about the object we're refering to (id, layerId for instance) + * - `key` and + * - `value` are the keys and values that are being modified. + */ +export class MessagesSender { + constructor(transport) { + this._transport = transport + } + + send(message) { + this._transport.send('operation', message) + } + + upsert(subject, metadata, value) { + this.send({ verb: 'upsert', subject, metadata, value }) + } + + update(subject, metadata, key, value) { + this.send({ verb: 'update', subject, metadata, key, value }) + } + + delete(subject, metadata, key) { + this.send({ verb: 'delete', subject, metadata, key }) + } +} diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js new file mode 100644 index 000000000..29c1c995d --- /dev/null +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -0,0 +1,127 @@ +import { propertyBelongsTo } from '../utils.js' + +/** + * This file contains the updaters: classes that are able to convert messages + * received from another party (or the server) to changes on the map. + */ + +class BaseUpdater { + constructor(map) { + this.map = map + } + + updateObjectValue(obj, key, value) { + const parts = key.split('.') + const lastKey = parts.pop() + + // Reduce the current list of attributes, + // to find the object to set the property onto + const objectToSet = parts.reduce((currentObj, part) => { + if (part in currentObj) return currentObj[part] + }, obj) + + // In case the given path doesn't exist, stop here + if (objectToSet === undefined) return + + // Set the value (or delete it) + if (typeof value === 'undefined') { + delete objectToSet[lastKey] + } else { + objectToSet[lastKey] = value + } + } + + getDataLayerFromID(layerId) { + if (layerId) return this.map.getDataLayerByUmapId(layerId) + return this.map.defaultEditDataLayer() + } + + applyMessage(payload) { + let { verb, subject } = payload + + if (verb == 'update') { + if (!propertyBelongsTo(payload.key, subject)) { + console.error('Invalid message received', payload) + return // Do not apply the message + } + } + return this[verb](payload) + } +} + +export class MapUpdater extends BaseUpdater { + update({ key, value }) { + this.updateObjectValue(this.map, key, value) + this.map.render([key]) + } +} + +export class DataLayerUpdater extends BaseUpdater { + upsert({ value }) { + // Inserts does not happen (we use multiple updates instead). + this.map.createDataLayer(value, false) + } + + update({ key, metadata, value }) { + const datalayer = this.getDataLayerFromID(metadata.id) + this.updateObjectValue(datalayer, key, value) + datalayer.render([key]) + } +} + +/** + * This is an abstract base class + * And needs to be subclassed to be used. + * + * The child classes need to expose: + * - `featureClass`: the name of the class to create the feature + * - `featureArgument`: an object with the properties to pass to the class when bulding it. + **/ +export class FeatureUpdater extends BaseUpdater { + getFeatureFromMetadata({ id, layerId }) { + const datalayer = this.getDataLayerFromID(layerId) + return datalayer.getFeatureById(id) + } + + // Create or update an object at a specific position + upsert({ metadata, value }) { + let { id, layerId } = metadata + const datalayer = this.getDataLayerFromID(layerId) + let feature = this.getFeatureFromMetadata(metadata, value) + if (feature === undefined) { + console.log(`Unable to find feature with id = ${metadata.id}. Creating a new one`) + } + feature = datalayer.geometryToFeature({ + geometry: value.geometry, + geojson: value, + id, + feature, + }) + feature.addTo(datalayer) + } + + // Update a property of an object + update({ key, metadata, value }) { + let feature = this.getFeatureFromMetadata(metadata) + if (feature === undefined) { + console.error(`Unable to find feature with id = ${metadata.id}.`) + } + switch (key) { + case 'geometry': + const datalayer = this.getDataLayerFromID(metadata.layerId) + datalayer.geometryToFeature({ geometry: value, id: metadata.id, feature }) + default: + this.updateObjectValue(feature, key, value) + } + + feature.datalayer.indexProperties(feature) + feature.render([key]) + } + + delete({ metadata }) { + // XXX Distinguish between properties getting deleted + // and the wole feature getting deleted + let feature = this.getFeatureFromMetadata(metadata) + if (feature) feature.del(false) + } +} diff --git a/umap/static/umap/js/modules/sync/websocket.js b/umap/static/umap/js/modules/sync/websocket.js new file mode 100644 index 000000000..e575bed6b --- /dev/null +++ b/umap/static/umap/js/modules/sync/websocket.js @@ -0,0 +1,25 @@ +export class WebSocketTransport { + constructor(webSocketURI, authToken, messagesReceiver) { + this.websocket = new WebSocket(webSocketURI) + this.websocket.onopen = () => { + this.send('join', { token: authToken }) + } + this.websocket.addEventListener('message', this.onMessage.bind(this)) + this.receiver = messagesReceiver + } + + onMessage(wsMessage) { + this.receiver.dispatch(JSON.parse(wsMessage.data)) + } + + send(kind, payload) { + const message = { ...payload } + message.kind = kind + let encoded = JSON.stringify(message) + this.websocket.send(encoded) + } + + close() { + this.websocket.close() + } +} diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 2f0cb57a4..831196c1d 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -30,7 +30,8 @@ export function checkId(string) { * * Return an array of unique impacts. * - * @param {fields} list[fields] + * @param {fields} list[fields] + * @param object schema object. If ommited, global U.SCHEMA will be used. * @returns Array[string] */ export function getImpactsFromSchema(fields, schema) { @@ -53,6 +54,31 @@ export function getImpactsFromSchema(fields, schema) { return Array.from(impacted) } +/** + * Checks the given property belongs to the given subject, according to the schema. + * + * @param srtring property + * @param string subject + * @param object schema object. If ommited, global U.SCHEMA will be used. + * @returns Bool + */ +export function propertyBelongsTo(property, subject, schema) { + schema = schema || U.SCHEMA + if (subject === 'feature') { + // FIXME allow properties.whatever + property = property.replace('properties.', '').replace('_umap_options.', '') + } + property = property.replace('options.', '') + const splits = property.split('.') + const nested = splits.length > 1 + if (nested) property = splits[0] + if (!Object.keys(schema).includes(property)) return false + if (nested) { + if (schema[property].type !== Object) return false + } + return schema[property].belongsTo.includes(subject) +} + /** * Import DOM purify, and initialize it. * diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 7c81921b9..497333ff6 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -1,7 +1,44 @@ U.FeatureMixin = { staticOptions: { mainColor: 'color' }, - initialize: function (map, latlng, options) { + getSyncMetadata: function () { + return { + engine: this.map.sync, + subject: 'feature', + metadata: { + id: this.id, + layerId: this.datalayer?.id || null, + featureType: this.getClassName(), + }, + } + }, + + onCommit: function () { + // When the layer is a remote layer, we don't want to sync the creation of the + // points via the websocket, as the other peers will get them themselves. + if (this.datalayer.isRemoteLayer()) return + const { subject, metadata, engine } = this.getSyncMetadata() + engine.upsert(subject, metadata, this.toGeoJSON()) + }, + + getGeometry: function () { + return this.toGeoJSON().geometry + }, + + syncUpdatedProperties: function (properties) { + // When updating latlng, sync the whole geometry + if ('latlng'.includes(properties)) { + const { subject, metadata, engine } = this.getSyncMetadata() + engine.update(subject, metadata, 'geometry', this.getGeometry()) + } + }, + + syncDelete: function () { + let { subject, metadata, engine } = this.getSyncMetadata() + engine.delete(subject, metadata) + }, + + initialize: function (map, latlng, options, id) { this.map = map if (typeof options === 'undefined') { options = {} @@ -9,17 +46,25 @@ U.FeatureMixin = { // DataLayer the marker belongs to this.datalayer = options.datalayer || null this.properties = { _umap_options: {} } - let geojson_id + if (options.geojson) { this.populate(options.geojson) - geojson_id = options.geojson.id } - // Each feature needs an unique identifier - if (U.Utils.checkId(geojson_id)) { - this.id = geojson_id + if (id) { + this.id = id } else { - this.id = U.Utils.generateId() + let geojson_id + if (options.geojson) { + geojson_id = options.geojson.id + } + + // Each feature needs an unique identifier + if (U.Utils.checkId(geojson_id)) { + this.id = geojson_id + } else { + this.id = U.Utils.generateId() + } } let isDirty = false const self = this @@ -98,6 +143,7 @@ U.FeatureMixin = { this.view() } } + this._redraw() }, openPopup: function () { @@ -149,7 +195,7 @@ U.FeatureMixin = { }, getAdvancedEditActions: function (container) { - const deleteButton = L.DomUtil.createButton( + L.DomUtil.createButton( 'button umap-delete', container, L._('Delete'), @@ -240,13 +286,14 @@ U.FeatureMixin = { } return false }, - - del: function () { + del: function (sync) { this.isDirty = true this.map.closePopup() if (this.datalayer) { this.datalayer.removeLayer(this) this.disconnectFromDataLayer(this.datalayer) + + if (sync !== false) this.syncDelete() } }, @@ -549,7 +596,10 @@ U.FeatureMixin = { }, clone: function () { - const layer = this.datalayer.geojsonToFeatures(this.toGeoJSON()) + const geoJSON = this.toGeoJSON() + delete geoJSON.id + delete geoJSON.properties.id + const layer = this.datalayer.geojsonToFeatures(geoJSON) layer.isDirty = true layer.edit() return layer @@ -602,9 +652,11 @@ U.Marker = L.Marker.extend({ function (e) { this.isDirty = true this.edit(e) + this.syncUpdatedProperties(['latlng']) }, this ) + this.on('editable:drawing:commit', this.onCommit) if (!this.isReadOnly()) this.on('mouseover', this._enableDragging) this.on('mouseout', this._onMouseOut) this._popupHandlersAdded = true // prevent Leaflet from binding event on bindPopup @@ -886,6 +938,7 @@ U.PathMixin = { addInteractions: function () { U.FeatureMixin.addInteractions.call(this) + this.on('editable:disable', this.onCommit) this.on('mouseover', this._onMouseOver) this.on('edit', this.makeDirty) this.on('drag editable:drag', this._onDrag) @@ -1086,6 +1139,9 @@ U.Polyline = L.Polyline.extend({ geojson.geometry.coordinates = [ U.Utils.flattenCoordinates(geojson.geometry.coordinates), ] + + delete geojson.id // delete the copied id, a new one will be generated. + const polygon = this.datalayer.geojsonToFeatures(geojson) polygon.edit() this.del() @@ -1093,7 +1149,7 @@ U.Polyline = L.Polyline.extend({ getAdvancedEditActions: function (container) { U.FeatureMixin.getAdvancedEditActions.call(this, container) - const toPolygon = L.DomUtil.createButton( + L.DomUtil.createButton( 'button umap-to-polygon', container, L._('Transform to polygon'), @@ -1223,6 +1279,8 @@ U.Polygon = L.Polygon.extend({ toPolyline: function () { const geojson = this.toGeoJSON() + delete geojson.id + delete geojson.properties.id geojson.geometry.type = 'LineString' geojson.geometry.coordinates = U.Utils.flattenCoordinates( geojson.geometry.coordinates diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 3297ff7f3..e9e8ab080 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -745,14 +745,12 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({ }) L.FormBuilder.FacetSearchBase = L.FormBuilder.Element.extend({ - buildLabel: function () { this.label = L.DomUtil.element({ tagName: 'legend', textContent: this.options.label, }) - } - + }, }) L.FormBuilder.FacetSearchChoices = L.FormBuilder.FacetSearchBase.extend({ build: function () { @@ -865,13 +863,13 @@ L.FormBuilder.MinMaxBase = L.FormBuilder.FacetSearchBase.extend({ }, isMinModified: function () { - const default_ = this.minInput.getAttribute("value") + const default_ = this.minInput.getAttribute('value') const current = this.minInput.value return current != default_ }, isMaxModified: function () { - const default_ = this.maxInput.getAttribute("value") + const default_ = this.maxInput.getAttribute('value') const current = this.maxInput.value return current != default_ }, @@ -1184,6 +1182,10 @@ U.FormBuilder = L.FormBuilder.extend({ L.FormBuilder.prototype.setter.call(this, field, value) this.obj.isDirty = true if ('render' in this.obj) this.obj.render([field], this) + if ('getSyncMetadata' in this.obj) { + const { subject, metadata, engine } = this.obj.getSyncMetadata() + if (engine) engine.update(subject, metadata, field, value) + } }, finish: function () { diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 8bc9ac215..689ae292c 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -33,6 +33,7 @@ U.Map = L.Map.extend({ includes: [ControlsMixin], initialize: function (el, geojson) { + this.sync = new U.SyncEngine(this) // Locale name (pt_PT, en_US…) // To be used for Django localization if (geojson.properties.locale) L.setLocale(geojson.properties.locale) @@ -205,6 +206,13 @@ U.Map = L.Map.extend({ this.editTools = new U.Editable(this) this.renderEditToolbar() } + if (!U.Utils.isObject(this.options.overlay)) { + this.options.overlay = {} + } + if (!U.Utils.isObject(this.options.tilelayer)) { + this.options.tilelayer = {} + } + this.initShortcuts() this.onceDataLoaded(function () { const slug = L.Util.queryString('feature') @@ -246,6 +254,26 @@ U.Map = L.Map.extend({ this.on('click contextmenu.show', this.closeInplaceToolbar) }, + initSyncEngine: async function () { + if (this.options.websocketEnabled == false) return + console.log('this.options.syncEnabled', this.options.syncEnabled) + if (this.options.syncEnabled != true) { + this.sync.stop() + } else { + const ws_token_uri = this.urls.get('map_websocket_auth_token', { + map_id: this.options.umap_id, + }) + await this.sync.authenticate(ws_token_uri, this.server) + } + }, + + getSyncMetadata: function () { + return { + engine: this.sync, + subject: 'map', + } + }, + render: function (fields) { let impacts = U.Utils.getImpactsFromSchema(fields) @@ -269,6 +297,8 @@ U.Map = L.Map.extend({ case 'bounds': this.handleLimitBounds() break + case 'sync': + this.initSyncEngine() } } }, @@ -774,11 +804,11 @@ U.Map = L.Map.extend({ return L.Map.prototype.setMaxBounds.call(this, bounds) }, - createDataLayer: function (datalayer) { + createDataLayer: function (datalayer, sync) { datalayer = datalayer || { name: `${L._('Layer')} ${this.datalayers_index.length + 1}`, } - return new U.DataLayer(this, datalayer) + return new U.DataLayer(this, datalayer, sync) }, newDataLayer: function () { @@ -1011,7 +1041,7 @@ U.Map = L.Map.extend({ formData.append('center', JSON.stringify(this.geometry())) formData.append('settings', JSON.stringify(geojson)) const uri = this.urls.get('map_save', { map_id: this.options.umap_id }) - const [data, response, error] = await this.server.post(uri, {}, formData) + const [data, _, error] = await this.server.post(uri, {}, formData) // FIXME: login_required response will not be an error, so it will not // stop code while it should if (!error) { @@ -1279,9 +1309,6 @@ U.Map = L.Map.extend({ }, _editTilelayer: function (container) { - if (!U.Utils.isObject(this.options.tilelayer)) { - this.options.tilelayer = {} - } const tilelayerFields = [ [ 'options.tilelayer.name', @@ -1329,9 +1356,6 @@ U.Map = L.Map.extend({ }, _editOverlay: function (container) { - if (!U.Utils.isObject(this.options.overlay)) { - this.options.overlay = {} - } const overlayFields = [ [ 'options.overlay.url_template', @@ -1413,6 +1437,14 @@ U.Map = L.Map.extend({ this.options.limitBounds.north = L.Util.formatNum(bounds.getNorth()) this.options.limitBounds.east = L.Util.formatNum(bounds.getEast()) boundsBuilder.fetchAll() + + const { subject, metadata, engine } = this.getSyncMetadata() + engine.update( + subject, + metadata, + 'options.limitBounds', + this.options.limitBounds + ) this.isDirty = true this.handleLimitBounds() }, @@ -1468,6 +1500,12 @@ U.Map = L.Map.extend({ slideshow.appendChild(slideshowBuilder.build()) }, + _editSync: function (container) { + const sync = L.DomUtil.createFieldset(container, L._('Real-time collaboration')) + const builder = new U.FormBuilder(this, ['options.syncEnabled']) + sync.appendChild(builder.build()) + }, + _advancedActions: function (container) { const advancedActions = L.DomUtil.createFieldset(container, L._('Advanced actions')) const advancedButtons = L.DomUtil.create('div', 'button-bar half', advancedActions) @@ -1513,9 +1551,10 @@ U.Map = L.Map.extend({ editCaption: function () { if (!this.editEnabled) return if (this.options.editMode !== 'advanced') return - const container = L.DomUtil.create('div', 'umap-edit-container'), - metadataFields = ['options.name', 'options.description'], - title = L.DomUtil.create('h3', '', container) + const container = L.DomUtil.create('div', 'umap-edit-container') + const metadataFields = ['options.name', 'options.description'] + + const title = L.DomUtil.create('h3', '', container) title.textContent = L._('Edit map details') const builder = new U.FormBuilder(this, metadataFields, { className: 'map-metadata', @@ -1549,6 +1588,9 @@ U.Map = L.Map.extend({ this._editOverlay(container) this._editBounds(container) this._editSlideshow(container) + if (this.options.websocketEnabled) { + this._editSync(container) + } this._advancedActions(container) this.editPanel.open({ content: container, className: 'dark' }) @@ -1559,6 +1601,7 @@ U.Map = L.Map.extend({ this.editEnabled = true this.drop.enable() this.fire('edit:enabled') + this.initSyncEngine() }, disableEdit: function () { @@ -1570,6 +1613,7 @@ U.Map = L.Map.extend({ this.fire('edit:disabled') this.editPanel.close() this.fullPanel.close() + this.sync.stop() }, hasEditMode: function () { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 8d4ca998f..f7188bd61 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -60,6 +60,9 @@ U.Layer.Cluster = L.MarkerClusterGroup.extend({ initialize: function (datalayer) { this.datalayer = datalayer + if (!U.Utils.isObject(this.datalayer.options.cluster)) { + this.datalayer.options.cluster = {} + } const options = { polygonOptions: { color: this.datalayer.getColor(), @@ -97,9 +100,6 @@ U.Layer.Cluster = L.MarkerClusterGroup.extend({ }, getEditableOptions: function () { - if (!U.Utils.isObject(this.datalayer.options.cluster)) { - this.datalayer.options.cluster = {} - } return [ [ 'options.cluster.radius', @@ -179,12 +179,18 @@ U.Layer.Choropleth = L.FeatureGroup.extend({ return +feature.properties[key] // TODO: should we catch values non castable to int ? }, - computeBreaks: function () { + getValues: function () { const values = [] this.datalayer.eachLayer((layer) => { let value = this._getValue(layer) if (!isNaN(value)) values.push(value) }) + return values + }, + + computeBreaks: function () { + const values = this.getValues() + if (!values.length) { this.options.breaks = [] this.options.colors = [] @@ -351,6 +357,9 @@ U.Layer.Heat = L.HeatLayer.extend({ initialize: function (datalayer) { this.datalayer = datalayer L.HeatLayer.prototype.initialize.call(this, [], this.datalayer.options.heat) + if (!U.Utils.isObject(this.datalayer.options.heat)) { + this.datalayer.options.heat = {} + } }, addLayer: function (layer) { @@ -383,9 +392,6 @@ U.Layer.Heat = L.HeatLayer.extend({ }, getEditableOptions: function () { - if (!U.Utils.isObject(this.datalayer.options.heat)) { - this.datalayer.options.heat = {} - } return [ [ 'options.heat.radius', @@ -463,8 +469,8 @@ U.Layer.Heat = L.HeatLayer.extend({ this._latlngs[i].alt !== undefined ? this._latlngs[i].alt : this._latlngs[i][2] !== undefined - ? +this._latlngs[i][2] - : 1 + ? +this._latlngs[i][2] + : 1 grid[y] = grid[y] || [] cell = grid[y][x] @@ -515,7 +521,7 @@ U.DataLayer = L.Evented.extend({ editMode: 'advanced', }, - initialize: function (map, data) { + initialize: function (map, data, sync) { this.map = map this._index = Array() this._layers = {} @@ -570,6 +576,10 @@ U.DataLayer = L.Evented.extend({ } this.setUmapId(data.id) this.setOptions(data) + + if (!U.Utils.isObject(this.options.remoteData)) { + this.options.remoteData = {} + } // Retrocompat if (this.options.remoteData && this.options.remoteData.from) { this.options.fromZoom = this.options.remoteData.from @@ -595,6 +605,21 @@ U.DataLayer = L.Evented.extend({ // be in the "forced visibility" mode if (this.autoLoaded()) this.map.on('zoomend', this.onZoomEnd, this) this.on('datachanged', this.map.onDataLayersChanged, this.map) + + if (sync !== false) { + const { engine, subject, metadata } = this.getSyncMetadata() + engine.upsert(subject, metadata, this.options) + } + }, + + getSyncMetadata: function () { + return { + engine: this.map.sync, + subject: 'datalayer', + metadata: { + id: this.umap_id, + }, + } }, render: function (fields, builder) { @@ -613,6 +638,7 @@ U.DataLayer = L.Evented.extend({ fields.forEach((field) => { this.layer.onEdit(field, builder) }) + this.redraw() this.show() break case 'remote-data': @@ -1003,22 +1029,53 @@ U.DataLayer = L.Evented.extend({ const features = geojson instanceof Array ? geojson : geojson.features let i let len - let latlng - let latlngs if (features) { U.Utils.sortFeatures(features, this.map.getOption('sortKey'), L.lang) for (i = 0, len = features.length; i < len; i++) { this.geojsonToFeatures(features[i]) } - return this + return this // Why returning "this" ? } const geometry = geojson.type === 'Feature' ? geojson.geometry : geojson + + let feature = this.geometryToFeature({ geometry, geojson }) + if (feature) { + this.addLayer(feature) + feature.onCommit() + return feature + } + }, + + /** + * Create or update Leaflet features from GeoJSON geometries. + * + * If no `feature` is provided, a new feature will be created. + * If `feature` is provided, it will be updated with the passed geometry. + * + * GeoJSON and Leaflet use incompatible formats to encode coordinates. + * This method takes care of the convertion. + * + * @param geometry GeoJSON geometry field + * @param geojson Enclosing GeoJSON. If none is provided, a new one will + * be created + * @param id Id of the feature + * @param feature Leaflet feature that should be updated with the new geometry + * @returns Leaflet feature. + */ + geometryToFeature: function ({ + geometry, + geojson = null, + id = null, + feature = null, + } = {}) { if (!geometry) return // null geometry is valid geojson. const coords = geometry.coordinates - let layer - let tmp + let latlng, latlngs + + // Create a default geojson if none is provided + if (geojson === undefined) geojson = { type: 'Feature', geometry: geometry } switch (geometry.type) { case 'Point': @@ -1028,8 +1085,11 @@ U.DataLayer = L.Evented.extend({ console.error('Invalid latlng object from', coords) break } - layer = this._pointToLayer(geojson, latlng) - break + if (feature) { + feature.setLatLng(latlng) + return feature + } + return this._pointToLayer(geojson, latlng, id) case 'MultiLineString': case 'LineString': @@ -1038,14 +1098,20 @@ U.DataLayer = L.Evented.extend({ geometry.type === 'LineString' ? 0 : 1 ) if (!latlngs.length) break - layer = this._lineToLayer(geojson, latlngs) - break + if (feature) { + feature.setLatLngs(latlngs) + return feature + } + return this._lineToLayer(geojson, latlngs, id) case 'MultiPolygon': case 'Polygon': latlngs = L.GeoJSON.coordsToLatLngs(coords, geometry.type === 'Polygon' ? 1 : 2) - layer = this._polygonToLayer(geojson, latlngs) - break + if (feature) { + feature.setLatLngs(latlngs) + return feature + } + return this._polygonToLayer(geojson, latlngs, id) case 'GeometryCollection': return this.geojsonToFeatures(geometry.geometries) @@ -1057,30 +1123,31 @@ U.DataLayer = L.Evented.extend({ level: 'error', }) } - if (layer) { - this.addLayer(layer) - return layer - } }, - _pointToLayer: function (geojson, latlng) { - return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }) + _pointToLayer: function (geojson, latlng, id) { + return new U.Marker(this.map, latlng, { geojson: geojson, datalayer: this }, id) }, - _lineToLayer: function (geojson, latlngs) { - return new U.Polyline(this.map, latlngs, { - geojson: geojson, - datalayer: this, - color: null, - }) + _lineToLayer: function (geojson, latlngs, id) { + return new U.Polyline( + this.map, + latlngs, + { + geojson: geojson, + datalayer: this, + color: null, + }, + id + ) }, - _polygonToLayer: function (geojson, latlngs) { + _polygonToLayer: function (geojson, latlngs, id) { // Ensure no empty hole // for (let i = latlngs.length - 1; i > 0; i--) { // if (!latlngs.slice()[i].length) latlngs.splice(i, 1); // } - return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }) + return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }, id) }, importRaw: function (raw, type) { @@ -1301,9 +1368,12 @@ U.DataLayer = L.Evented.extend({ ) popupFieldset.appendChild(builder.build()) + // XXX I'm not sure **why** this is needed (as it's set during `this.initialize`) + // but apparently it's needed. if (!U.Utils.isObject(this.options.remoteData)) { this.options.remoteData = {} } + const remoteDataFields = [ [ 'options.remoteData.url', @@ -1537,6 +1607,12 @@ U.DataLayer = L.Evented.extend({ return this._layers[id] }, + // TODO Add an index + // For now, iterate on all the features. + getFeatureById: function (id) { + return Object.values(this._layers).find((feature) => feature.id === id) + }, + getNextFeature: function (feature) { const id = this._index.indexOf(L.stamp(feature)) const nextId = this._index[id + 1] diff --git a/umap/static/umap/js/umap.permissions.js b/umap/static/umap/js/umap.permissions.js index 663d9528c..87678e7be 100644 --- a/umap/static/umap/js/umap.permissions.js +++ b/umap/static/umap/js/umap.permissions.js @@ -109,6 +109,7 @@ U.MapPermissions = L.Class.extend({ { handler: 'ManageEditors', label: L._("Map's editors") }, ]) } + const builder = new U.FormBuilder(this, fields) const form = builder.build() container.appendChild(form) diff --git a/umap/static/umap/unittests/sync.js b/umap/static/umap/unittests/sync.js new file mode 100644 index 000000000..a7eb464b8 --- /dev/null +++ b/umap/static/umap/unittests/sync.js @@ -0,0 +1,107 @@ +import { describe, it } from 'mocha' +import sinon from 'sinon' + +import pkg from 'chai' +const { expect } = pkg + +import { MapUpdater } from '../js/modules/sync/updaters.js' +import { MessagesDispatcher, SyncEngine } from '../js/modules/sync/engine.js' + +describe('SyncEngine', () => { + it('should initialize methods even before start', function () { + const engine = new SyncEngine({}) + engine.upsert() + engine.update() + engine.delete() + }) +}) + +describe('MessageDispatcher', () => { + describe('#dispatch', function () { + it('should raise an error on unknown updater', function () { + const dispatcher = new MessagesDispatcher({}) + expect(() => { + dispatcher.dispatch({ + kind: 'operation', + subject: 'unknown', + metadata: {}, + }) + }).to.throw(Error) + }) + it('should produce an error on malformated messages', function () { + const dispatcher = new MessagesDispatcher({}) + expect(() => { + dispatcher.dispatch({ + yeah: 'yeah', + payload: { foo: 'bar' }, + }) + }).to.throw(Error) + }) + it('should raise an unknown operations', function () { + const dispatcher = new MessagesDispatcher({}) + expect(() => { + dispatcher.dispatch({ + kind: 'something-else', + }) + }).to.throw(Error) + }) + }) +}) + +describe('Updaters', () => { + describe('BaseUpdater', function () { + let updater + let map + let obj + + this.beforeEach(function () { + map = {} + updater = new MapUpdater(map) + obj = {} + }) + it('should be able to set object properties', function () { + let obj = {} + updater.updateObjectValue(obj, 'foo', 'foo') + expect(obj).deep.equal({ foo: 'foo' }) + }) + + it('should be able to set object properties recursively on existing objects', function () { + let obj = { foo: {} } + updater.updateObjectValue(obj, 'foo.bar', 'foo') + expect(obj).deep.equal({ foo: { bar: 'foo' } }) + }) + + it('should be able to set object properties recursively on deep objects', function () { + let obj = { foo: { bar: { baz: {} } } } + updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value') + expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) + }) + + it('should be able to replace object properties recursively on deep objects', function () { + let obj = { foo: { bar: { baz: { test: 'test' } } } } + updater.updateObjectValue(obj, 'foo.bar.baz.test', 'value') + expect(obj).deep.equal({ foo: { bar: { baz: { test: 'value' } } } }) + }) + + it('should not set object properties recursively on non-existing objects', function () { + let obj = { foo: {} } + updater.updateObjectValue(obj, 'bar.bar', 'value') + + expect(obj).deep.equal({ foo: {} }) + }) + + it('should delete keys for undefined values', function () { + let obj = { foo: 'foo' } + updater.updateObjectValue(obj, 'foo', undefined) + + expect(obj).deep.equal({}) + }) + + it('should delete keys for undefined values, recursively', function () { + let obj = { foo: { bar: 'bar' } } + updater.updateObjectValue(obj, 'foo.bar', undefined) + + expect(obj).deep.equal({ foo: {} }) + }) + }) +}) diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index c405b4d49..8c18fc1b0 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -461,13 +461,12 @@ describe('Utils', function () { }) describe('#normalize()', function () { - it('should remove accents', - function () { - // French é - assert.equal(Utils.normalize('aéroport'), 'aeroport') - // American é - assert.equal(Utils.normalize('aéroport'), 'aeroport') - }) + it('should remove accents', function () { + // French é + assert.equal(Utils.normalize('aéroport'), 'aeroport') + // American é + assert.equal(Utils.normalize('aéroport'), 'aeroport') + }) }) describe('#sortFeatures()', function () { @@ -530,17 +529,17 @@ describe('Utils', function () { }) }) - describe("#copyJSON", function () { + describe('#copyJSON', function () { it('should actually copy the JSON', function () { - let originalJSON = { "some": "json" } + let originalJSON = { some: 'json' } let returned = Utils.CopyJSON(originalJSON) // Change the original JSON - originalJSON["anotherKey"] = "value" + originalJSON['anotherKey'] = 'value' // ensure the two aren't the same object assert.notEqual(returned, originalJSON) - assert.deepEqual(returned, { "some": "json" }) + assert.deepEqual(returned, { some: 'json' }) }) }) @@ -597,21 +596,115 @@ describe('Utils', function () { assert.deepEqual(getImpactsFromSchema(['foo', 'bar', 'baz'], schema), ['A', 'B']) }) }) - describe('parseNaiveDate', () => { + + describe('#propertyBelongsTo', () => { + it('should return false on unexisting property', function () { + let schema = {} + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), false) + }) + it('should return false if subject is not listed', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), false) + }) + it('should return true if subject is listed', function () { + let schema = { + foo: { belongsTo: ['map'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('foo', 'map', schema), true) + }) + it('should remove the `options.` prefix before checking', function () { + let schema = { + foo: { belongsTo: ['map'] }, + } + assert.deepEqual(Utils.propertyBelongsTo('options.foo', 'map', schema), true) + }) + + it('Accepts setting properties on objects', function () { + let schema = { + foo: { + type: Object, + belongsTo: ['map'], + }, + } + assert.deepEqual(Utils.propertyBelongsTo('options.foo.name', 'map', schema), true) + }) + + it('Rejects setting properties on non-objects', function () { + let schema = { + foo: { + type: String, + belongsTo: ['map'], + }, + } + assert.deepEqual( + Utils.propertyBelongsTo('options.foo.name', 'map', schema), + false + ) + }) + + it('when subject = feature, should filter the `properties.`', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('properties.foo', 'feature', schema), + true + ) + }) + + it('On features, should filter the `_umap_options.`', function () { + let schema = { + foo: { belongsTo: ['feature'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('properties._umap_options.foo', 'feature', schema), + true + ) + }) + + it('Should accept options.tilelayer.url_template', function () { + let schema = { + tilelayer: { type: Object, belongsTo: ['map'] }, + } + assert.deepEqual( + Utils.propertyBelongsTo('options.tilelayer.url_template', 'map', schema), + true + ) + }) + }) + + describe('#parseNaiveDate', () => { it('should parse a date', () => { - assert.equal(Utils.parseNaiveDate("2024/03/04").toISOString(), "2024-03-04T00:00:00.000Z") + assert.equal( + Utils.parseNaiveDate('2024/03/04').toISOString(), + '2024-03-04T00:00:00.000Z' + ) }) it('should parse a datetime', () => { - assert.equal(Utils.parseNaiveDate("2024/03/04 12:13:14").toISOString(), "2024-03-04T00:00:00.000Z") + assert.equal( + Utils.parseNaiveDate('2024/03/04 12:13:14').toISOString(), + '2024-03-04T00:00:00.000Z' + ) }) it('should parse an iso datetime', () => { - assert.equal(Utils.parseNaiveDate("2024-03-04T00:00:00.000Z").toISOString(), "2024-03-04T00:00:00.000Z") + assert.equal( + Utils.parseNaiveDate('2024-03-04T00:00:00.000Z').toISOString(), + '2024-03-04T00:00:00.000Z' + ) }) it('should parse a GMT time', () => { - assert.equal(Utils.parseNaiveDate("04 Mar 2024 00:12:00 GMT").toISOString(), "2024-03-04T00:00:00.000Z") + assert.equal( + Utils.parseNaiveDate('04 Mar 2024 00:12:00 GMT').toISOString(), + '2024-03-04T00:00:00.000Z' + ) }) it('should parse a GMT time with explicit timezone', () => { - assert.equal(Utils.parseNaiveDate("Thu, 04 Mar 2024 00:00:00 GMT+0300").toISOString(), "2024-03-03T00:00:00.000Z") + assert.equal( + Utils.parseNaiveDate('Thu, 04 Mar 2024 00:00:00 GMT+0300').toISOString(), + '2024-03-03T00:00:00.000Z' + ) }) }) }) diff --git a/umap/tests/integration/conftest.py b/umap/tests/integration/conftest.py index 34e3545be..06367086b 100644 --- a/umap/tests/integration/conftest.py +++ b/umap/tests/integration/conftest.py @@ -1,7 +1,9 @@ import os +from pathlib import Path import pytest from playwright.sync_api import expect +from xprocess import ProcessStarter @pytest.fixture(autouse=True) @@ -33,3 +35,21 @@ def do_login(user): return page return do_login + + +@pytest.fixture() +def websocket_server(xprocess): + class Starter(ProcessStarter): + settings_path = ( + (Path(__file__).parent.parent / "settings.py").absolute().as_posix() + ) + os.environ["UMAP_SETTINGS"] = settings_path + # env = {"UMAP_SETTINGS": settings_path} + pattern = "Waiting for connections*" + args = ["python", "-m", "umap.ws"] + timeout = 1 + terminate_on_interrupt = True + + xprocess.ensure("websocket_server", Starter) + yield + xprocess.getinfo("websocket_server").terminate() diff --git a/umap/tests/integration/test_collaborative_editing.py b/umap/tests/integration/test_optimistic_merge.py similarity index 97% rename from umap/tests/integration/test_collaborative_editing.py rename to umap/tests/integration/test_optimistic_merge.py index d441c68e5..ee7a1e6a3 100644 --- a/umap/tests/integration/test_collaborative_editing.py +++ b/umap/tests/integration/test_optimistic_merge.py @@ -5,16 +5,16 @@ from playwright.sync_api import expect -from umap.models import DataLayer, Map +from umap.models import DataLayer from ..base import DataLayerFactory, MapFactory DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*") -def test_collaborative_editing_create_markers(context, live_server, tilelayer): +def test_created_markers_are_merged(context, live_server, tilelayer): # Let's create a new map with an empty datalayer - map = MapFactory(name="collaborative editing") + map = MapFactory(name="server-side merge") datalayer = DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) # Now navigate to this map and create marker @@ -146,7 +146,7 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer): def test_empty_datalayers_can_be_merged(context, live_server, tilelayer): # Let's create a new map with an empty datalayer - map = MapFactory(name="collaborative editing") + map = MapFactory(name="server-side merge") DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) # Open two tabs at the same time, on the same empty map @@ -202,7 +202,7 @@ def test_empty_datalayers_can_be_merged(context, live_server, tilelayer): def test_same_second_edit_doesnt_conflict(context, live_server, tilelayer): # Let's create a new map with an empty datalayer - map = MapFactory(name="collaborative editing") + map = MapFactory(name="server-side merge") datalayer = DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) # Open the created map on two pages. diff --git a/umap/tests/integration/test_websocket_sync.py b/umap/tests/integration/test_websocket_sync.py new file mode 100644 index 000000000..485f0b05a --- /dev/null +++ b/umap/tests/integration/test_websocket_sync.py @@ -0,0 +1,216 @@ +import re + +from playwright.sync_api import expect + +from umap.models import Map + +from ..base import DataLayerFactory, MapFactory + +DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*") + + +def test_websocket_connection_can_sync_markers( + context, live_server, websocket_server, tilelayer +): + map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) + map.settings["properties"]["syncEnabled"] = True + map.save() + DataLayerFactory(map=map, data={}) + + # Create two tabs + peerA = context.new_page() + peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + peerB = context.new_page() + peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + a_marker_pane = peerA.locator(".leaflet-marker-pane > div") + b_marker_pane = peerB.locator(".leaflet-marker-pane > div") + expect(a_marker_pane).to_have_count(0) + expect(b_marker_pane).to_have_count(0) + + # Add a marker from peer A + a_create_marker = peerA.get_by_title("Draw a marker") + expect(a_create_marker).to_be_visible() + a_create_marker.click() + + a_map_el = peerA.locator("#map") + a_map_el.click(position={"x": 220, "y": 220}) + expect(a_marker_pane).to_have_count(1) + expect(b_marker_pane).to_have_count(1) + + # Add a second marker from peer B + b_create_marker = peerB.get_by_title("Draw a marker") + expect(b_create_marker).to_be_visible() + b_create_marker.click() + + b_map_el = peerB.locator("#map") + b_map_el.click(position={"x": 225, "y": 225}) + expect(a_marker_pane).to_have_count(2) + expect(b_marker_pane).to_have_count(2) + + # FIXME: find a better locator for markers + b_first_marker = peerB.locator("div:nth-child(4) > div:nth-child(2)").first + a_first_marker = peerA.locator("div:nth-child(4) > div:nth-child(2)").first + + # Drag a marker on peer B and check that it moved on peer A + a_first_marker.bounding_box() == b_first_marker.bounding_box() + b_old_bbox = b_first_marker.bounding_box() + b_first_marker.drag_to(b_map_el, target_position={"x": 250, "y": 250}) + + assert b_old_bbox is not b_first_marker.bounding_box() + a_first_marker.bounding_box() == b_first_marker.bounding_box() + + # Delete a marker from peer A and check it's been deleted on peer B + a_first_marker.click(button="right") + peerA.on("dialog", lambda dialog: dialog.accept()) + peerA.get_by_role("link", name="Delete this feature").click() + expect(a_marker_pane).to_have_count(1) + expect(b_marker_pane).to_have_count(1) + + +def test_websocket_connection_can_sync_polygons( + context, live_server, websocket_server, tilelayer +): + map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) + map.settings["properties"]["syncEnabled"] = True + map.save() + DataLayerFactory(map=map, data={}) + + # Create two tabs + peerA = context.new_page() + peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + peerB = context.new_page() + peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + b_map_el = peerB.locator("#map") + + # Click on the Draw a polygon button on a new map. + create_line = peerA.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_line.click() + + a_polygons = peerA.locator(".leaflet-overlay-pane path[fill='DarkBlue']") + b_polygons = peerB.locator(".leaflet-overlay-pane path[fill='DarkBlue']") + expect(a_polygons).to_have_count(0) + expect(b_polygons).to_have_count(0) + + # Click on the map, it will create a polygon. + map = peerA.locator("#map") + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 200}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 100}) + + # It is created on peerA, but not yet synced + expect(a_polygons).to_have_count(1) + expect(b_polygons).to_have_count(0) + + # Escaping the edition syncs + peerA.keyboard.press("Escape") + expect(a_polygons).to_have_count(1) + expect(b_polygons).to_have_count(1) + + # change the geometry by moving a point on peer B + a_polygon = peerA.locator("path") + b_polygon = peerB.locator("path") + b_polygon_bbox_t1 = b_polygon.bounding_box() + a_polygon_bbox_t1 = a_polygon.bounding_box() + assert b_polygon_bbox_t1 == a_polygon_bbox_t1 + + b_polygon.click() + peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click() + + edited_vertex = peerB.locator("div:nth-child(6)").first + edited_vertex.drag_to(b_map_el, target_position={"x": 233, "y": 126}) + peerB.keyboard.press("Escape") + + b_polygon_bbox_t2 = b_polygon.bounding_box() + a_polygon_bbox_t2 = a_polygon.bounding_box() + + assert b_polygon_bbox_t2 != b_polygon_bbox_t1 + assert b_polygon_bbox_t2 == a_polygon_bbox_t2 + + # Move the polygon on peer B and check it moved also on peer A + b_polygon.click() + peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click() + + b_polygon.drag_to(b_map_el, target_position={"x": 400, "y": 400}) + peerB.keyboard.press("Escape") + b_polygon_bbox_t3 = b_polygon.bounding_box() + a_polygon_bbox_t3 = a_polygon.bounding_box() + + assert b_polygon_bbox_t3 != b_polygon_bbox_t2 + assert b_polygon_bbox_t3 == a_polygon_bbox_t3 + + # Delete a polygon from peer A and check it's been deleted on peer B + a_polygon.click(button="right") + peerA.on("dialog", lambda dialog: dialog.accept()) + peerA.get_by_role("link", name="Delete this feature").click() + expect(a_polygons).to_have_count(0) + expect(b_polygons).to_have_count(0) + # Add properties / option and check + # Map: everything is in properties (in geojson, but in options in the JS) + # Datalayer: everything is in options, but stored in `_umap_options` on the datalayer object + # Features: properties are not limited (that's data). Everything is in properties. + # In properties there is _umap_option, to store everythign that's not user data. + # FIXME Save and check + + +def test_websocket_connection_can_sync_map_properties( + context, live_server, websocket_server, tilelayer +): + map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) + map.settings["properties"]["syncEnabled"] = True + map.save() + DataLayerFactory(map=map, data={}) + + # Create two tabs + peerA = context.new_page() + peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + peerB = context.new_page() + peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + # Name change is synced + peerA.get_by_role("link", name="Edit map name and caption").click() + peerA.locator('input[name="name"]').click() + peerA.locator('input[name="name"]').fill("it syncs!") + + expect(peerB.locator(".map-name").last).to_have_text("it syncs!") + + # Zoom control is synced + peerB.get_by_role("link", name="Map advanced properties").click() + peerB.locator("summary").filter(has_text="User interface options").click() + peerB.locator("div").filter( + has_text=re.compile(r"^Display the zoom control") + ).locator("label").nth(2).click() + + expect(peerA.locator(".leaflet-control-zoom")).to_be_hidden() + + +def test_websocket_connection_can_sync_datalayer_properties( + context, live_server, websocket_server, tilelayer +): + map = MapFactory(name="sync", edit_status=Map.ANONYMOUS) + map.settings["properties"]["syncEnabled"] = True + map.save() + DataLayerFactory(map=map, data={}) + + # Create two tabs + peerA = context.new_page() + peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + peerB = context.new_page() + peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + # Layer addition, name and type are synced + peerA.get_by_role("link", name="Manage layers").click() + peerA.get_by_role("button", name="Add a layer").click() + peerA.locator('input[name="name"]').click() + peerA.locator('input[name="name"]').fill("synced layer!") + peerA.get_by_role("combobox").select_option("Choropleth") + peerA.locator("body").press("Escape") + + peerB.get_by_role("link", name="Manage layers").click() + peerB.get_by_role("button", name="Edit").first.click() + expect(peerB.locator('input[name="name"]')).to_have_value("synced layer!") + expect(peerB.get_by_role("combobox")).to_have_value("Choropleth") diff --git a/umap/tests/settings.py b/umap/tests/settings.py index 479737a95..4a5f0130c 100644 --- a/umap/tests/settings.py +++ b/umap/tests/settings.py @@ -24,3 +24,8 @@ PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] + + +WEBSOCKET_ENABLED = True +WEBSOCKET_PORT = "8010" +WEBSOCKET_URI = "ws://localhost:8010" diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index 86904cdc2..b180d315a 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -5,6 +5,7 @@ import pytest from django.conf import settings from django.contrib.auth import get_user, get_user_model +from django.core.signing import TimestampSigner from django.test import RequestFactory from django.urls import reverse from django.utils.timezone import make_aware @@ -430,3 +431,55 @@ def test_home_feed(client, settings, user, tilelayer): assert "A public map starred by non staff" not in content assert "A private map starred by staff" not in content assert "A reserved map starred by staff" not in content + + +@pytest.mark.django_db +def test_websocket_token_returns_login_required_if_not_connected(client, user, map): + token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id}) + resp = client.get(token_url) + assert "login_required" in resp.json() + + +@pytest.mark.django_db +def test_websocket_token_returns_403_if_unauthorized(client, user, user2, map): + client.login(username=map.owner.username, password="123123") + map.owner = user2 + map.save() + + token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id}) + resp = client.get(token_url) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_websocket_token_is_generated_for_anonymous(client, user, user2, map): + map.edit_status = Map.ANONYMOUS + map.save() + + token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id}) + resp = client.get(token_url) + token = resp.json().get("token") + assert TimestampSigner().unsign_object(token, max_age=30) + + +@pytest.mark.django_db +def test_websocket_token_returns_a_valid_token_when_authorized(client, user, map): + client.login(username=map.owner.username, password="123123") + token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id}) + resp = client.get(token_url) + assert resp.status_code == 200 + token = resp.json().get("token") + assert TimestampSigner().unsign_object(token, max_age=30) + + +@pytest.mark.django_db +def test_websocket_token_is_generated_for_editors(client, user, user2, map): + map.edit_status = Map.EDITORS + map.editors.add(user2) + map.save() + + assert client.login(username=user2.username, password="456456") + token_url = reverse("map_websocket_auth_token", kwargs={"map_id": map.id}) + resp = client.get(token_url) + token = resp.json().get("token") + assert TimestampSigner().unsign_object(token, max_age=30) diff --git a/umap/urls.py b/umap/urls.py index 28564b557..ecc9353ae 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -155,6 +155,11 @@ views.UpdateDataLayerPermissions.as_view(), name="datalayer_permissions", ), + path( + "map//ws-token/", + views.get_websocket_auth_token, + name="map_websocket_auth_token", + ), ] if settings.DEFAULT_FROM_EMAIL: map_urls.append( diff --git a/umap/views.py b/umap/views.py index c8806b1cf..2819d4109 100644 --- a/umap/views.py +++ b/umap/views.py @@ -24,7 +24,7 @@ from django.core.exceptions import PermissionDenied from django.core.mail import send_mail from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.core.signing import BadSignature, Signer +from django.core.signing import BadSignature, Signer, TimestampSigner from django.core.validators import URLValidator, ValidationError from django.http import ( Http404, @@ -40,7 +40,6 @@ from django.urls import resolve, reverse, reverse_lazy from django.utils import translation from django.utils.encoding import smart_bytes -from django.utils.http import http_date from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control @@ -504,6 +503,8 @@ def get_map_properties(self): ], "umap_version": VERSION, "featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS, + "websocketEnabled": settings.WEBSOCKET_ENABLED, + "websocketURI": settings.WEBSOCKET_URI, } created = bool(getattr(self, "object", None)) if (created and self.object.owner) or (not created and not user.is_anonymous): @@ -624,8 +625,8 @@ def get_canonical_url(self): def get_datalayers(self): return [ - l.metadata(self.request.user, self.request) - for l in self.object.datalayer_set.all() + dl.metadata(self.request.user, self.request) + for dl in self.object.datalayer_set.all() ] @property @@ -778,6 +779,38 @@ def form_valid(self, form): return response +def get_websocket_auth_token(request, map_id, map_inst): + """Return an signed authentication token for the currently + connected user, allowing edits for this map over WebSocket. + + If the user is anonymous, return a signed token with the map id. + + The returned token is a signed object with the following keys: + - user: user primary key OR "anonymous" + - map_id: the map id + - permissions: a list of allowed permissions for this user and this map + """ + map_object: Map = Map.objects.get(pk=map_id) + + if map_object.can_edit(request.user, request): + permissions = ["edit"] + if map_object.is_owner(request.user, request): + permissions.append("owner") + + if request.user.is_authenticated: + user = request.user.pk + else: + user = "anonymous" + signed_token = TimestampSigner().sign_object( + {"user": user, "map_id": map_id, "permissions": permissions} + ) + return simple_json_response(token=signed_token) + else: + return HttpResponseForbidden( + _("You cannot edit this map with your current permissions.") + ) + + class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView): model = Map form_class = MapSettingsForm diff --git a/umap/ws.py b/umap/ws.py new file mode 100644 index 000000000..30b0ee018 --- /dev/null +++ b/umap/ws.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python + +import asyncio +from collections import defaultdict +from typing import Literal, Optional + +import django +import websockets +from django.conf import settings +from django.core.signing import TimestampSigner +from pydantic import BaseModel, ValidationError +from websockets import WebSocketClientProtocol +from websockets.server import serve + +# This needs to run before the django-specific imports +# See https://docs.djangoproject.com/en/5.0/topics/settings/#calling-django-setup-is-required-for-standalone-django-usage +from umap.settings import settings_as_dict + +settings.configure(**settings_as_dict) +django.setup() + +from umap.models import Map, User # NOQA + +# Contains the list of websocket connections handled by this process. +# It's a mapping of map_id to a set of the active websocket connections +CONNECTIONS = defaultdict(set) + + +class JoinMessage(BaseModel): + kind: str = "join" + token: str + + +class OperationMessage(BaseModel): + kind: str = "operation" + verb: str = Literal["upsert", "update", "delete"] + subject: str = Literal["map", "layer", "feature"] + metadata: Optional[dict] = None + key: Optional[str] = None + value: Optional[str | dict | list] + + +async def join_and_listen( + map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol +): + """Join a "room" whith other connected peers. + + New messages will be broadcasted to other connected peers. + """ + print(f"{user} joined room #{map_id}") + CONNECTIONS[map_id].add(websocket) + try: + async for raw_message in websocket: + # recompute the peers-list at the time of message-sending. + # as doing so beforehand would miss new connections + peers = CONNECTIONS[map_id] - {websocket} + # Only relay valid "operation" messages + try: + OperationMessage.model_validate_json(raw_message) + websockets.broadcast(peers, raw_message) + except ValidationError: + print(raw_message) + finally: + CONNECTIONS[map_id].remove(websocket) + + +async def handler(websocket): + """Main WebSocket handler. + + If permissions are granted, let the peer enter a room. + """ + raw_message = await websocket.recv() + + # The first event should always be 'join' + message: JoinMessage = JoinMessage.model_validate_json(raw_message) + signed = TimestampSigner().unsign_object(message.token, max_age=30) + user, map_id, permissions = signed.values() + + # Check if permissions for this map have been granted by the server + if "edit" in signed["permissions"]: + await join_and_listen(map_id, permissions, user, websocket) + + +async def main(): + if not settings.WEBSOCKET_ENABLED: + print("WEBSOCKET_ENABLED should be set to True to run the WebSocket Server") + exit(1) + + async with serve(handler, settings.WEBSOCKET_HOST, settings.WEBSOCKET_PORT): + print( + ( + f"Waiting for connections on {settings.WEBSOCKET_HOST}:{settings.WEBSOCKET_PORT}" + ) + ) + await asyncio.Future() # run forever + + +asyncio.run(main())