umap-project 2.8.1__py3-none-any.whl → 2.9.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of umap-project might be problematic. Click here for more details.

Files changed (159) hide show
  1. umap/__init__.py +1 -1
  2. umap/asgi.py +12 -7
  3. umap/context_processors.py +1 -0
  4. umap/locale/en/LC_MESSAGES/django.po +102 -59
  5. umap/locale/fr/LC_MESSAGES/django.mo +0 -0
  6. umap/locale/fr/LC_MESSAGES/django.po +105 -61
  7. umap/locale/hu/LC_MESSAGES/django.mo +0 -0
  8. umap/locale/hu/LC_MESSAGES/django.po +10 -10
  9. umap/management/commands/empty_trash.py +12 -1
  10. umap/migrations/0026_datalayer_modified_at_datalayer_share_status.py +26 -0
  11. umap/models.py +23 -3
  12. umap/settings/base.py +4 -1
  13. umap/static/umap/base.css +1 -1
  14. umap/static/umap/content.css +2 -22
  15. umap/static/umap/css/bar.css +7 -10
  16. umap/static/umap/css/form.css +28 -29
  17. umap/static/umap/css/icon.css +8 -2
  18. umap/static/umap/css/panel.css +2 -1
  19. umap/static/umap/css/tooltip.css +33 -31
  20. umap/static/umap/img/16-white.svg +2 -0
  21. umap/static/umap/img/16.svg +1 -1
  22. umap/static/umap/img/providers/bitbucket.png +0 -0
  23. umap/static/umap/img/providers/github.png +0 -0
  24. umap/static/umap/img/providers/keycloak.png +0 -0
  25. umap/static/umap/img/providers/openstreetmap-oauth2.png +0 -0
  26. umap/static/umap/img/providers/twitter-oauth2.png +0 -0
  27. umap/static/umap/img/source/16-white.svg +3 -1
  28. umap/static/umap/img/source/16.svg +1 -1
  29. umap/static/umap/js/components/alerts/alert.js +4 -1
  30. umap/static/umap/js/modules/browser.js +6 -6
  31. umap/static/umap/js/modules/caption.js +30 -7
  32. umap/static/umap/js/modules/data/features.js +21 -24
  33. umap/static/umap/js/modules/data/layer.js +71 -33
  34. umap/static/umap/js/modules/form/builder.js +241 -0
  35. umap/static/umap/js/modules/form/fields.js +1338 -0
  36. umap/static/umap/js/modules/formatter.js +5 -8
  37. umap/static/umap/js/modules/help.js +3 -1
  38. umap/static/umap/js/modules/importer.js +1 -1
  39. umap/static/umap/js/modules/permissions.js +5 -4
  40. umap/static/umap/js/modules/rendering/icon.js +5 -1
  41. umap/static/umap/js/modules/rendering/layers/classified.js +11 -7
  42. umap/static/umap/js/modules/rendering/layers/cluster.js +11 -1
  43. umap/static/umap/js/modules/rendering/map.js +0 -2
  44. umap/static/umap/js/modules/rules.js +2 -1
  45. umap/static/umap/js/modules/schema.js +5 -6
  46. umap/static/umap/js/modules/share.js +3 -3
  47. umap/static/umap/js/modules/sync/engine.js +18 -13
  48. umap/static/umap/js/modules/sync/updaters.js +8 -0
  49. umap/static/umap/js/modules/sync/websocket.js +10 -5
  50. umap/static/umap/js/modules/tableeditor.js +3 -2
  51. umap/static/umap/js/modules/ui/bar.js +17 -9
  52. umap/static/umap/js/modules/ui/base.js +7 -24
  53. umap/static/umap/js/modules/ui/tooltip.js +19 -11
  54. umap/static/umap/js/modules/umap.js +39 -27
  55. umap/static/umap/js/modules/utils.js +196 -12
  56. umap/static/umap/js/umap.controls.js +1 -13
  57. umap/static/umap/locale/br.js +21 -13
  58. umap/static/umap/locale/br.json +21 -13
  59. umap/static/umap/locale/ca.js +12 -4
  60. umap/static/umap/locale/ca.json +12 -4
  61. umap/static/umap/locale/cs_CZ.js +10 -4
  62. umap/static/umap/locale/cs_CZ.json +10 -4
  63. umap/static/umap/locale/de.js +12 -4
  64. umap/static/umap/locale/de.json +12 -4
  65. umap/static/umap/locale/el.js +12 -4
  66. umap/static/umap/locale/el.json +12 -4
  67. umap/static/umap/locale/en.js +9 -4
  68. umap/static/umap/locale/en.json +9 -4
  69. umap/static/umap/locale/es.js +20 -12
  70. umap/static/umap/locale/es.json +20 -12
  71. umap/static/umap/locale/eu.js +12 -4
  72. umap/static/umap/locale/eu.json +12 -4
  73. umap/static/umap/locale/fa_IR.js +12 -4
  74. umap/static/umap/locale/fa_IR.json +12 -4
  75. umap/static/umap/locale/fr.js +10 -5
  76. umap/static/umap/locale/fr.json +10 -5
  77. umap/static/umap/locale/gl.js +353 -345
  78. umap/static/umap/locale/gl.json +353 -345
  79. umap/static/umap/locale/hu.js +34 -26
  80. umap/static/umap/locale/hu.json +34 -26
  81. umap/static/umap/locale/it.js +100 -92
  82. umap/static/umap/locale/it.json +100 -92
  83. umap/static/umap/locale/ms.js +12 -4
  84. umap/static/umap/locale/ms.json +12 -4
  85. umap/static/umap/locale/nl.js +12 -4
  86. umap/static/umap/locale/nl.json +12 -4
  87. umap/static/umap/locale/pl.js +12 -4
  88. umap/static/umap/locale/pl.json +12 -4
  89. umap/static/umap/locale/pt.js +12 -4
  90. umap/static/umap/locale/pt.json +12 -4
  91. umap/static/umap/locale/pt_PT.js +12 -4
  92. umap/static/umap/locale/pt_PT.json +12 -4
  93. umap/static/umap/locale/th_TH.js +12 -4
  94. umap/static/umap/locale/th_TH.json +12 -4
  95. umap/static/umap/locale/zh_TW.js +10 -4
  96. umap/static/umap/locale/zh_TW.json +10 -4
  97. umap/static/umap/map.css +12 -8
  98. umap/static/umap/nav.css +2 -3
  99. umap/static/umap/unittests/utils.js +14 -0
  100. umap/static/umap/vars.css +2 -0
  101. umap/sync/__init__.py +0 -0
  102. umap/sync/app.py +181 -0
  103. umap/sync/payloads.py +49 -0
  104. umap/templates/auth/user_detail.html +4 -0
  105. umap/templates/auth/user_form.html +9 -6
  106. umap/templates/auth/user_stars.html +4 -0
  107. umap/templates/base.html +1 -1
  108. umap/templates/registration/login.html +2 -5
  109. umap/templates/umap/about.html +5 -0
  110. umap/templates/umap/about_summary.html +2 -2
  111. umap/templates/umap/components/provider.html +8 -0
  112. umap/templates/umap/js.html +0 -3
  113. umap/templates/umap/map_detail.html +1 -1
  114. umap/templates/umap/password_change.html +4 -0
  115. umap/templates/umap/password_change_done.html +4 -0
  116. umap/templates/umap/search.html +4 -0
  117. umap/templates/umap/team_confirm_delete.html +4 -0
  118. umap/templates/umap/team_detail.html +4 -0
  119. umap/templates/umap/team_form.html +4 -0
  120. umap/templates/umap/user_dashboard.html +1 -1
  121. umap/templates/umap/user_teams.html +4 -0
  122. umap/tests/base.py +3 -1
  123. umap/tests/integration/conftest.py +16 -23
  124. umap/tests/integration/test_basics.py +2 -2
  125. umap/tests/integration/test_caption.py +1 -0
  126. umap/tests/integration/test_draw_polygon.py +3 -3
  127. umap/tests/integration/test_edit_datalayer.py +1 -1
  128. umap/tests/integration/test_edit_map.py +3 -3
  129. umap/tests/integration/test_edit_polygon.py +1 -1
  130. umap/tests/integration/test_import.py +23 -1
  131. umap/tests/integration/test_optimistic_merge.py +1 -0
  132. umap/tests/integration/test_picto.py +8 -8
  133. umap/tests/integration/test_save.py +1 -0
  134. umap/tests/integration/test_star.py +13 -9
  135. umap/tests/integration/test_tableeditor.py +1 -0
  136. umap/tests/integration/test_websocket_sync.py +112 -33
  137. umap/tests/settings.py +2 -0
  138. umap/tests/test_datalayer.py +2 -3
  139. umap/tests/test_datalayer_views.py +20 -1
  140. umap/tests/test_empty_trash.py +10 -3
  141. umap/tests/test_map_views.py +11 -0
  142. umap/utils.py +24 -11
  143. umap/views.py +37 -6
  144. {umap_project-2.8.1.dist-info → umap_project-2.9.0b0.dist-info}/METADATA +15 -15
  145. {umap_project-2.8.1.dist-info → umap_project-2.9.0b0.dist-info}/RECORD +148 -147
  146. {umap_project-2.8.1.dist-info → umap_project-2.9.0b0.dist-info}/WHEEL +1 -1
  147. umap/management/commands/run_websocket_server.py +0 -23
  148. umap/settings/local_s3.py +0 -45
  149. umap/static/umap/bitbucket.png +0 -0
  150. umap/static/umap/github.png +0 -0
  151. umap/static/umap/js/umap.forms.js +0 -1242
  152. umap/static/umap/keycloak.png +0 -0
  153. umap/static/umap/openstreetmap.png +0 -0
  154. umap/static/umap/twitter.png +0 -0
  155. umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js +0 -468
  156. umap/tests/test_websocket_server.py +0 -22
  157. umap/websocket_server.py +0 -202
  158. {umap_project-2.8.1.dist-info → umap_project-2.9.0b0.dist-info}/entry_points.txt +0 -0
  159. {umap_project-2.8.1.dist-info → umap_project-2.9.0b0.dist-info}/licenses/LICENSE +0 -0
Binary file
Binary file
Binary file
@@ -1,468 +0,0 @@
1
- L.FormBuilder = L.Evented.extend({
2
- options: {
3
- className: 'leaflet-form',
4
- },
5
-
6
- defaultOptions: {
7
- // Eg.:
8
- // name: {label: L._('name')},
9
- // description: {label: L._('description'), handler: 'Textarea'},
10
- // opacity: {label: L._('opacity'), helpText: L._('Opacity, from 0.1 to 1.0 (opaque).')},
11
- },
12
-
13
- initialize: function (obj, fields, options) {
14
- L.setOptions(this, options)
15
- this.obj = obj
16
- this.form = L.DomUtil.create('form', this.options.className)
17
- this.setFields(fields)
18
- if (this.options.id) {
19
- this.form.id = this.options.id
20
- }
21
- if (this.options.className) {
22
- L.DomUtil.addClass(this.form, this.options.className)
23
- }
24
- },
25
-
26
- setFields: function (fields) {
27
- this.fields = fields || []
28
- this.helpers = {}
29
- },
30
-
31
- build: function () {
32
- this.form.innerHTML = ''
33
- for (const idx in this.fields) {
34
- this.buildField(this.fields[idx])
35
- }
36
- this.on('postsync', this.onPostSync)
37
- return this.form
38
- },
39
-
40
- buildField: function (field) {
41
- // field can be either a string like "option.name" or a full definition array,
42
- // like ['options.tilelayer.tms', {handler: 'CheckBox', helpText: 'TMS format'}]
43
- let type
44
- let helper
45
- let options
46
- if (Array.isArray(field)) {
47
- options = field[1] || {}
48
- field = field[0]
49
- } else {
50
- options = this.defaultOptions[this.getName(field)] || {}
51
- }
52
- type = options.handler || 'Input'
53
- if (typeof type === 'string' && L.FormBuilder[type]) {
54
- helper = new L.FormBuilder[type](this, field, options)
55
- } else {
56
- helper = new type(this, field, options)
57
- }
58
- this.helpers[field] = helper
59
- return helper
60
- },
61
-
62
- getter: function (field) {
63
- const path = field.split('.')
64
- let value = this.obj
65
- for (const sub of path) {
66
- try {
67
- value = value[sub]
68
- } catch {
69
- console.log(field)
70
- }
71
- }
72
- return value
73
- },
74
-
75
- setter: function (field, value) {
76
- const path = field.split('.')
77
- let obj = this.obj
78
- let what
79
- for (let i = 0, l = path.length; i < l; i++) {
80
- what = path[i]
81
- if (what === path[l - 1]) {
82
- if (typeof value === 'undefined') {
83
- delete obj[what]
84
- } else {
85
- obj[what] = value
86
- }
87
- } else {
88
- obj = obj[what]
89
- }
90
- }
91
- },
92
-
93
- restoreField: function (field) {
94
- const initial = this.helpers[field].initial
95
- this.setter(field, initial)
96
- },
97
-
98
- getName: (field) => {
99
- const fieldEls = field.split('.')
100
- return fieldEls[fieldEls.length - 1]
101
- },
102
-
103
- fetchAll: function () {
104
- for (const helper of Object.values(this.helpers)) {
105
- helper.fetch()
106
- }
107
- },
108
-
109
- syncAll: function () {
110
- for (const helper of Object.values(this.helpers)) {
111
- helper.sync()
112
- }
113
- },
114
-
115
- onPostSync: function (e) {
116
- if (e.helper.options.callback) {
117
- e.helper.options.callback.call(e.helper.options.callbackContext || this.obj, e)
118
- }
119
- if (this.options.callback) {
120
- this.options.callback.call(this.options.callbackContext || this.obj, e)
121
- }
122
- },
123
- })
124
-
125
- L.FormBuilder.Element = L.Evented.extend({
126
- initialize: function (builder, field, options) {
127
- this.builder = builder
128
- this.obj = this.builder.obj
129
- this.form = this.builder.form
130
- this.field = field
131
- L.setOptions(this, options)
132
- this.fieldEls = this.field.split('.')
133
- this.name = this.builder.getName(field)
134
- this.parentNode = this.getParentNode()
135
- this.buildLabel()
136
- this.build()
137
- this.buildHelpText()
138
- this.fireAndForward('helper:init')
139
- },
140
-
141
- fireAndForward: function (type, e = {}) {
142
- e.helper = this
143
- this.fire(type, e)
144
- this.builder.fire(type, e)
145
- if (this.obj.fire) this.obj.fire(type, e)
146
- },
147
-
148
- getParentNode: function () {
149
- return this.options.wrapper
150
- ? L.DomUtil.create(
151
- this.options.wrapper,
152
- this.options.wrapperClass || '',
153
- this.form
154
- )
155
- : this.form
156
- },
157
-
158
- get: function () {
159
- return this.builder.getter(this.field)
160
- },
161
-
162
- toHTML: function () {
163
- return this.get()
164
- },
165
-
166
- toJS: function () {
167
- return this.value()
168
- },
169
-
170
- sync: function () {
171
- this.fireAndForward('presync')
172
- this.set()
173
- this.fireAndForward('postsync')
174
- },
175
-
176
- set: function () {
177
- this.builder.setter(this.field, this.toJS())
178
- },
179
-
180
- getLabelParent: function () {
181
- return this.parentNode
182
- },
183
-
184
- getHelpTextParent: function () {
185
- return this.parentNode
186
- },
187
-
188
- buildLabel: function () {
189
- if (this.options.label) {
190
- this.label = L.DomUtil.create('label', '', this.getLabelParent())
191
- this.label.innerHTML = this.options.label
192
- }
193
- },
194
-
195
- buildHelpText: function () {
196
- if (this.options.helpText) {
197
- const container = L.DomUtil.create('small', 'help-text', this.getHelpTextParent())
198
- container.innerHTML = this.options.helpText
199
- }
200
- },
201
-
202
- fetch: () => {},
203
-
204
- finish: function () {
205
- this.fireAndForward('finish')
206
- },
207
- })
208
-
209
- L.FormBuilder.Textarea = L.FormBuilder.Element.extend({
210
- build: function () {
211
- this.input = L.DomUtil.create(
212
- 'textarea',
213
- this.options.className || '',
214
- this.parentNode
215
- )
216
- if (this.options.placeholder) this.input.placeholder = this.options.placeholder
217
- this.fetch()
218
- L.DomEvent.on(this.input, 'input', this.sync, this)
219
- L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this)
220
- },
221
-
222
- fetch: function () {
223
- const value = this.toHTML()
224
- this.initial = value
225
- if (value) {
226
- this.input.value = value
227
- }
228
- },
229
-
230
- value: function () {
231
- return this.input.value
232
- },
233
-
234
- onKeyPress: function (e) {
235
- if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) {
236
- L.DomEvent.stop(e)
237
- this.finish()
238
- }
239
- },
240
- })
241
-
242
- L.FormBuilder.Input = L.FormBuilder.Element.extend({
243
- build: function () {
244
- this.input = L.DomUtil.create(
245
- 'input',
246
- this.options.className || '',
247
- this.parentNode
248
- )
249
- this.input.type = this.type()
250
- this.input.name = this.name
251
- this.input._helper = this
252
- if (this.options.placeholder) {
253
- this.input.placeholder = this.options.placeholder
254
- }
255
- if (this.options.min !== undefined) {
256
- this.input.min = this.options.min
257
- }
258
- if (this.options.max !== undefined) {
259
- this.input.max = this.options.max
260
- }
261
- if (this.options.step) {
262
- this.input.step = this.options.step
263
- }
264
- this.fetch()
265
- L.DomEvent.on(this.input, this.getSyncEvent(), this.sync, this)
266
- L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this)
267
- },
268
-
269
- fetch: function () {
270
- const value = this.toHTML() !== undefined ? this.toHTML() : null
271
- this.initial = value
272
- this.input.value = value
273
- },
274
-
275
- getSyncEvent: () => 'input',
276
-
277
- type: function () {
278
- return this.options.type || 'text'
279
- },
280
-
281
- value: function () {
282
- return this.input.value || undefined
283
- },
284
-
285
- onKeyDown: function (e) {
286
- if (e.key === 'Enter') {
287
- L.DomEvent.stop(e)
288
- this.finish()
289
- }
290
- },
291
- })
292
-
293
- L.FormBuilder.BlurInput = L.FormBuilder.Input.extend({
294
- getSyncEvent: () => 'blur',
295
-
296
- build: function () {
297
- L.FormBuilder.Input.prototype.build.call(this)
298
- L.DomEvent.on(this.input, 'focus', this.fetch, this)
299
- },
300
-
301
- finish: function () {
302
- this.sync()
303
- L.FormBuilder.Input.prototype.finish.call(this)
304
- },
305
-
306
- sync: function () {
307
- // Do not commit any change if user only clicked
308
- // on the field than clicked outside
309
- if (this.initial !== this.value()) {
310
- L.FormBuilder.Input.prototype.sync.call(this)
311
- }
312
- },
313
- })
314
-
315
- L.FormBuilder.IntegerMixin = {
316
- value: function () {
317
- return !isNaN(this.input.value) && this.input.value !== ''
318
- ? parseInt(this.input.value, 10)
319
- : undefined
320
- },
321
-
322
- type: () => 'number',
323
- }
324
-
325
- L.FormBuilder.IntInput = L.FormBuilder.Input.extend({
326
- includes: [L.FormBuilder.IntegerMixin],
327
- })
328
-
329
- L.FormBuilder.BlurIntInput = L.FormBuilder.BlurInput.extend({
330
- includes: [L.FormBuilder.IntegerMixin],
331
- })
332
-
333
- L.FormBuilder.FloatMixin = {
334
- value: function () {
335
- return !isNaN(this.input.value) && this.input.value !== ''
336
- ? parseFloat(this.input.value)
337
- : undefined
338
- },
339
-
340
- type: () => 'number',
341
- }
342
-
343
- L.FormBuilder.FloatInput = L.FormBuilder.Input.extend({
344
- options: {
345
- step: 'any',
346
- },
347
-
348
- includes: [L.FormBuilder.FloatMixin],
349
- })
350
-
351
- L.FormBuilder.BlurFloatInput = L.FormBuilder.BlurInput.extend({
352
- options: {
353
- step: 'any',
354
- },
355
-
356
- includes: [L.FormBuilder.FloatMixin],
357
- })
358
-
359
- L.FormBuilder.CheckBox = L.FormBuilder.Element.extend({
360
- build: function () {
361
- const container = L.DomUtil.create('div', 'checkbox-wrapper', this.parentNode)
362
- this.input = L.DomUtil.create('input', this.options.className || '', container)
363
- this.input.type = 'checkbox'
364
- this.input.name = this.name
365
- this.input._helper = this
366
- this.fetch()
367
- L.DomEvent.on(this.input, 'change', this.sync, this)
368
- },
369
-
370
- fetch: function () {
371
- this.initial = this.toHTML()
372
- this.input.checked = this.initial === true
373
- },
374
-
375
- value: function () {
376
- return this.input.checked
377
- },
378
-
379
- toHTML: function () {
380
- return [1, true].indexOf(this.get()) !== -1
381
- },
382
- })
383
-
384
- L.FormBuilder.Select = L.FormBuilder.Element.extend({
385
- selectOptions: [['value', 'label']],
386
-
387
- build: function () {
388
- this.select = L.DomUtil.create('select', '', this.parentNode)
389
- this.select.name = this.name
390
- this.validValues = []
391
- this.buildOptions()
392
- L.DomEvent.on(this.select, 'change', this.sync, this)
393
- },
394
-
395
- getOptions: function () {
396
- return this.options.selectOptions || this.selectOptions
397
- },
398
-
399
- fetch: function () {
400
- this.buildOptions()
401
- },
402
-
403
- buildOptions: function () {
404
- this.select.innerHTML = ''
405
- for (const option of this.getOptions()) {
406
- if (typeof option === 'string') this.buildOption(option, option)
407
- else this.buildOption(option[0], option[1])
408
- }
409
- },
410
-
411
- buildOption: function (value, label) {
412
- this.validValues.push(value)
413
- const option = L.DomUtil.create('option', '', this.select)
414
- option.value = value
415
- option.innerHTML = label
416
- if (this.toHTML() === value) {
417
- option.selected = 'selected'
418
- }
419
- },
420
-
421
- value: function () {
422
- if (this.select[this.select.selectedIndex])
423
- return this.select[this.select.selectedIndex].value
424
- },
425
-
426
- getDefault: function () {
427
- return this.getOptions()[0][0]
428
- },
429
-
430
- toJS: function () {
431
- const value = this.value()
432
- if (this.validValues.indexOf(value) !== -1) {
433
- return value
434
- }
435
- return this.getDefault()
436
- },
437
- })
438
-
439
- L.FormBuilder.IntSelect = L.FormBuilder.Select.extend({
440
- value: function () {
441
- return parseInt(L.FormBuilder.Select.prototype.value.apply(this), 10)
442
- },
443
- })
444
-
445
- L.FormBuilder.NullableBoolean = L.FormBuilder.Select.extend({
446
- selectOptions: [
447
- [undefined, 'inherit'],
448
- [true, 'yes'],
449
- [false, 'no'],
450
- ],
451
-
452
- toJS: function () {
453
- let value = this.value()
454
- switch (value) {
455
- case 'true':
456
- case true:
457
- value = true
458
- break
459
- case 'false':
460
- case false:
461
- value = false
462
- break
463
- default:
464
- value = undefined
465
- }
466
- return value
467
- },
468
- })
@@ -1,22 +0,0 @@
1
- from umap.websocket_server import OperationMessage, PeerMessage, Request, ServerRequest
2
-
3
-
4
- def test_messages_are_parsed_correctly():
5
- server = Request.model_validate(dict(kind="Server", action="list-peers")).root
6
- assert type(server) is ServerRequest
7
-
8
- operation = Request.model_validate(
9
- dict(
10
- kind="OperationMessage",
11
- verb="upsert",
12
- subject="map",
13
- metadata={},
14
- key="key",
15
- )
16
- ).root
17
- assert type(operation) is OperationMessage
18
-
19
- peer_message = Request.model_validate(
20
- dict(kind="PeerMessage", sender="Alice", recipient="Bob", message={})
21
- ).root
22
- assert type(peer_message) is PeerMessage
umap/websocket_server.py DELETED
@@ -1,202 +0,0 @@
1
- #!/usr/bin/env python
2
-
3
- import asyncio
4
- import logging
5
- import uuid
6
- from collections import defaultdict
7
- from typing import Literal, Optional, Union
8
-
9
- import websockets
10
- from django.conf import settings
11
- from django.core.signing import TimestampSigner
12
- from pydantic import BaseModel, Field, RootModel, ValidationError
13
- from websockets import WebSocketClientProtocol
14
- from websockets.server import serve
15
-
16
-
17
- class Connections:
18
- def __init__(self) -> None:
19
- self._connections: set[WebSocketClientProtocol] = set()
20
- self._ids: dict[WebSocketClientProtocol, str] = dict()
21
-
22
- def join(self, websocket: WebSocketClientProtocol) -> str:
23
- self._connections.add(websocket)
24
- _id = str(uuid.uuid4())
25
- self._ids[websocket] = _id
26
- return _id
27
-
28
- def leave(self, websocket: WebSocketClientProtocol) -> None:
29
- self._connections.remove(websocket)
30
- del self._ids[websocket]
31
-
32
- def get(self, id) -> WebSocketClientProtocol:
33
- # use an iterator to stop iterating as soon as we found
34
- return next(k for k, v in self._ids.items() if v == id)
35
-
36
- def get_id(self, websocket: WebSocketClientProtocol):
37
- return self._ids[websocket]
38
-
39
- def get_other_peers(
40
- self, websocket: WebSocketClientProtocol
41
- ) -> set[WebSocketClientProtocol]:
42
- return self._connections - {websocket}
43
-
44
- def get_all_peers(self) -> set[WebSocketClientProtocol]:
45
- return self._connections
46
-
47
-
48
- # Contains the list of websocket connections handled by this process.
49
- # It's a mapping of map_id to a set of the active websocket connections
50
- CONNECTIONS: defaultdict[int, Connections] = defaultdict(Connections)
51
-
52
-
53
- class JoinRequest(BaseModel):
54
- kind: Literal["JoinRequest"] = "JoinRequest"
55
- token: str
56
-
57
-
58
- class OperationMessage(BaseModel):
59
- """Message sent from one peer to all the others"""
60
-
61
- kind: Literal["OperationMessage"] = "OperationMessage"
62
- verb: Literal["upsert", "update", "delete"]
63
- subject: Literal["map", "datalayer", "feature"]
64
- metadata: Optional[dict] = None
65
- key: Optional[str] = None
66
-
67
-
68
- class PeerMessage(BaseModel):
69
- """Message sent from a specific peer to another one"""
70
-
71
- kind: Literal["PeerMessage"] = "PeerMessage"
72
- sender: str
73
- recipient: str
74
- # The message can be whatever the peers want. It's not checked by the server.
75
- message: dict
76
-
77
-
78
- class ServerRequest(BaseModel):
79
- """A request towards the server"""
80
-
81
- kind: Literal["Server"] = "Server"
82
- action: Literal["list-peers"]
83
-
84
-
85
- class Request(RootModel):
86
- """Any message coming from the websocket should be one of these, and will be rejected otherwise."""
87
-
88
- root: Union[ServerRequest, PeerMessage, OperationMessage] = Field(
89
- discriminator="kind"
90
- )
91
-
92
-
93
- class JoinResponse(BaseModel):
94
- """Server response containing the list of peers"""
95
-
96
- kind: Literal["JoinResponse"] = "JoinResponse"
97
- peers: list
98
- uuid: str
99
-
100
-
101
- class ListPeersResponse(BaseModel):
102
- kind: Literal["ListPeersResponse"] = "ListPeersResponse"
103
- peers: list
104
-
105
-
106
- async def join_and_listen(
107
- map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol
108
- ):
109
- """Join a "room" with other connected peers, and wait for messages."""
110
- logging.debug(f"{user} joined room #{map_id}")
111
- connections: Connections = CONNECTIONS[map_id]
112
- _id: str = connections.join(websocket)
113
-
114
- # Assign an ID to the joining peer and return it the list of connected peers.
115
- peers: list[WebSocketClientProtocol] = [
116
- connections.get_id(p) for p in connections.get_all_peers()
117
- ]
118
- response = JoinResponse(uuid=_id, peers=peers)
119
- await websocket.send(response.model_dump_json())
120
-
121
- # Notify all other peers of the new list of connected peers.
122
- message = ListPeersResponse(peers=peers)
123
- websockets.broadcast(
124
- connections.get_other_peers(websocket), message.model_dump_json()
125
- )
126
-
127
- try:
128
- async for raw_message in websocket:
129
- if raw_message == "ping":
130
- await websocket.send("pong")
131
- continue
132
-
133
- # recompute the peers list at the time of message-sending.
134
- # as doing so beforehand would miss new connections
135
- other_peers = connections.get_other_peers(websocket)
136
- try:
137
- incoming = Request.model_validate_json(raw_message)
138
- except ValidationError as e:
139
- error = f"An error occurred when receiving the following message: {raw_message!r}"
140
- logging.error(error, e)
141
- else:
142
- match incoming.root:
143
- # Broadcast all operation messages to connected peers
144
- case OperationMessage():
145
- websockets.broadcast(other_peers, raw_message)
146
-
147
- # Send peer messages to the proper peer
148
- case PeerMessage(recipient=_id):
149
- peer = connections.get(_id)
150
- if peer:
151
- await peer.send(raw_message)
152
-
153
- finally:
154
- # On disconnect, remove the connection from the pool
155
- connections.leave(websocket)
156
-
157
- # TODO: refactor this in a separate method.
158
- # Notify all other peers of the new list of connected peers.
159
- peers = [connections.get_id(p) for p in connections.get_all_peers()]
160
- message = ListPeersResponse(peers=peers)
161
- websockets.broadcast(
162
- connections.get_other_peers(websocket), message.model_dump_json()
163
- )
164
-
165
-
166
- async def handler(websocket: WebSocketClientProtocol):
167
- """Main WebSocket handler.
168
-
169
- Check if the permission is granted and let the peer enter a room.
170
- """
171
- raw_message = await websocket.recv()
172
-
173
- # The first event should always be 'join'
174
- message: JoinRequest = JoinRequest.model_validate_json(raw_message)
175
- signed = TimestampSigner().unsign_object(message.token, max_age=30)
176
- user, map_id, permissions = signed.values()
177
-
178
- # Check if permissions for this map have been granted by the server
179
- if "edit" in signed["permissions"]:
180
- await join_and_listen(map_id, permissions, user, websocket)
181
-
182
-
183
- def run(host: str, port: int):
184
- if not settings.WEBSOCKET_ENABLED:
185
- msg = (
186
- "WEBSOCKET_ENABLED should be set to True to run the WebSocket Server. "
187
- "See the documentation at "
188
- "https://docs.umap-project.org/en/stable/config/settings/#websocket_enabled "
189
- "for more information."
190
- )
191
- print(msg)
192
- exit(1)
193
-
194
- async def _serve():
195
- async with serve(handler, host, port):
196
- logging.debug(f"Waiting for connections on {host}:{port}")
197
- await asyncio.Future() # run forever
198
-
199
- try:
200
- asyncio.run(_serve())
201
- except KeyboardInterrupt:
202
- print("Closing WebSocket server")