kopia lustrzana https://github.com/OpenDroneMap/WebODM
Porównaj commity
430 Commity
Autor | SHA1 | Data |
---|---|---|
Stephen Mather | fa058e7567 | |
Stephen Mather | a60dec4e69 | |
Piero Toffanin | c098b976c6 | |
Piero Toffanin | 32ba4ed707 | |
Piero Toffanin | 7272ca55bc | |
Piero Toffanin | e1cb0c83bb | |
Piero Toffanin | 7ab95bc8b1 | |
Piero Toffanin | 80a7f2048d | |
Piero Toffanin | e9c2409ea9 | |
Piero Toffanin | 9ee58f7216 | |
Piero Toffanin | 289ef48b12 | |
Piero Toffanin | 8468fdff5c | |
Piero Toffanin | 35fc60aa2c | |
Piero Toffanin | 57ccd23234 | |
Piero Toffanin | 4e2ffbb768 | |
Piero Toffanin | 3b55ebd3e5 | |
Piero Toffanin | 1fc7e11c86 | |
Piero Toffanin | d76eacabd3 | |
Piero Toffanin | 75678b7a84 | |
Piero Toffanin | 681482983c | |
Piero Toffanin | adf9c7dc5f | |
Piero Toffanin | ece6bba200 | |
Piero Toffanin | d46b582dcd | |
Piero Toffanin | dd6b46a2c9 | |
Piero Toffanin | 5375eb8a19 | |
Piero Toffanin | e0eb7cad7e | |
Piero Toffanin | 9a8013d6ce | |
Piero Toffanin | f0cd13a464 | |
Piero Toffanin | 5c663b8435 | |
Piero Toffanin | 30eff78d3b | |
Piero Toffanin | 9af1ee018b | |
Piero Toffanin | 568e80a941 | |
Piero Toffanin | cb173bd48c | |
Piero Toffanin | b86411c298 | |
Piero Toffanin | 06ccd29d09 | |
Piero Toffanin | bf24be7b72 | |
Piero Toffanin | d73558256a | |
Piero Toffanin | 59e104946c | |
Piero Toffanin | 972b06d03b | |
Piero Toffanin | 2352d838cf | |
Piero Toffanin | e7337f3b5d | |
Piero Toffanin | a44c2ce86f | |
Piero Toffanin | 6f5d68d6ed | |
Piero Toffanin | 04af329f78 | |
Piero Toffanin | cc2b7d5265 | |
Piero Toffanin | 10a44851ac | |
Piero Toffanin | c2c65a2cc2 | |
Piero Toffanin | b1fdcbb242 | |
Piero Toffanin | 02b4132daa | |
Piero Toffanin | ed6a43699f | |
Piero Toffanin | e8f8f2f130 | |
Zuline | 57223fc539 | |
Piero Toffanin | 9a3123c878 | |
Zuline | 860942b80f | |
Piero Toffanin | 4cd3618442 | |
Gonzalo Bulnes Guilpain | 9209b52a19 | |
Piero Toffanin | 8150b822d9 | |
Gonzalo Bulnes Guilpain | 02b4061379 | |
Piero Toffanin | 206edf1087 | |
Gonzalo Bulnes Guilpain | 7c9b1da92a | |
Piero Toffanin | a1331d0b0b | |
Saijin-Naib | 864055b0db | |
Mike Ryan | faae70991a | |
Piero Toffanin | 712705d35a | |
Piero Toffanin | 863bf2477c | |
Piero Toffanin | 94851d2ed3 | |
Piero Toffanin | d415b12806 | |
Piero Toffanin | ccabacb3dc | |
Piero Toffanin | d2755db412 | |
Piero Toffanin | 59195124a0 | |
Piero Toffanin | 362ba7fc9b | |
Piero Toffanin | b3f5b2de5d | |
Piero Toffanin | f227bdb08b | |
Piero Toffanin | 15ce002d78 | |
douw | a41d4ceff7 | |
Piero Toffanin | be8bd6e7ee | |
Piero Toffanin | 913db4c52b | |
Piero Toffanin | 7bba3a38df | |
Piero Toffanin | 136d270666 | |
Piero Toffanin | 448a2cb2d6 | |
Piero Toffanin | ae08c10ec7 | |
Piero Toffanin | bc0c4ac3e0 | |
chris-bateman | 8ff81f74c4 | |
chris-bateman | 5ca0cf82db | |
chris-bateman | dae49a7b2c | |
chris-bateman | 6ea180dd43 | |
chris-bateman | 3f42eaa824 | |
Piero Toffanin | 24f3b38dce | |
Piero Toffanin | 0b2e697c15 | |
Piero Toffanin | 9af93014cc | |
Piero Toffanin | d4dcf3fed1 | |
Piero Toffanin | 425710862f | |
Piero Toffanin | 4a529baace | |
Piero Toffanin | e8ae09b96f | |
Piero Toffanin | c4b59721a3 | |
Piero Toffanin | 9880d461d4 | |
Piero Toffanin | a78a2e4206 | |
Piero Toffanin | fd7721ee6b | |
Piero Toffanin | c28d00f0b0 | |
Piero Toffanin | 5a94579a8e | |
Piero Toffanin | 512314ba67 | |
Piero Toffanin | 782d6ed7a9 | |
Piero Toffanin | bf76edf4c0 | |
Piero Toffanin | 824c88cd54 | |
Piero Toffanin | d0dc128ca9 | |
Piero Toffanin | c4c9ed9598 | |
Piero Toffanin | 5fc886028e | |
Piero Toffanin | 8976aa51e3 | |
Piero Toffanin | 5e7ef34290 | |
Piero Toffanin | 9ea217c218 | |
Piero Toffanin | ce9cd0a8b9 | |
Piero Toffanin | 58dcc46e40 | |
Piero Toffanin | 9fb0c6db67 | |
Piero Toffanin | 6b4230f233 | |
Piero Toffanin | bd183c6455 | |
Piero Toffanin | 6bc90025aa | |
Piero Toffanin | bcc0f24fd5 | |
Piero Toffanin | 918ec48e6d | |
Rion Lerm | 18cab57e9f | |
Saijin-Naib | 38ba80b6ec | |
allandaly | a5cbac0ff8 | |
Piero Toffanin | 295bf3f99a | |
Piero Toffanin | bc86c7977b | |
Piero Toffanin | 2d5a403109 | |
Piero Toffanin | 49c9f2d7b8 | |
Piero Toffanin | 62d5185a79 | |
Piero Toffanin | f67f435a1c | |
Piero Toffanin | 13121566ad | |
Piero Toffanin | 80dcff41ca | |
Piero Toffanin | 4897d4e52a | |
Piero Toffanin | 44b2495291 | |
Piero Toffanin | b68e622234 | |
Piero Toffanin | 43b24eb8b6 | |
Piero Toffanin | 530720b699 | |
Piero Toffanin | 474e2d844b | |
Piero Toffanin | 1f75945fbb | |
Piero Toffanin | d5e597fee8 | |
Piero Toffanin | 36e98818d3 | |
Piero Toffanin | d9736cf11f | |
Piero Toffanin | 74e41077cc | |
Piero Toffanin | 0501938d61 | |
Piero Toffanin | 9b49ad777d | |
Piero Toffanin | a852dfb04e | |
Piero Toffanin | 0e7d9ee6f2 | |
Piero Toffanin | c8c0f51805 | |
Piero Toffanin | 1b327fb56e | |
Piero Toffanin | bf3eec282f | |
Piero Toffanin | 0093ca71cd | |
Piero Toffanin | 7bb818dda9 | |
Piero Toffanin | 92b98389ad | |
Piero Toffanin | ef4db8f491 | |
Piero Toffanin | 9ece192f28 | |
Piero Toffanin | cdeae25426 | |
Piero Toffanin | 9cf533f87c | |
Piero Toffanin | a364de2176 | |
Piero Toffanin | 9d336a5c61 | |
Piero Toffanin | e2b7de81d3 | |
Piero Toffanin | ac78176f2d | |
Piero Toffanin | 950d54d51b | |
Piero Toffanin | ef5336927d | |
Piero Toffanin | c0fe407157 | |
Piero Toffanin | 82f3408b94 | |
Piero Toffanin | c6d4c763f0 | |
Piero Toffanin | c54857d6e9 | |
Piero Toffanin | 9f5c58fe9a | |
Piero Toffanin | 0de8a7e0fe | |
Piero Toffanin | 93704420c6 | |
Piero Toffanin | 9bfdf9c320 | |
Piero Toffanin | 74dc45a8ca | |
Piero Toffanin | 3254968b63 | |
Piero Toffanin | be082a7d71 | |
Piero Toffanin | 95085301c2 | |
Piero Toffanin | 3541882423 | |
Piero Toffanin | 14aad55245 | |
Piero Toffanin | eda8e1abe0 | |
Piero Toffanin | 8059900a58 | |
Piero Toffanin | b1fd36da26 | |
Piero Toffanin | b7178c830a | |
Piero Toffanin | ec908cdc12 | |
Piero Toffanin | df245905c5 | |
Piero Toffanin | 2c2b75a759 | |
Piero Toffanin | ba2d42b3e5 | |
Piero Toffanin | e510e2fc9b | |
Piero Toffanin | 53079dbd30 | |
Piero Toffanin | 49655a4115 | |
Piero Toffanin | c7ff74a526 | |
Piero Toffanin | a709c8fdf6 | |
Piero Toffanin | 039df51cc6 | |
Piero Toffanin | 54296bd7a4 | |
Piero Toffanin | e7d57b4cd5 | |
Piero Toffanin | 83419a7dab | |
Piero Toffanin | 73052fb2ec | |
Piero Toffanin | 89a6aca5f0 | |
Piero Toffanin | bd70b4b7ec | |
Piero Toffanin | 4cd5a01023 | |
Piero Toffanin | fd05b3a71f | |
Piero Toffanin | fab02f0cee | |
Piero Toffanin | 7c97d9365b | |
Chris Bateman | 132e8f9d69 | |
Chris Bateman | c4c5085e2a | |
Chris Bateman | 5047413e12 | |
Piero Toffanin | 1b92ee1f19 | |
Piero Toffanin | f1b358db44 | |
Piero Toffanin | 510cd961cf | |
Grant | f5ff31b3ff | |
Piero Toffanin | aa737da1a1 | |
Piero Toffanin | b4e54e6406 | |
Piero Toffanin | 872d5abbc7 | |
Piero Toffanin | a0dbd68122 | |
Piero Toffanin | cd7f779019 | |
Piero Toffanin | ba1965add0 | |
Piero Toffanin | 5ba0d472af | |
Piero Toffanin | 08608a6727 | |
Piero Toffanin | 84356f1ce7 | |
Piero Toffanin | 51f03be14e | |
Piero Toffanin | 544b06a81a | |
Piero Toffanin | 2035b3a3fe | |
Piero Toffanin | 849ae6576f | |
vinsonliux | 397117fad1 | |
vinsonliux | 4b87007682 | |
vinsonliux | 7e9791d5c1 | |
Piero Toffanin | a290b7af75 | |
Piero Toffanin | ce108ec119 | |
Piero Toffanin | 1e5356f74d | |
Diego Acuña | af7188890f | |
Diego Acuña | 798434ecad | |
Diego Acuña | 3316d1c3a8 | |
Piero Toffanin | cc816a66e9 | |
Piero Toffanin | a6023a9f8d | |
Piero Toffanin | 5c668292e8 | |
Piero Toffanin | 8c88111cc4 | |
Piero Toffanin | e0747ab9ae | |
Piero Toffanin | 277a659771 | |
Piero Toffanin | 2cedf42751 | |
Ronald | 394e7add2c | |
Piero Toffanin | bdf5b334d6 | |
Piero Toffanin | 441782987c | |
Piero Toffanin | 26dee3b023 | |
Piero Toffanin | d8825e2160 | |
Piero Toffanin | 92a016b095 | |
Piero Toffanin | 7413ebda7b | |
Piero Toffanin | 82c027226a | |
Piero Toffanin | c029446f88 | |
Piero Toffanin | a18c1d3506 | |
Piero Toffanin | 3712d3a757 | |
Piero Toffanin | f6114c0544 | |
Piero Toffanin | 3309664043 | |
Piero Toffanin | c2bb526df7 | |
Ronald | 726ae46886 | |
Ronald | 111971c261 | |
Ronald | 18d3c7827c | |
Ronald | 8005fcdc21 | |
Piero Toffanin | a8f852cdf7 | |
Piero Toffanin | 0fc5387cf5 | |
Piero Toffanin | 473b435acf | |
Piero Toffanin | 932bfec0b0 | |
t3Y | 69674401c2 | |
Piero Toffanin | 48d76079bd | |
Piero Toffanin | f7ec1c3208 | |
Piero Toffanin | c621c44e56 | |
Piero Toffanin | 34e8c46a4d | |
Piero Toffanin | 1c24acf001 | |
Piero Toffanin | e55ef9726a | |
Piero Toffanin | f8410c720d | |
Piero Toffanin | ff0d4b5c4f | |
Diego Acuña | 2ee003338d | |
Diego Acuña | 3429255f42 | |
Piero Toffanin | ecd89a3f3f | |
Piero Toffanin | 245ba2d522 | |
Piero Toffanin | 80742baae6 | |
Piero Toffanin | f2855c1ae8 | |
Saijin-Naib | 4aa9986943 | |
Piero Toffanin | 4bd0e0f198 | |
Piero Toffanin | 9598ebfaf1 | |
Tariq Islam | 3d69c2c4e0 | |
Tariq Islam | 8c28849da0 | |
Piero Toffanin | a6e92a4ff2 | |
Piero Toffanin | 9d4e0e0086 | |
Tariq Islam | d3a743a38b | |
Tariq Islam | 04aa66c478 | |
Tariq Islam | 0ecd53ccb5 | |
Piero Toffanin | 1028185b2a | |
Vagner Silveira | 74d82d09cb | |
Tariq Islam | 001d63939b | |
Tariq Islam | 90acb3dc41 | |
Tariq Islam | 7d20d54119 | |
Piero Toffanin | 3ef4c044a3 | |
Piero Toffanin | fd80f494f2 | |
Piero Toffanin | 57c4f06fe2 | |
Piero Toffanin | 31e1770ce7 | |
Piero Toffanin | fca64b7d09 | |
Piero Toffanin | 0205966093 | |
Piero Toffanin | b7501de5e6 | |
HeDo | 00ebfeb550 | |
Piero Toffanin | cc573c9364 | |
DanV | 8724acf794 | |
Piero Toffanin | 14c0a356fa | |
Piero Toffanin | 21c0097a05 | |
Piero Toffanin | b5e9dfad6f | |
Piero Toffanin | 0dc54a64d7 | |
Piero Toffanin | ed5ac98d06 | |
Piero Toffanin | 6258626102 | |
Piero Toffanin | e41b095a9a | |
Piero Toffanin | a18e9dd479 | |
Piero Toffanin | f0b15c2b2f | |
Piero Toffanin | 2ceb6159d9 | |
Piero Toffanin | 7125876d45 | |
Piero Toffanin | b979a695c7 | |
Piero Toffanin | b91f1fbb1f | |
Piero Toffanin | 832805943e | |
Piero Toffanin | 394baf9d46 | |
Piero Toffanin | 55fe056e82 | |
Piero Toffanin | f82b66bd91 | |
Piero Toffanin | 78c62a6131 | |
Piero Toffanin | 5a3be07f45 | |
Piero Toffanin | 7433e72857 | |
Piero Toffanin | 2b26e83a8d | |
Piero Toffanin | 116c14af71 | |
Piero Toffanin | 27c359f5ac | |
Piero Toffanin | d069d63852 | |
Piero Toffanin | 8c16f9a26d | |
Piero Toffanin | c90b575850 | |
Piero Toffanin | c852f72b20 | |
Piero Toffanin | 8a51317774 | |
Piero Toffanin | b8d7e9f7d2 | |
Piero Toffanin | ac195deee3 | |
Piero Toffanin | 70386c7ce6 | |
Piero Toffanin | d46058c042 | |
Piero Toffanin | c2c06e6d26 | |
Piero Toffanin | 26acc6ea1d | |
Piero Toffanin | a7b09ee3fa | |
Piero Toffanin | 8df0e9a96e | |
Piero Toffanin | de79e1b606 | |
Piero Toffanin | e6c423f240 | |
Piero Toffanin | 809f6269bc | |
Piero Toffanin | bc8c75ac9a | |
Piero Toffanin | c0488f5760 | |
Piero Toffanin | baae377156 | |
Piero Toffanin | c40f34cc2a | |
Piero Toffanin | 2d9d978d16 | |
Philippe Rémy | 9d3da2f2bd | |
Saijin-Naib | 0b0b61a3ec | |
Saijin-Naib | c492b9ae19 | |
Piero Toffanin | 5a18c0ede1 | |
Piero Toffanin | ae0b721f7c | |
Piero Toffanin | 0226dfedb6 | |
Piero Toffanin | 824830e86e | |
Piero Toffanin | dcf4b8225e | |
Piero Toffanin | 99f95c1ec1 | |
Piero Toffanin | b159eaaa61 | |
Piero Toffanin | fc6af96011 | |
Piero Toffanin | d0de84c296 | |
Piero Toffanin | 91a65aa64a | |
Piero Toffanin | 7f4aea3af9 | |
Piero Toffanin | 2661223141 | |
Piero Toffanin | aa9e9fe5bb | |
Piero Toffanin | 9a11f70f9b | |
Piero Toffanin | 4c7b858d44 | |
Piero Toffanin | 5cf7195a8f | |
Piero Toffanin | 8e2c0109be | |
Piero Toffanin | 0f7849990a | |
Piero Toffanin | 6d58468be7 | |
Piero Toffanin | 6eea1af4fe | |
Piero Toffanin | 212864a26c | |
Piero Toffanin | 95fb2fddf8 | |
Piero Toffanin | 4a1923a95c | |
Piero Toffanin | e555be9058 | |
Piero Toffanin | 5224c89313 | |
Piero Toffanin | 965a7f8bda | |
Piero Toffanin | ad41692aa3 | |
Piero Toffanin | 0f4394feed | |
Piero Toffanin | d87c500921 | |
Piero Toffanin | 2278b0bb3f | |
Piero Toffanin | f137750296 | |
Piero Toffanin | efcefa4719 | |
Piero Toffanin | faf84af8ab | |
Piero Toffanin | 13df7c7d94 | |
Owen Kaluza | f0552817d5 | |
Piero Toffanin | 9ac40f8f5f | |
Piero Toffanin | f25b2962b8 | |
Stephen Mather | b5b82ced5c | |
Stephen Mather | fef544dada | |
Stephen Mather | 711c6be42c | |
Stephen Mather | cbc8644b34 | |
Stephen Mather | a9def132e7 | |
Piero Toffanin | 3394365d44 | |
Piero Toffanin | 87840769e6 | |
Piero Toffanin | de65e11be2 | |
Owen Kaluza | a81583d37b | |
Owen Kaluza | da75da50cc | |
Piero Toffanin | 446fdb7987 | |
Piero Toffanin | 7ab2b14ba6 | |
Piero Toffanin | 21cb6b401c | |
Piero Toffanin | 8fb5569b67 | |
Piero Toffanin | ed55a9d30e | |
Piero Toffanin | 01373dcfcf | |
Steve Santacroce | 8670e26c9d | |
Steve Santacroce | f7b8fc8b4a | |
Steve Santacroce | 6db30b88dc | |
Piero Toffanin | 3af0db1b84 | |
Piero Toffanin | e26de1af7d | |
Piero Toffanin | a803d919da | |
Piero Toffanin | 7d191e3a8c | |
Piero Toffanin | a47a5de210 | |
Piero Toffanin | 29ac754cc1 | |
IuliuNovac | 2fd07c2bc6 | |
Piero Toffanin | e02719ab13 | |
Piero Toffanin | dfe5441672 | |
Piero Toffanin | d06fc0088f | |
Piero Toffanin | 8997fb7261 | |
Saijin-Naib | db0348cc93 | |
Piero Toffanin | f0e9e8cb94 | |
Yauhen Kharuzhy | 2876ed760f | |
Piero Toffanin | 95977a55ba | |
Diego Acuña | 77c117b9ed | |
Dacuna | 1ce13af357 | |
Piero Toffanin | 869d577bbc | |
Piero Toffanin | 0e3b2bf52f | |
Piero Toffanin | 3f901c0cca | |
Piero Toffanin | 827d894e32 | |
Piero Toffanin | 33303720ba | |
Piero Toffanin | a565cab23f | |
Piero Toffanin | 0eab99ae74 | |
Piero Toffanin | 11697fa0ed | |
Piero Toffanin | db5de9357d | |
Scott | 1eb024bc43 | |
Piero Toffanin | 64812bcc65 | |
Piero Toffanin | 0806828ee8 | |
Piero Toffanin | 3d7841fab7 | |
Piero Toffanin | 374ed8b4be |
|
@ -1 +1,2 @@
|
|||
**/.git
|
||||
.secret_key
|
2
.env
2
.env
|
@ -1,6 +1,7 @@
|
|||
WO_HOST=localhost
|
||||
WO_PORT=8000
|
||||
WO_MEDIA_DIR=appmedia
|
||||
WO_DB_DIR=dbdata
|
||||
WO_SSL=NO
|
||||
WO_SSL_KEY=
|
||||
WO_SSL_CERT=
|
||||
|
@ -9,3 +10,4 @@ WO_DEBUG=NO
|
|||
WO_DEV=NO
|
||||
WO_BROKER=redis://broker
|
||||
WO_DEFAULT_NODES=1
|
||||
WO_SETTINGS=
|
||||
|
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up QEMU
|
||||
|
@ -38,8 +38,9 @@ jobs:
|
|||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
no-cache: true
|
||||
tags: |
|
||||
${{ steps.docker_meta.outputs.tags }}
|
||||
opendronemap/webodm_webapp:latest
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
ruby-version: 2.7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
|
@ -28,4 +28,4 @@ jobs:
|
|||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./slate/build
|
||||
keep_files: true
|
||||
keep_files: true
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
name: Issue Triage
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
issue_triage:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: pierotofy/issuewhiz@v1
|
||||
with:
|
||||
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
openAI: ${{ secrets.OPENAI_TOKEN }}
|
||||
filter: |
|
||||
- "#"
|
||||
variables: |
|
||||
- Q: "A question about using a software or seeking guidance on doing something?"
|
||||
- B: "Reporting an issue or a software bug?"
|
||||
- P: "Describes an issue with processing a set of images or a particular dataset?"
|
||||
- D: "Contains a link to a dataset or images?"
|
||||
- E: "Contains a suggestion for an improvement or a feature request?"
|
||||
- SC: "Describes an issue related to compiling or building source code?"
|
||||
logic: |
|
||||
- 'Q and (not B) and (not P) and (not E) and (not SC) and not (title_lowercase ~= ".*bug: .+")': [comment: "Could we move this conversation over to the forum at https://community.opendronemap.org? :pray: The forum is the right place to ask questions (we try to keep the GitHub issue tracker for feature requests and bugs only). Thank you! :+1:", close: true, stop: true]
|
||||
- "B and (not P) and (not E) and (not SC)": [label: "software fault", stop: true]
|
||||
- "P and D": [label: "possible software fault", stop: true]
|
||||
- "P and (not D) and (not SC) and (not E)": [comment: "Thanks for the report, but it looks like you didn't include a copy of your dataset for us to reproduce this issue? Please make sure to follow our [issue guidelines](https://github.com/OpenDroneMap/WebODM/blob/master/ISSUE_TEMPLATE.md) :pray: ", close: true, stop: true]
|
||||
- "E": [label: enhancement, stop: true]
|
||||
- "SC": [label: "possible software fault"]
|
||||
|
||||
signature: "p.s. I'm just an automated script, not a human being."
|
|
@ -7,11 +7,14 @@ jobs:
|
|||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
name: Checkout
|
||||
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 12
|
||||
- name: Build and Test
|
||||
run: |
|
||||
docker-compose -f docker-compose.yml -f docker-compose.build.yml build --build-arg TEST_BUILD=ON
|
||||
|
|
|
@ -102,4 +102,5 @@ package-lock.json
|
|||
|
||||
# Debian builds
|
||||
dpkg/build
|
||||
dpkg/deb
|
||||
dpkg/deb
|
||||
.secret_key
|
16
Dockerfile
16
Dockerfile
|
@ -2,6 +2,7 @@ FROM ubuntu:21.04
|
|||
MAINTAINER Piero Toffanin <pt@masseranolabs.com>
|
||||
|
||||
ARG TEST_BUILD
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONPATH $PYTHONPATH:/webodm
|
||||
ENV PROJ_LIB=/usr/share/proj
|
||||
|
@ -10,19 +11,26 @@ ENV PROJ_LIB=/usr/share/proj
|
|||
ADD . /webodm/
|
||||
WORKDIR /webodm
|
||||
|
||||
# Install Node.js
|
||||
# Use old-releases for 21.04
|
||||
RUN printf "deb http://old-releases.ubuntu.com/ubuntu/ hirsute main restricted\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates main restricted\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute universe\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates universe\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute multiverse\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-updates multiverse\ndeb http://old-releases.ubuntu.com/ubuntu/ hirsute-backports main restricted universe multiverse" > /etc/apt/sources.list
|
||||
|
||||
# Install Node.js using new Node install method
|
||||
RUN apt-get -qq update && apt-get -qq install -y --no-install-recommends wget curl && \
|
||||
wget --no-check-certificate https://deb.nodesource.com/setup_12.x -O /tmp/node.sh && bash /tmp/node.sh && \
|
||||
apt-get install -y ca-certificates gnupg && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
NODE_MAJOR=20 && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get -qq update && apt-get -qq install -y nodejs && \
|
||||
# Install Python3, GDAL, PDAL, nginx, letsencrypt, psql
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot grass-core gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
apt-get -qq update && apt-get -qq install -y --no-install-recommends python3 python3-pip python3-setuptools python3-wheel git g++ python3-dev python2.7-dev libpq-dev binutils libproj-dev gdal-bin pdal libgdal-dev python3-gdal nginx certbot gettext-base cron postgresql-client-13 gettext tzdata && \
|
||||
update-alternatives --install /usr/bin/python python /usr/bin/python2.7 1 && update-alternatives --install /usr/bin/python python /usr/bin/python3.9 2 && \
|
||||
# Install pip reqs
|
||||
pip install -U pip && pip install -r requirements.txt "boto3==1.14.14" && \
|
||||
# Setup cron
|
||||
ln -s /webodm/nginx/crontab /var/spool/cron/crontabs/root && chmod 0644 /webodm/nginx/crontab && service cron start && chmod +x /webodm/nginx/letsencrypt-autogen.sh && \
|
||||
/webodm/nodeodm/setup.sh && /webodm/nodeodm/cleanup.sh && cd /webodm && \
|
||||
npm install --quiet -g webpack@4.16.5 && npm install --quiet -g webpack-cli@4.2.0 && npm install --quiet && webpack --mode production && \
|
||||
npm install --quiet -g webpack@5.89.0 && npm install --quiet -g webpack-cli@5.1.4 && npm install --quiet && webpack --mode production && \
|
||||
echo "UTC" > /etc/timezone && \
|
||||
python manage.py collectstatic --noinput && \
|
||||
python manage.py rebuildplugins && \
|
||||
|
|
73
README.md
73
README.md
|
@ -1,6 +1,6 @@
|
|||
<img alt="WebODM" src="https://user-images.githubusercontent.com/1951843/34074943-8f057c3c-e287-11e7-924d-3ccafa60c43a.png" width="180">
|
||||
|
||||
[![Build Status](https://travis-ci.org/OpenDroneMap/WebODM.svg?branch=master)](https://travis-ci.org/OpenDroneMap/WebODM) [![Translated](https://hosted.weblate.org/widgets/webodm/-/svg-badge.svg)](https://hosted.weblate.org/engage/webodm/)
|
||||
![Build Status](https://img.shields.io/github/actions/workflow/status/OpenDroneMap/WebODM/build-and-publish.yml?branch=master) ![Version](https://img.shields.io/github/v/release/OpenDroneMap/WebODM) [![Translated](https://hosted.weblate.org/widgets/webodm/-/svg-badge.svg)](https://hosted.weblate.org/engage/webodm/)
|
||||
|
||||
A user-friendly, commercial grade software for drone image processing. Generate georeferenced maps, point clouds, elevation models and textured 3D models from aerial images. It supports multiple engines for processing, currently [ODM](https://github.com/OpenDroneMap/ODM) and [MicMac](https://github.com/OpenDroneMap/NodeMICMAC/).
|
||||
|
||||
|
@ -38,9 +38,11 @@ A user-friendly, commercial grade software for drone image processing. Generate
|
|||
|
||||
Windows and macOS users can purchase an automated [installer](https://www.opendronemap.org/webodm/download#installer), which makes the installation process easier.
|
||||
|
||||
To install WebODM manually, these steps should get you up and running:
|
||||
There's also a cloud-hosted version of WebODM available from [webodm.net](https://webodm.net).
|
||||
|
||||
* Install the following applications (if they are not installed already):
|
||||
To install WebODM manually on your machine:
|
||||
|
||||
* Install the following applications:
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Docker](https://www.docker.com/)
|
||||
- [Docker-compose](https://docs.docker.com/compose/install/)
|
||||
|
@ -127,10 +129,16 @@ Note! You cannot pass an IP address to the hostname parameter! You need a DNS re
|
|||
|
||||
### Where Are My Files Stored?
|
||||
|
||||
When using Docker, all processing results are stored in a docker volume and are not available on the host filesystem. If you want to store your files on the host filesystem instead of a docker volume, you need to pass a path via the `--media-dir` option:
|
||||
When using Docker, all processing results are stored in a docker volume and are not available on the host filesystem. There are two specific docker volumes of interest:
|
||||
1. Media (called webodm_appmedia): This is where all files related to a project and task are stored.
|
||||
2. Postgres DB (called webodm_dbdata): This is what Postgres database uses to store its data.
|
||||
|
||||
For more information on how these two volumes are used and in which containers, please refer to the [docker-compose.yml](docker-compose.yml) file.
|
||||
|
||||
For various reasons such as ease of backup/restore, if you want to store your files on the host filesystem instead of a docker volume, you need to pass a path via the `--media-dir` and/or the `--db-dir` options:
|
||||
|
||||
```bash
|
||||
./webodm.sh restart --media-dir /home/user/webodm_data
|
||||
./webodm.sh restart --media-dir /home/user/webodm_data --db-dir /home/user/webodm_db
|
||||
```
|
||||
|
||||
Note that existing task results will not be available after the change. Refer to the [Migrate Data Volumes](https://docs.docker.com/engine/tutorials/dockervolumes/#backup-restore-or-migrate-data-volumes) section of the Docker documentation for information on migrating existing task results.
|
||||
|
@ -148,6 +156,23 @@ Cannot start WebODM via `./webodm.sh start`, error messages are different at eac
|
|||
While running WebODM with Docker Toolbox (VirtualBox) you cannot access WebODM from another computer in the same network. | As Administrator, run `cmd.exe` and then type `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" controlvm "default" natpf1 "rule-name,tcp,,8000,,8000"`
|
||||
On Windows, the storage space shown on the WebODM diagnostic page is not the same as what is actually set in Docker's settings. | From Hyper-V Manager, right-click “DockerDesktopVM”, go to Edit Disk, then choose to expand the disk and match the maximum size to the settings specified in the docker settings. Upon making the changes, restart docker.
|
||||
|
||||
#### Images Missing from Lightning Assets
|
||||
|
||||
When you use Lightning to process your task, you will need to download all assets to your local instance of WebODM. The all assets zip does *not* contain the images which were used to create the orthomosaic. This means that, although you can visualise the cameras layer in your local WebODM, when you click on a particular camera icon the image will not be shown.
|
||||
|
||||
The fix if you are using WebODM with Docker is as follows (instructions are for MacOS host):
|
||||
|
||||
1. Ensure that you have a directory which contains all of the images for the task and only the images;
|
||||
2. Open Docker Desktop and navigate to Containers. Identify your WebODM instance and navigate to the container that is named `worker`. You will need the Container ID. This is a hash which is listed under the container name. Click to copy the Container ID using the copy icon next to it.
|
||||
3. Open Terminal and enter `docker cp <sourcedirectory>/. <dockercontainerID>:/webodm/app/media/project/<projectID>/task/<taskID>`. Paste the Container ID to replace the location titled `<dockercontainerID>`. Enter the full directory path for your images to replace `<sourcedirectory>`;
|
||||
4. Go back to Docker Desktop and navigate to Volumes in the side bar. Click on the volume called `webodm_appmedia`, click on `project`, identify the correct project and click on it, click on `task` and identify the correct task.
|
||||
5. From Docker Desktop substitute the correct `<projectID>` and `<taskID>` into the command in Terminal;
|
||||
6. Execute the newly edited command in Terminal. You will see a series of progress messages and your images will be copied to Docker;
|
||||
7. Navigate to your project in your local instance of WebODM;
|
||||
8. Open the Map and turn on the Cameras layer (top left);
|
||||
9. Click on a Camera icon and the relevant image will be shown
|
||||
|
||||
|
||||
Have you had other issues? Please [report them](https://github.com/OpenDroneMap/WebODM/issues/new) so that we can include them in this document.
|
||||
|
||||
### Backup and Restore
|
||||
|
@ -216,21 +241,19 @@ To run a standalone installation of WebODM (the user interface), including the p
|
|||
* 100 GB free disk space
|
||||
* 16 GB RAM
|
||||
|
||||
Don't expect to process more than a few hundred images with these specifications. To process larger datasets, add more RAM linearly to the number of images you want to process. A CPU with more cores will speed up processing, but can increase memory usage. GPU acceleration is still a work in progress, so currently a good video card does not improve performance.
|
||||
Don't expect to process more than a few hundred images with these specifications. To process larger datasets, add more RAM linearly to the number of images you want to process. A CPU with more cores will speed up processing, but can increase memory usage. GPU acceleration is also supported. To make use of your CUDA-compatible graphics card, make sure to pass `--gpu` when starting WebODM. You need the nvidia-docker installed in this case, see https://github.com/NVIDIA/nvidia-docker and https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker for information on docker/NVIDIA setup.
|
||||
|
||||
WebODM runs best on Linux, but works well on Windows and Mac too. If you are technically inclined, you can get WebODM to run natively on all three platforms and there's a [native installer for Ubuntu 16.04](https://www.opendronemap.org/webodm/server-installer/) also available.
|
||||
|
||||
[NodeODM](https://github.com/OpenDroneMap/NodeODM) and [ODM](https://github.com/OpenDroneMap/ODM) cannot run natively on Mac and Windows and this is the reason we mostly recommend people to use docker.
|
||||
WebODM runs best on Linux, but works well on Windows and Mac too. If you are technically inclined, you can get WebODM to run natively on all three platforms.
|
||||
|
||||
WebODM by itself is just a user interface (see [below](#odm-nodeodm-webodm-what)) and does not require many resources. WebODM can be loaded on a machine with just 1 or 2 GB of RAM and work fine without NodeODM. You can then use a processing service such as the [lightning network](https://webodm.net) or run NodeODM on a separate, more powerful machine.
|
||||
|
||||
## Customizing and Extending
|
||||
|
||||
Small customizations such as changing the application colors, name, logo, or addying custom CSS/HTML/Javascript can be performed directly from the Customize -- Brand/Theme panels within WebODM. No need to fork or change the code.
|
||||
Small customizations such as changing the application colors, name, logo, or adding custom CSS/HTML/Javascript can be performed directly from the Customize -- Brand/Theme panels within WebODM. No need to fork or change the code.
|
||||
|
||||
More advanced customizations can be achieved by writing [plugins](https://github.com/OpenDroneMap/WebODM/tree/master/plugins). This is the preferred way to add new functionality to WebODM since it requires less effort than maintaining a separate fork. The plugin system features server-side [signals](https://github.com/OpenDroneMap/WebODM/blob/master/app/plugins/signals.py) that can be used to be notified of various events, a ES6/React build system, a dynamic [client-side API](https://github.com/OpenDroneMap/WebODM/tree/master/app/static/app/js/classes/plugins) for adding elements to the UI, a built-in data store, an async task runner, a GRASS engine, hooks to add menu items and functions to rapidly inject CSS, Javascript and Django views.
|
||||
More advanced customizations can be achieved by writing [plugins](https://github.com/OpenDroneMap/WebODM/tree/master/coreplugins). This is the preferred way to add new functionality to WebODM since it requires less effort than maintaining a separate fork. The plugin system features server-side [signals](https://github.com/OpenDroneMap/WebODM/blob/master/app/plugins/signals.py) that can be used to be notified of various events, a ES6/React build system, a dynamic [client-side API](https://github.com/OpenDroneMap/WebODM/tree/master/app/static/app/js/classes/plugins) for adding elements to the UI, a built-in data store, an async task runner, a GRASS engine, hooks to add menu items and functions to rapidly inject CSS, Javascript and Django views.
|
||||
|
||||
The plugin system is still in beta. The best source of documentation currently is to look at existing [code](https://github.com/OpenDroneMap/WebODM/tree/master/plugins). If a particular hook / entrypoint for your plugin does not yet exist, [request it](https://github.com/OpenDroneMap/WebODM/issues). We are adding hooks and entrypoints as we go.
|
||||
For plugins, the best source of documentation currently is to look at existing [code](https://github.com/OpenDroneMap/WebODM/tree/master/coreplugins). If a particular hook / entrypoint for your plugin does not yet exist, [request it](https://github.com/OpenDroneMap/WebODM/issues). We are adding hooks and entrypoints as we go.
|
||||
|
||||
To create a plugin simply copy the `plugins/test` plugin into a new directory (for example, `plugins/myplugin`), then modify `manifest.json`, `plugin.py` and issue a `./webodm.sh restart`.
|
||||
|
||||
|
@ -253,7 +276,7 @@ We have several channels of communication for people to ask questions and to get
|
|||
- [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm)
|
||||
- [Report Issues](https://github.com/OpenDroneMap/WebODM/issues)
|
||||
|
||||
We also have a [Gitter Chat](https://gitter.im/OpenDroneMap/web-development), but the preferred way to communicate is via the [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm).
|
||||
The preferred way to communicate is via the [OpenDroneMap Community Forum](http://community.opendronemap.org/c/webodm).
|
||||
|
||||
## Support the Project
|
||||
|
||||
|
@ -264,6 +287,7 @@ There are many ways to contribute back to the project:
|
|||
- Help answer questions on the community [forum](http://community.opendronemap.org/c/webodm) and [chat](https://gitter.im/OpenDroneMap/web-development).
|
||||
- ⭐️ us on GitHub.
|
||||
- Help us [translate](#translations) WebODM in your language.
|
||||
- Help us classify [point cloud datasets](https://github.com/OpenDroneMap/ODMSemantic3D).
|
||||
- Spread the word about WebODM and OpenDroneMap on social media.
|
||||
- While we don't accept donations, you can purchase an [installer](https://webodm.org/download#installer), a [book](https://odmbook.com/) or a [sponsor package](https://github.com/users/pierotofy/sponsorship).
|
||||
- You can [pledge funds](https://fund.webodm.org) for getting new features built and bug fixed.
|
||||
|
@ -326,36 +350,37 @@ If you wish to run the docker version with auto start/monitoring/stop, etc, as a
|
|||
|
||||
This should work on any Linux OS capable of running WebODM, and using a SystemD based service daemon (such as Ubuntu 16.04 server for example).
|
||||
|
||||
This has only been tested on Ubuntu 16.04 server.
|
||||
This has only been tested on Ubuntu 16.04 server and Red Hat Enterprise Linux 9.
|
||||
|
||||
The following pre-requisites are required:
|
||||
* Requires odm user
|
||||
* Requires docker installed via system (ubuntu: `sudo apt-get install docker.io`)
|
||||
* Requires screen to be installed
|
||||
* Requires 'screen' package to be installed
|
||||
* Requires odm user member of docker group
|
||||
* Required WebODM directory checked out to /webodm
|
||||
* Requires that /webodm is recursively owned by odm:odm
|
||||
* Requires that a Python 3 environment is used at /webodm/python3-venv
|
||||
* Required WebODM directory checked out/cloned to /opt/WebODM
|
||||
* Requires that /opt/WebODM is recursively owned by odm:odm
|
||||
* Requires that a Python 3 environment is used at /opt/WebODM/python3-venv
|
||||
|
||||
If all pre-requisites have been met, and repository is checked out to /opt/WebODM folder, then you can use the following steps to enable and manage the service:
|
||||
If all pre-requisites have been met, and repository is checked out/cloned to /opt/WebODM folder, then you can use the following steps to enable and manage the service:
|
||||
|
||||
First, to install the service, and enable the services to run at startup from now on:
|
||||
```bash
|
||||
sudo systemctl enable /webodm/service/webodm-gunicorn.service
|
||||
sudo systemctl enable /webodm/service/webodm-nginx.service
|
||||
sudo systemctl enable /opt/WebODM/service/webodm-docker.service
|
||||
```
|
||||
|
||||
To manually start/stop the service:
|
||||
```bash
|
||||
sudo systemctl stop webodm-gunicorn
|
||||
sudo systemctl start webodm-gunicorn
|
||||
sudo systemctl stop webodm-docker
|
||||
sudo systemctl start webodm-docker
|
||||
```
|
||||
|
||||
To manually check service status:
|
||||
```bash
|
||||
sudo systemctl status webodm-gunicorn
|
||||
sudo systemctl status webodm-docker
|
||||
```
|
||||
|
||||
For the adventurous, the repository can be put anyplace you like by editing the ./WebODM/service/webodm-docker.service file before enabling the service the reflect your repository location, and modifying the systemctl enable command to that directiory.
|
||||
|
||||
## Run it natively
|
||||
|
||||
WebODM can run natively on Windows, MacOS and Linux. We don't recommend to run WebODM natively (using docker is easier), but it's possible.
|
||||
|
|
78
app/admin.py
78
app/admin.py
|
@ -10,41 +10,54 @@ from django.http import HttpResponseRedirect
|
|||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from guardian.admin import GuardedModelAdmin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from app.models import PluginDatum
|
||||
from app.models import Preset
|
||||
from app.models import Plugin
|
||||
from app.models import Profile
|
||||
from app.plugins import get_plugin_by_name, enable_plugin, disable_plugin, delete_plugin, valid_plugin, \
|
||||
get_plugins_persistent_path, clear_plugins_cache, init_plugins
|
||||
from .models import Project, Task, ImageUpload, Setting, Theme
|
||||
from .models import Project, Task, Setting, Theme
|
||||
from django import forms
|
||||
from codemirror2.widgets import CodeMirrorEditor
|
||||
from webodm import settings
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.utils.translation import gettext_lazy as _, gettext
|
||||
|
||||
admin.site.register(Project, GuardedModelAdmin)
|
||||
|
||||
class ProjectAdmin(GuardedModelAdmin):
|
||||
list_display = ('id', 'name', 'owner', 'created_at', 'tasks_count', 'tags')
|
||||
list_filter = ('owner',)
|
||||
search_fields = ('id', 'name', 'owner__username')
|
||||
|
||||
|
||||
admin.site.register(Project, ProjectAdmin)
|
||||
|
||||
|
||||
class TaskAdmin(admin.ModelAdmin):
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
admin.site.register(Task, TaskAdmin)
|
||||
|
||||
class ImageUploadAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ('image',)
|
||||
admin.site.register(ImageUpload, ImageUploadAdmin)
|
||||
list_display = ('id', 'name', 'project', 'processing_node', 'created_at', 'status', 'last_error')
|
||||
list_filter = ('status', 'project',)
|
||||
search_fields = ('id', 'name', 'project__name')
|
||||
|
||||
|
||||
admin.site.register(Task, TaskAdmin)
|
||||
|
||||
admin.site.register(Preset, admin.ModelAdmin)
|
||||
|
||||
|
||||
class SettingAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# if there's already an entry, do not allow adding
|
||||
count = Setting.objects.all().count()
|
||||
return count == 0
|
||||
|
||||
|
||||
admin.site.register(Setting, SettingAdmin)
|
||||
|
||||
|
||||
|
@ -64,11 +77,12 @@ class ThemeModelForm(forms.ModelForm):
|
|||
html_after_body = forms.CharField(help_text=_("HTML that will be displayed after the body tag"),
|
||||
label=_("HTML (after body)"),
|
||||
required=False,
|
||||
widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True}))
|
||||
html_footer = forms.CharField(help_text=_("HTML that will be displayed in the footer. You can also use the special tags such as {ORGANIZATION} and {YEAR}."),
|
||||
label=_("HTML (footer)"),
|
||||
required=False,
|
||||
widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True}))
|
||||
widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True}))
|
||||
html_footer = forms.CharField(help_text=_(
|
||||
"HTML that will be displayed in the footer. You can also use the special tags such as {ORGANIZATION} and {YEAR}."),
|
||||
label=_("HTML (footer)"),
|
||||
required=False,
|
||||
widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True}))
|
||||
|
||||
class Meta:
|
||||
model = Theme
|
||||
|
@ -85,11 +99,12 @@ admin.site.register(PluginDatum, admin.ModelAdmin)
|
|||
|
||||
class PluginAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "description", "version", "author", "enabled", "plugin_actions")
|
||||
readonly_fields = ("name", )
|
||||
readonly_fields = ("name",)
|
||||
change_list_template = "admin/change_list_plugin.html"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
@ -141,9 +156,11 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
try:
|
||||
p = enable_plugin(plugin_name)
|
||||
if p.requires_restart():
|
||||
messages.warning(request, _("Restart required. Please restart WebODM to enable %(plugin)s") % {'plugin': plugin_name})
|
||||
messages.warning(request, _("Restart required. Please restart WebODM to enable %(plugin)s") % {
|
||||
'plugin': plugin_name})
|
||||
except Exception as e:
|
||||
messages.warning(request, _("Cannot enable plugin %(plugin)s: %(message)s") % {'plugin': plugin_name, 'message': str(e)})
|
||||
messages.warning(request, _("Cannot enable plugin %(plugin)s: %(message)s") % {'plugin': plugin_name,
|
||||
'message': str(e)})
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
|
@ -151,9 +168,11 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
try:
|
||||
p = disable_plugin(plugin_name)
|
||||
if p.requires_restart():
|
||||
messages.warning(request, _("Restart required. Please restart WebODM to fully disable %(plugin)s") % {'plugin': plugin_name})
|
||||
messages.warning(request, _("Restart required. Please restart WebODM to fully disable %(plugin)s") % {
|
||||
'plugin': plugin_name})
|
||||
except Exception as e:
|
||||
messages.warning(request, _("Cannot disable plugin %(plugin)s: %(message)s") % {'plugin': plugin_name, 'message': str(e)})
|
||||
messages.warning(request, _("Cannot disable plugin %(plugin)s: %(message)s") % {'plugin': plugin_name,
|
||||
'message': str(e)})
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
|
@ -161,7 +180,8 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
try:
|
||||
delete_plugin(plugin_name)
|
||||
except Exception as e:
|
||||
messages.warning(request, _("Cannot delete plugin %(plugin)s: %(message)s") % {'plugin': plugin_name, 'message': str(e)})
|
||||
messages.warning(request, _("Cannot delete plugin %(plugin)s: %(message)s") % {'plugin': plugin_name,
|
||||
'message': str(e)})
|
||||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
|
@ -193,10 +213,13 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
plugin_name = folders[0]
|
||||
plugin_path = os.path.join(tmp_extract_path, plugin_name)
|
||||
if not valid_plugin(plugin_path):
|
||||
raise ValueError("This doesn't look like a plugin. Are plugin.py and manifest.json in the proper place?")
|
||||
raise ValueError(
|
||||
"This doesn't look like a plugin. Are plugin.py and manifest.json in the proper place?")
|
||||
|
||||
if os.path.exists(get_plugins_persistent_path(plugin_name)):
|
||||
raise ValueError("A plugin with the name {} already exist. Please remove it before uploading one with the same name.".format(plugin_name))
|
||||
raise ValueError(
|
||||
"A plugin with the name {} already exist. Please remove it before uploading one with the same name.".format(
|
||||
plugin_name))
|
||||
|
||||
# Move
|
||||
shutil.move(plugin_path, get_plugins_persistent_path())
|
||||
|
@ -217,13 +240,13 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
|
||||
return HttpResponseRedirect(reverse('admin:app_plugin_changelist'))
|
||||
|
||||
|
||||
def plugin_actions(self, obj):
|
||||
plugin = get_plugin_by_name(obj.name, only_active=False)
|
||||
return format_html(
|
||||
'<a class="button" href="{}" {}>{}</a> '
|
||||
'<a class="button" href="{}" {}>{}</a>'
|
||||
+ (' <a class="button" href="{}" onclick="return confirm(\'Are you sure you want to delete {}?\')"><i class="fa fa-trash"></i></a>' if not plugin.is_persistent() else ' ')
|
||||
+ (
|
||||
' <a class="button" href="{}" onclick="return confirm(\'Are you sure you want to delete {}?\')"><i class="fa fa-trash"></i></a>' if not plugin.is_persistent() else ' ')
|
||||
,
|
||||
reverse('admin:plugin-disable', args=[obj.pk]) if obj.enabled else '#',
|
||||
'disabled' if not obj.enabled else '',
|
||||
|
@ -240,3 +263,14 @@ class PluginAdmin(admin.ModelAdmin):
|
|||
|
||||
|
||||
admin.site.register(Plugin, PluginAdmin)
|
||||
|
||||
class ProfileInline(admin.StackedInline):
|
||||
model = Profile
|
||||
can_delete = False
|
||||
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
inlines = [ProfileInline]
|
||||
|
||||
# Re-register UserAdmin
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, UserAdmin)
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
from django.contrib.auth.models import User, Group
|
||||
from rest_framework import serializers, viewsets, generics, status
|
||||
from app.models import Profile
|
||||
from rest_framework import serializers, viewsets, generics, status, exceptions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from app import models
|
||||
|
||||
|
@ -20,6 +23,7 @@ class AdminUserViewSet(viewsets.ModelViewSet):
|
|||
if email is not None:
|
||||
queryset = queryset.filter(email=email)
|
||||
return queryset
|
||||
|
||||
def create(self, request):
|
||||
data = request.data.copy()
|
||||
password = data.get('password')
|
||||
|
@ -44,3 +48,37 @@ class AdminGroupViewSet(viewsets.ModelViewSet):
|
|||
if name is not None:
|
||||
queryset = queryset.filter(name=name)
|
||||
return queryset
|
||||
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Profile
|
||||
exclude = ('id', )
|
||||
|
||||
read_only_fields = ('user', )
|
||||
|
||||
class AdminProfileViewSet(viewsets.ModelViewSet):
|
||||
pagination_class = None
|
||||
serializer_class = ProfileSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
lookup_field = 'user'
|
||||
|
||||
def get_queryset(self):
|
||||
return Profile.objects.all()
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def update_quota_deadline(self, request, user=None):
|
||||
try:
|
||||
hours = float(request.data.get('hours', ''))
|
||||
if hours < 0:
|
||||
raise ValueError("hours must be >= 0")
|
||||
except ValueError as e:
|
||||
raise exceptions.ValidationError(str(e))
|
||||
|
||||
try:
|
||||
p = Profile.objects.get(user=user)
|
||||
except ObjectDoesNotExist:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
return Response({'deadline': p.set_quota_deadline(hours)}, status=status.HTTP_200_OK)
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth import login
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import exceptions, permissions, parsers
|
||||
from rest_framework.response import Response
|
||||
from app.auth.backends import get_user_from_external_auth_response
|
||||
import requests
|
||||
from webodm import settings
|
||||
|
||||
class ExternalTokenAuth(APIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
parser_classes = (parsers.JSONParser, parsers.FormParser,)
|
||||
|
||||
def post(self, request):
|
||||
# This should never happen
|
||||
if settings.EXTERNAL_AUTH_ENDPOINT == '':
|
||||
return Response({'error': 'EXTERNAL_AUTH_ENDPOINT not set'})
|
||||
|
||||
token = request.COOKIES.get('external_access_token', '')
|
||||
if token == '':
|
||||
return Response({'error': 'external_access_token cookie not set'})
|
||||
|
||||
try:
|
||||
r = requests.post(settings.EXTERNAL_AUTH_ENDPOINT, headers={
|
||||
'Authorization': "Bearer %s" % token
|
||||
})
|
||||
res = r.json()
|
||||
if res.get('user_id') is not None:
|
||||
user = get_user_from_external_auth_response(res)
|
||||
if user is not None:
|
||||
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
return Response({'redirect': '/'})
|
||||
else:
|
||||
return Response({'error': 'Invalid credentials'})
|
||||
else:
|
||||
return Response({'error': res.get('message', 'Invalid external server response')})
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)})
|
||||
|
|
@ -9,19 +9,28 @@ from django.utils.translation import gettext_lazy as _
|
|||
algos = {
|
||||
'NDVI': {
|
||||
'expr': '(N - R) / (N + R)',
|
||||
'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.')
|
||||
'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'NDYI': {
|
||||
'expr': '(G - B) / (G + B)',
|
||||
'help': _('Normalized difference yellowness index (NDYI), best model variability in relative yield potential in Canola.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'NDRE': {
|
||||
'expr': '(N - Re) / (N + Re)',
|
||||
'help': _('Normalized Difference Red Edge Index shows the amount of green vegetation of permanent or later stage crops.')
|
||||
'help': _('Normalized Difference Red Edge Index shows the amount of green vegetation of permanent or later stage crops.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'NDWI': {
|
||||
'expr': '(G - N) / (G + N)',
|
||||
'help': _('Normalized Difference Water Index shows the amount of water content in water bodies.')
|
||||
'help': _('Normalized Difference Water Index shows the amount of water content in water bodies.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'NDVI (Blue)': {
|
||||
'expr': '(N - B) / (N + B)',
|
||||
'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.')
|
||||
'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'ENDVI':{
|
||||
'expr': '((N + G) - (2 * B)) / ((N + G) + (2 * B))',
|
||||
|
@ -35,15 +44,16 @@ algos = {
|
|||
'expr': '(G - R) / (G + R - B)',
|
||||
'help': _('Visual Atmospheric Resistance Index shows the areas of vegetation.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'MPRI': {
|
||||
'expr': '(G - R) / (G + R)',
|
||||
'help': _('Modified Photochemical Reflectance Index'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'EXG': {
|
||||
'expr': '(2 * G) - (R + B)',
|
||||
'help': _('Excess Green Index (derived from only the RGB bands) emphasizes the greenness of leafy crops such as potatoes.')
|
||||
},
|
||||
'TGI': {
|
||||
'expr': '(G - 0.39) * (R - 0.61) * B',
|
||||
'help': _('Triangular Greenness Index (derived from only the RGB bands) performs similarly to EXG but with improvements over certain environments.')
|
||||
},
|
||||
'BAI': {
|
||||
'expr': '1.0 / (((0.1 - R) ** 2) + ((0.06 - N) ** 2))',
|
||||
'help': _('Burn Area Index hightlights burned land in the red to near-infrared spectrum.')
|
||||
|
@ -55,7 +65,8 @@ algos = {
|
|||
},
|
||||
'GNDVI':{
|
||||
'expr': '(N - G) / (N + G)',
|
||||
'help': _('Green Normalized Difference Vegetation Index is similar to NDVI, but measures the green spectrum instead of red.')
|
||||
'help': _('Green Normalized Difference Vegetation Index is similar to NDVI, but measures the green spectrum instead of red.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'GRVI':{
|
||||
'expr': 'N / G',
|
||||
|
@ -95,13 +106,18 @@ algos = {
|
|||
'help': _('Enhanced Vegetation Index is useful in areas where NDVI might saturate, by using blue wavelengths to correct soil signals.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'Thermal C': {
|
||||
'expr': 'Lwir',
|
||||
'help': _('Thermal temperature in Celsius degrees.')
|
||||
'ARVI': {
|
||||
'expr': '(N - (2 * R) + B) / (N + (2 * R) + B)',
|
||||
'help': _('Atmospherically Resistant Vegetation Index. Useful when working with imagery for regions with high atmospheric aerosol content.'),
|
||||
'range': (-1, 1)
|
||||
},
|
||||
'Thermal K': {
|
||||
'expr': 'Lwir / 100 - 273.15',
|
||||
'help': _('Thermal temperature in Centikelvin degrees.')
|
||||
'Celsius': {
|
||||
'expr': 'L',
|
||||
'help': _('Temperature in Celsius degrees.')
|
||||
},
|
||||
'Kelvin': {
|
||||
'expr': 'L * 100 + 27315',
|
||||
'help': _('Temperature in Centikelvin degrees.')
|
||||
},
|
||||
|
||||
# more?
|
||||
|
@ -124,15 +140,21 @@ camera_filters = [
|
|||
'NRB',
|
||||
|
||||
'RGBN',
|
||||
'RGNRe',
|
||||
'GRReN',
|
||||
|
||||
'RGBNRe',
|
||||
'BGRNRe',
|
||||
'BGRReN',
|
||||
'RGBNRe',
|
||||
'RGBReN',
|
||||
|
||||
'BGRNReLwir',
|
||||
'BGRReNLwir',
|
||||
'RGBNReL',
|
||||
'BGRNReL',
|
||||
'BGRReNL',
|
||||
|
||||
'RGBNRePL',
|
||||
|
||||
'L', # FLIR camera has a single LWIR band
|
||||
|
||||
# more?
|
||||
# TODO: certain cameras have only two bands? eg. MAPIR NDVI BLUE+NIR
|
||||
|
@ -147,9 +169,9 @@ def lookup_formula(algo, band_order = 'RGB'):
|
|||
|
||||
if algo not in algos:
|
||||
raise ValueError("Cannot find algorithm " + algo)
|
||||
|
||||
input_bands = tuple(band_order)
|
||||
|
||||
|
||||
input_bands = tuple(b for b in re.split(r"([A-Z][a-z]*)", band_order) if b != "")
|
||||
|
||||
def repl(matches):
|
||||
b = matches.group(1)
|
||||
try:
|
||||
|
@ -169,7 +191,7 @@ def get_algorithm_list(max_bands=3):
|
|||
if k.startswith("_"):
|
||||
continue
|
||||
|
||||
cam_filters = get_camera_filters_for(algos[k], max_bands)
|
||||
cam_filters = get_camera_filters_for(algos[k]['expr'], max_bands)
|
||||
|
||||
if len(cam_filters) == 0:
|
||||
continue
|
||||
|
@ -182,9 +204,9 @@ def get_algorithm_list(max_bands=3):
|
|||
|
||||
return res
|
||||
|
||||
def get_camera_filters_for(algo, max_bands=3):
|
||||
@lru_cache(maxsize=100)
|
||||
def get_camera_filters_for(expr, max_bands=3):
|
||||
result = []
|
||||
expr = algo['expr']
|
||||
pattern = re.compile("([A-Z]+?[a-z]*)")
|
||||
bands = list(set(re.findall(pattern, expr)))
|
||||
for f in camera_filters:
|
||||
|
@ -202,3 +224,45 @@ def get_camera_filters_for(algo, max_bands=3):
|
|||
|
||||
return result
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_bands_lookup():
|
||||
bands_aliases = {
|
||||
'R': ['red', 'r'],
|
||||
'G': ['green', 'g'],
|
||||
'B': ['blue', 'b'],
|
||||
'N': ['nir', 'n'],
|
||||
'Re': ['rededge', 're'],
|
||||
'P': ['panchro', 'p'],
|
||||
'L': ['lwir', 'l']
|
||||
}
|
||||
bands_lookup = {}
|
||||
for band in bands_aliases:
|
||||
for a in bands_aliases[band]:
|
||||
bands_lookup[a] = band
|
||||
return bands_lookup
|
||||
|
||||
def get_auto_bands(orthophoto_bands, formula):
|
||||
algo = algos.get(formula)
|
||||
if not algo:
|
||||
raise ValueError("Cannot find formula: " + formula)
|
||||
|
||||
max_bands = len(orthophoto_bands) - 1 # minus alpha
|
||||
filters = get_camera_filters_for(algo['expr'], max_bands)
|
||||
if not filters:
|
||||
raise valueError(f"Cannot find filters for {algo} with max bands {max_bands}")
|
||||
|
||||
bands_lookup = get_bands_lookup()
|
||||
band_order = ""
|
||||
|
||||
for band in orthophoto_bands:
|
||||
if band['name'] == 'alpha' or (not band['description']):
|
||||
continue
|
||||
f_band = bands_lookup.get(band['description'].lower())
|
||||
|
||||
if f_band is not None:
|
||||
band_order += f_band
|
||||
|
||||
if band_order in filters:
|
||||
return band_order, True
|
||||
else:
|
||||
return filters[0], False # Fallback
|
||||
|
|
|
@ -4,7 +4,6 @@ import math
|
|||
|
||||
from .tasks import TaskNestedView
|
||||
from rest_framework import exceptions
|
||||
from app.models import ImageUpload
|
||||
from app.models.task import assets_directory_path
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
from django.http import HttpResponse
|
||||
|
@ -33,12 +32,7 @@ class Thumbnail(TaskNestedView):
|
|||
Generate a thumbnail on the fly for a particular task's image
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk)
|
||||
image = ImageUpload.objects.filter(task=task, image=assets_directory_path(task.id, task.project.id, image_filename)).first()
|
||||
|
||||
if image is None:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
image_path = image.path()
|
||||
image_path = task.get_image_path(image_filename)
|
||||
if not os.path.isfile(image_path):
|
||||
raise exceptions.NotFound()
|
||||
|
||||
|
@ -146,12 +140,7 @@ class ImageDownload(TaskNestedView):
|
|||
Download a task's image
|
||||
"""
|
||||
task = self.get_and_check_task(request, pk)
|
||||
image = ImageUpload.objects.filter(task=task, image=assets_directory_path(task.id, task.project.id, image_filename)).first()
|
||||
|
||||
if image is None:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
image_path = image.path()
|
||||
image_path = task.get_image_path(image_filename)
|
||||
if not os.path.isfile(image_path):
|
||||
raise exceptions.NotFound()
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from nodeodm.models import ProcessingNode
|
||||
|
||||
from webodm import settings
|
||||
|
||||
class ProcessingNodeSerializer(serializers.ModelSerializer):
|
||||
online = serializers.SerializerMethodField()
|
||||
|
@ -49,6 +49,18 @@ class ProcessingNodeViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = ProcessingNodeSerializer
|
||||
queryset = ProcessingNode.objects.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
if settings.UI_MAX_PROCESSING_NODES is not None:
|
||||
queryset = queryset[:settings.UI_MAX_PROCESSING_NODES]
|
||||
|
||||
if settings.NODE_OPTIMISTIC_MODE:
|
||||
for pn in queryset:
|
||||
pn.update_node_info()
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class ProcessingNodeOptionsView(APIView):
|
||||
"""
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import re
|
||||
from guardian.shortcuts import get_perms, get_users_with_perms, assign_perm, remove_perm
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django_filters import rest_framework as filters
|
||||
from django.db import transaction
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.search import SearchQuery, SearchVector
|
||||
from django.contrib.postgres.aggregates import StringAgg
|
||||
from django.db.models import Q
|
||||
|
||||
from app import models
|
||||
from .tasks import TaskIDsSerializer
|
||||
from .tags import TagsField, parse_tags_input
|
||||
from .common import get_and_check_project
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
@ -19,8 +25,10 @@ class ProjectSerializer(serializers.ModelSerializer):
|
|||
owner = serializers.HiddenField(
|
||||
default=serializers.CurrentUserDefault()
|
||||
)
|
||||
owned = serializers.SerializerMethodField()
|
||||
created_at = serializers.ReadOnlyField()
|
||||
permissions = serializers.SerializerMethodField()
|
||||
tags = TagsField(required=False)
|
||||
|
||||
def get_permissions(self, obj):
|
||||
if 'request' in self.context:
|
||||
|
@ -28,12 +36,61 @@ class ProjectSerializer(serializers.ModelSerializer):
|
|||
else:
|
||||
# Cannot list permissions, no user is associated with request (happens when serializing ui test mocks)
|
||||
return []
|
||||
|
||||
def get_owned(self, obj):
|
||||
if 'request' in self.context:
|
||||
user = self.context['request'].user
|
||||
return user.is_superuser or obj.owner.id == user.id
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
model = models.Project
|
||||
exclude = ('deleting', )
|
||||
|
||||
|
||||
class ProjectFilter(filters.FilterSet):
|
||||
search = filters.CharFilter(method='filter_search')
|
||||
|
||||
def filter_search(self, qs, name, value):
|
||||
value = value.replace(":", "#")
|
||||
tag_pattern = re.compile("#[^\s]+")
|
||||
tags = set(re.findall(tag_pattern, value))
|
||||
|
||||
task_tags = set([t for t in tags if t.startswith("##")])
|
||||
project_tags = tags - task_tags
|
||||
|
||||
task_tags = [t.replace("##", "") for t in task_tags]
|
||||
project_tags = [t.replace("#", "") for t in project_tags]
|
||||
|
||||
names = re.sub("\s+", " ", re.sub(tag_pattern, "", value)).strip()
|
||||
|
||||
if len(names) > 0:
|
||||
project_name_vec = SearchVector("name")
|
||||
task_name_vec = SearchVector(StringAgg("task__name", delimiter=' '))
|
||||
name_query = SearchQuery(names, search_type="plain")
|
||||
qs = qs.annotate(n_search=project_name_vec + task_name_vec).filter(n_search=name_query)
|
||||
|
||||
if len(task_tags) > 0:
|
||||
task_tags_vec = SearchVector("task__tags")
|
||||
tags_query = SearchQuery(task_tags[0])
|
||||
for t in task_tags[1:]:
|
||||
tags_query = tags_query & SearchQuery(t)
|
||||
qs = qs.annotate(tt_search=task_tags_vec).filter(tt_search=tags_query)
|
||||
|
||||
if len(project_tags) > 0:
|
||||
project_tags_vec = SearchVector("tags")
|
||||
tags_query = SearchQuery(project_tags[0])
|
||||
for t in project_tags[1:]:
|
||||
tags_query = tags_query & SearchQuery(t)
|
||||
qs = qs.annotate(pt_search=project_tags_vec).filter(pt_search=tags_query)
|
||||
|
||||
return qs.distinct()
|
||||
|
||||
class Meta:
|
||||
model = models.Project
|
||||
fields = ['search', 'id', 'name', 'description', 'created_at']
|
||||
|
||||
|
||||
class ProjectViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
Project get/add/delete/update
|
||||
|
@ -45,6 +102,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||
filter_fields = ('id', 'name', 'description', 'created_at')
|
||||
serializer_class = ProjectSerializer
|
||||
queryset = models.Project.objects.prefetch_related('task_set').filter(deleting=False).order_by('-created_at')
|
||||
filterset_class = ProjectFilter
|
||||
ordering_fields = '__all__'
|
||||
|
||||
# Disable pagination when not requesting any page
|
||||
|
@ -52,7 +110,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||
if self.paginator and self.request.query_params.get(self.paginator.page_query_param, None) is None:
|
||||
return None
|
||||
return super().paginate_queryset(queryset)
|
||||
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""
|
||||
|
@ -60,7 +118,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||
"""
|
||||
project = get_and_check_project(request, pk, ('change_project', ))
|
||||
|
||||
new_project = project.duplicate()
|
||||
new_project = project.duplicate(new_owner=request.user)
|
||||
if new_project:
|
||||
return Response({'success': True, 'project': ProjectSerializer(new_project).data}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
|
@ -89,6 +147,7 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||
with transaction.atomic():
|
||||
project.name = request.data.get('name', '')
|
||||
project.description = request.data.get('description', '')
|
||||
project.tags = TagsField().to_internal_value(parse_tags_input(request.data.get('tags', [])))
|
||||
project.save()
|
||||
|
||||
form_perms = request.data.get('permissions')
|
||||
|
@ -136,3 +195,20 @@ class ProjectViewSet(viewsets.ModelViewSet):
|
|||
return Response({'error': _("Invalid permissions")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response({'success': True}, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
project = get_and_check_project(request, pk, ('view_project', ))
|
||||
|
||||
# Owner? Delete the project
|
||||
if project.owner == request.user or request.user.is_superuser:
|
||||
get_and_check_project(request, pk, ('delete_project', ))
|
||||
|
||||
return super().destroy(self, request, pk=pk)
|
||||
else:
|
||||
# Do not remove the project, simply remove all user's permissions to the project
|
||||
# to avoid shared projects from being accidentally deleted
|
||||
for p in ["add", "change", "delete", "view"]:
|
||||
perm = p + "_project"
|
||||
remove_perm(perm, request.user, project)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from rest_framework import serializers
|
||||
import json
|
||||
|
||||
class TagsField(serializers.JSONField):
|
||||
def to_representation(self, tags):
|
||||
return [t for t in tags.split(" ") if t != ""]
|
||||
|
||||
def to_internal_value(self, tags):
|
||||
return " ".join([t.strip() for t in tags])
|
||||
|
||||
def parse_tags_input(tags):
|
||||
if tags is None:
|
||||
return []
|
||||
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
r = json.loads(tags)
|
||||
if isinstance(r, list):
|
||||
return r
|
||||
else:
|
||||
raise Exception("Invalid tags string")
|
||||
except:
|
||||
return []
|
||||
elif isinstance(tags, list):
|
||||
return list(map(str, tags))
|
||||
else:
|
||||
return []
|
105
app/api/tasks.py
105
app/api/tasks.py
|
@ -1,9 +1,11 @@
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
import mimetypes
|
||||
|
||||
from shutil import copyfileobj
|
||||
from shutil import copyfileobj, move
|
||||
from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation, ValidationError
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.db import transaction
|
||||
|
@ -20,9 +22,10 @@ from nodeodm import status_codes
|
|||
from nodeodm.models import ProcessingNode
|
||||
from worker import tasks as worker_tasks
|
||||
from .common import get_and_check_project, get_asset_download_filename
|
||||
from .tags import TagsField
|
||||
from app.security import path_traversal_check
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from webodm import settings
|
||||
|
||||
def flatten_files(request_files):
|
||||
# MultiValueDict in, flat array of files out
|
||||
|
@ -41,6 +44,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||
processing_node_name = serializers.SerializerMethodField()
|
||||
can_rerun_from = serializers.SerializerMethodField()
|
||||
statistics = serializers.SerializerMethodField()
|
||||
tags = TagsField(required=False)
|
||||
|
||||
def get_processing_node_name(self, obj):
|
||||
if obj.processing_node is not None:
|
||||
|
@ -72,8 +76,8 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.Task
|
||||
exclude = ('console_output', 'orthophoto_extent', 'dsm_extent', 'dtm_extent', )
|
||||
read_only_fields = ('processing_time', 'status', 'last_error', 'created_at', 'pending_action', 'available_assets', )
|
||||
exclude = ('orthophoto_extent', 'dsm_extent', 'dtm_extent', )
|
||||
read_only_fields = ('processing_time', 'status', 'last_error', 'created_at', 'pending_action', 'available_assets', 'size', )
|
||||
|
||||
class TaskViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
|
@ -81,7 +85,7 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
A task represents a set of images and other input to be sent to a processing node.
|
||||
Once a processing node completes processing, results are stored in the task.
|
||||
"""
|
||||
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dsm_extent', 'dtm_extent', 'console_output', )
|
||||
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dsm_extent', 'dtm_extent', )
|
||||
|
||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser, parsers.FormParser, )
|
||||
ordering_fields = '__all__'
|
||||
|
@ -143,8 +147,7 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
raise exceptions.NotFound()
|
||||
|
||||
line_num = max(0, int(request.query_params.get('line', 0)))
|
||||
output = task.console_output or ""
|
||||
return Response('\n'.join(output.rstrip().split('\n')[line_num:]))
|
||||
return Response('\n'.join(task.console.output().rstrip().split('\n')[line_num:]))
|
||||
|
||||
def list(self, request, project_pk=None):
|
||||
get_and_check_project(request, project_pk)
|
||||
|
@ -177,11 +180,12 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
raise exceptions.NotFound()
|
||||
|
||||
task.partial = False
|
||||
task.images_count = models.ImageUpload.objects.filter(task=task).count()
|
||||
task.images_count = len(task.scan_images())
|
||||
|
||||
if task.images_count < 2:
|
||||
raise exceptions.ValidationError(detail=_("You need to upload at least 2 images before commit"))
|
||||
if task.images_count < 1:
|
||||
raise exceptions.ValidationError(detail=_("You need to upload at least 1 file before commit"))
|
||||
|
||||
task.update_size()
|
||||
task.save()
|
||||
worker_tasks.process_task.delay(task.id)
|
||||
|
||||
|
@ -200,21 +204,17 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
raise exceptions.NotFound()
|
||||
|
||||
files = flatten_files(request.FILES)
|
||||
|
||||
if len(files) == 0:
|
||||
raise exceptions.ValidationError(detail=_("No files uploaded"))
|
||||
|
||||
with transaction.atomic():
|
||||
for image in files:
|
||||
models.ImageUpload.objects.create(task=task, image=image)
|
||||
|
||||
task.images_count = models.ImageUpload.objects.filter(task=task).count()
|
||||
uploaded = task.handle_images_upload(files)
|
||||
task.images_count = len(task.scan_images())
|
||||
# Update other parameters such as processing node, task name, etc.
|
||||
serializer = TaskSerializer(task, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response({'success': True}, status=status.HTTP_200_OK)
|
||||
return Response({'success': True, 'uploaded': uploaded}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None, project_pk=None):
|
||||
|
@ -254,9 +254,8 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
task = models.Task.objects.create(project=project,
|
||||
pending_action=pending_actions.RESIZE if 'resize_to' in request.data else None)
|
||||
|
||||
for image in files:
|
||||
models.ImageUpload.objects.create(task=task, image=image)
|
||||
task.images_count = len(files)
|
||||
task.handle_images_upload(files)
|
||||
task.images_count = len(task.scan_images())
|
||||
|
||||
# Update other parameters such as processing node, task name, etc.
|
||||
serializer = TaskSerializer(task, data=request.data, partial=True)
|
||||
|
@ -297,7 +296,7 @@ class TaskViewSet(viewsets.ViewSet):
|
|||
|
||||
|
||||
class TaskNestedView(APIView):
|
||||
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', 'console_output', )
|
||||
queryset = models.Task.objects.all().defer('orthophoto_extent', 'dtm_extent', 'dsm_extent', )
|
||||
permission_classes = (AllowAny, )
|
||||
|
||||
def get_and_check_task(self, request, pk, annotate={}):
|
||||
|
@ -366,19 +365,20 @@ class TaskDownloads(TaskNestedView):
|
|||
|
||||
# Check and download
|
||||
try:
|
||||
asset_fs, is_zipstream = task.get_asset_file_or_zipstream(asset)
|
||||
asset_fs = task.get_asset_file_or_stream(asset)
|
||||
except FileNotFoundError:
|
||||
raise exceptions.NotFound(_("Asset does not exist"))
|
||||
|
||||
if not is_zipstream and not os.path.isfile(asset_fs):
|
||||
is_stream = not isinstance(asset_fs, str)
|
||||
if not is_stream and not os.path.isfile(asset_fs):
|
||||
raise exceptions.NotFound(_("Asset does not exist"))
|
||||
|
||||
download_filename = request.GET.get('filename', get_asset_download_filename(task, asset))
|
||||
|
||||
if not is_zipstream:
|
||||
return download_file_response(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
else:
|
||||
if is_stream:
|
||||
return download_file_stream(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
else:
|
||||
return download_file_response(request, asset_fs, 'attachment', download_filename=download_filename)
|
||||
|
||||
"""
|
||||
Raw access to the task's asset folder resources
|
||||
|
@ -422,18 +422,52 @@ class TaskAssetsImport(APIView):
|
|||
if import_url and len(files) > 0:
|
||||
raise exceptions.ValidationError(detail=_("Cannot create task, either specify a URL or upload 1 file."))
|
||||
|
||||
chunk_index = request.data.get('dzchunkindex')
|
||||
uuid = request.data.get('dzuuid')
|
||||
total_chunk_count = request.data.get('dztotalchunkcount', None)
|
||||
|
||||
# Chunked upload?
|
||||
tmp_upload_file = None
|
||||
if len(files) > 0 and chunk_index is not None and uuid is not None and total_chunk_count is not None:
|
||||
byte_offset = request.data.get('dzchunkbyteoffset', 0)
|
||||
|
||||
try:
|
||||
chunk_index = int(chunk_index)
|
||||
byte_offset = int(byte_offset)
|
||||
total_chunk_count = int(total_chunk_count)
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(detail="Some parameters are not integers")
|
||||
uuid = re.sub('[^0-9a-zA-Z-]+', "", uuid)
|
||||
|
||||
tmp_upload_file = os.path.join(settings.FILE_UPLOAD_TEMP_DIR, f"{uuid}.upload")
|
||||
if os.path.isfile(tmp_upload_file) and chunk_index == 0:
|
||||
os.unlink(tmp_upload_file)
|
||||
|
||||
with open(tmp_upload_file, 'ab') as fd:
|
||||
fd.seek(byte_offset)
|
||||
if isinstance(files[0], InMemoryUploadedFile):
|
||||
for chunk in files[0].chunks():
|
||||
fd.write(chunk)
|
||||
else:
|
||||
with open(files[0].temporary_file_path(), 'rb') as file:
|
||||
fd.write(file.read())
|
||||
|
||||
if chunk_index + 1 < total_chunk_count:
|
||||
return Response({'uploaded': True}, status=status.HTTP_200_OK)
|
||||
|
||||
# Ready to import
|
||||
with transaction.atomic():
|
||||
task = models.Task.objects.create(project=project,
|
||||
auto_processing_node=False,
|
||||
name=task_name,
|
||||
import_url=import_url if import_url else "file://all.zip",
|
||||
status=status_codes.RUNNING,
|
||||
pending_action=pending_actions.IMPORT)
|
||||
auto_processing_node=False,
|
||||
name=task_name,
|
||||
import_url=import_url if import_url else "file://all.zip",
|
||||
status=status_codes.RUNNING,
|
||||
pending_action=pending_actions.IMPORT)
|
||||
task.create_task_directories()
|
||||
destination_file = task.assets_path("all.zip")
|
||||
|
||||
if len(files) > 0:
|
||||
destination_file = task.assets_path("all.zip")
|
||||
|
||||
# Non-chunked file import
|
||||
if tmp_upload_file is None and len(files) > 0:
|
||||
with open(destination_file, 'wb+') as fd:
|
||||
if isinstance(files[0], InMemoryUploadedFile):
|
||||
for chunk in files[0].chunks():
|
||||
|
@ -441,6 +475,9 @@ class TaskAssetsImport(APIView):
|
|||
else:
|
||||
with open(files[0].temporary_file_path(), 'rb') as file:
|
||||
copyfileobj(file, fd)
|
||||
elif tmp_upload_file is not None:
|
||||
# Move
|
||||
shutil.move(tmp_upload_file, destination_file)
|
||||
|
||||
worker_tasks.process_task.delay(task.id)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import rio_tiler.utils
|
|||
from rasterio.enums import ColorInterp
|
||||
from rasterio.crs import CRS
|
||||
from rasterio.features import bounds as featureBounds
|
||||
from rasterio.errors import NotGeoreferencedWarning
|
||||
import urllib
|
||||
import os
|
||||
from .common import get_asset_download_filename
|
||||
|
@ -16,19 +17,25 @@ from rio_tiler.models import Metadata as RioMetadata
|
|||
from rio_tiler.profiles import img_profiles
|
||||
from rio_tiler.colormap import cmap as colormap, apply_cmap
|
||||
from rio_tiler.io import COGReader
|
||||
from rio_tiler.errors import InvalidColorMapName
|
||||
from rio_tiler.errors import InvalidColorMapName, AlphaBandWarning
|
||||
import numpy as np
|
||||
from .custom_colormaps_helper import custom_colormaps
|
||||
from app.raster_utils import extension_for_export_format, ZOOM_EXTRA_LEVELS
|
||||
from .hsvblend import hsv_blend
|
||||
from .hillshade import LightSource
|
||||
from .formulas import lookup_formula, get_algorithm_list
|
||||
from .formulas import lookup_formula, get_algorithm_list, get_auto_bands
|
||||
from .tasks import TaskNestedView
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.response import Response
|
||||
from worker.tasks import export_raster, export_pointcloud
|
||||
from django.utils.translation import gettext as _
|
||||
import warnings
|
||||
|
||||
# Disable: NotGeoreferencedWarning: Dataset has no geotransform, gcps, or rpcs. The identity matrix be returned.
|
||||
warnings.filterwarnings("ignore", category=NotGeoreferencedWarning)
|
||||
|
||||
# Disable: Alpha band was removed from the output data array
|
||||
warnings.filterwarnings("ignore", category=AlphaBandWarning)
|
||||
|
||||
for custom_colormap in custom_colormaps:
|
||||
colormap = colormap.register(custom_colormap)
|
||||
|
@ -134,6 +141,12 @@ class Metadata(TaskNestedView):
|
|||
if boundaries_feature == '': boundaries_feature = None
|
||||
if boundaries_feature is not None:
|
||||
boundaries_feature = json.loads(boundaries_feature)
|
||||
|
||||
is_auto_bands_match = False
|
||||
is_auto_bands = False
|
||||
if bands == 'auto' and formula:
|
||||
is_auto_bands = True
|
||||
bands, is_auto_bands_match = get_auto_bands(task.orthophoto_bands, formula)
|
||||
try:
|
||||
expr, hrange = lookup_formula(formula, bands)
|
||||
if defined_range is not None:
|
||||
|
@ -194,6 +207,8 @@ class Metadata(TaskNestedView):
|
|||
for b in info['statistics']:
|
||||
info['statistics'][b]['min'] = hrange[0]
|
||||
info['statistics'][b]['max'] = hrange[1]
|
||||
info['statistics'][b]['percentiles'][0] = max(hrange[0], info['statistics'][b]['percentiles'][0])
|
||||
info['statistics'][b]['percentiles'][1] = min(hrange[1], info['statistics'][b]['percentiles'][1])
|
||||
|
||||
cmap_labels = {
|
||||
"viridis": "Viridis",
|
||||
|
@ -217,6 +232,8 @@ class Metadata(TaskNestedView):
|
|||
|
||||
colormaps = []
|
||||
algorithms = []
|
||||
auto_bands = {'filter': '', 'match': None}
|
||||
|
||||
if tile_type in ['dsm', 'dtm']:
|
||||
colormaps = ['viridis', 'jet', 'terrain', 'gist_earth', 'pastel1']
|
||||
elif formula and bands:
|
||||
|
@ -224,9 +241,14 @@ class Metadata(TaskNestedView):
|
|||
'better_discrete_ndvi',
|
||||
'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'jet', 'jet_r']
|
||||
algorithms = *get_algorithm_list(band_count),
|
||||
if is_auto_bands:
|
||||
auto_bands['filter'] = bands
|
||||
auto_bands['match'] = is_auto_bands_match
|
||||
|
||||
info['color_maps'] = []
|
||||
info['algorithms'] = algorithms
|
||||
info['auto_bands'] = auto_bands
|
||||
|
||||
if colormaps:
|
||||
for cmap in colormaps:
|
||||
try:
|
||||
|
@ -247,6 +269,7 @@ class Metadata(TaskNestedView):
|
|||
info['maxzoom'] += ZOOM_EXTRA_LEVELS
|
||||
info['minzoom'] -= ZOOM_EXTRA_LEVELS
|
||||
info['bounds'] = {'value': src.bounds, 'crs': src.dataset.crs}
|
||||
|
||||
return Response(info)
|
||||
|
||||
|
||||
|
@ -289,6 +312,8 @@ class Tiles(TaskNestedView):
|
|||
if color_map == '': color_map = None
|
||||
if hillshade == '' or hillshade == '0': hillshade = None
|
||||
if tilesize == '' or tilesize is None: tilesize = 256
|
||||
if bands == 'auto' and formula:
|
||||
bands, _discard_ = get_auto_bands(task.orthophoto_bands, formula)
|
||||
|
||||
try:
|
||||
tilesize = int(tilesize)
|
||||
|
@ -374,7 +399,7 @@ class Tiles(TaskNestedView):
|
|||
# Hillshading is not a local tile operation and
|
||||
# requires neighbor tiles to be rendered seamlessly
|
||||
if hillshade is not None:
|
||||
tile_buffer = tilesize
|
||||
tile_buffer = 16
|
||||
|
||||
try:
|
||||
if expr is not None:
|
||||
|
@ -446,17 +471,17 @@ class Tiles(TaskNestedView):
|
|||
# Remove elevation data from edge buffer tiles
|
||||
# (to keep intensity uniform across tiles)
|
||||
elevation = tile.data[0]
|
||||
elevation[0:tilesize, 0:tilesize] = nodata
|
||||
elevation[tilesize*2:tilesize*3, 0:tilesize] = nodata
|
||||
elevation[0:tilesize, tilesize*2:tilesize*3] = nodata
|
||||
elevation[tilesize*2:tilesize*3, tilesize*2:tilesize*3] = nodata
|
||||
elevation[0:tile_buffer, 0:tile_buffer] = nodata
|
||||
elevation[tile_buffer+tilesize:tile_buffer*2+tilesize, 0:tile_buffer] = nodata
|
||||
elevation[0:tile_buffer, tile_buffer+tilesize:tile_buffer*2+tilesize] = nodata
|
||||
elevation[tile_buffer+tilesize:tile_buffer*2+tilesize, tile_buffer+tilesize:tile_buffer*2+tilesize] = nodata
|
||||
|
||||
intensity = ls.hillshade(elevation, dx=dx, dy=dy, vert_exag=hillshade)
|
||||
intensity = intensity[tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
intensity = intensity[tile_buffer:tile_buffer+tilesize, tile_buffer:tile_buffer+tilesize]
|
||||
|
||||
if intensity is not None:
|
||||
rgb = tile.post_process(in_range=(rescale_arr,))
|
||||
rgb_data = rgb.data[:,tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
rgb_data = rgb.data[:,tile_buffer:tilesize+tile_buffer, tile_buffer:tilesize+tile_buffer]
|
||||
if colormap:
|
||||
rgb, _discard_ = apply_cmap(rgb_data, colormap.get(color_map))
|
||||
if rgb.data.shape[0] != 3:
|
||||
|
@ -465,7 +490,7 @@ class Tiles(TaskNestedView):
|
|||
intensity = intensity * 255.0
|
||||
rgb = hsv_blend(rgb, intensity)
|
||||
if rgb is not None:
|
||||
mask = tile.mask[tilesize:tilesize * 2, tilesize:tilesize * 2]
|
||||
mask = tile.mask[tile_buffer:tilesize+tile_buffer, tile_buffer:tilesize+tile_buffer]
|
||||
return HttpResponse(
|
||||
render(rgb, mask, img_format=driver, **options),
|
||||
content_type="image/{}".format(ext)
|
||||
|
@ -537,6 +562,9 @@ class Export(TaskNestedView):
|
|||
raise exceptions.ValidationError(_("Both formula and bands parameters are required"))
|
||||
|
||||
if formula and bands:
|
||||
if bands == 'auto':
|
||||
bands, _discard_ = get_auto_bands(task.orthophoto_bands, formula)
|
||||
|
||||
try:
|
||||
expr, _discard_ = lookup_formula(formula, bands)
|
||||
except ValueError as e:
|
||||
|
@ -604,4 +632,4 @@ class Export(TaskNestedView):
|
|||
else:
|
||||
celery_task_id = export_pointcloud.delay(url, epsg=epsg,
|
||||
format=export_format).task_id
|
||||
return Response({'celery_task_id': celery_task_id, 'filename': filename})
|
||||
return Response({'celery_task_id': celery_task_id, 'filename': filename})
|
||||
|
|
|
@ -6,13 +6,14 @@ from .projects import ProjectViewSet
|
|||
from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskAssetsImport
|
||||
from .imageuploads import Thumbnail, ImageDownload
|
||||
from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView
|
||||
from .admin import AdminUserViewSet, AdminGroupViewSet
|
||||
from .admin import AdminUserViewSet, AdminGroupViewSet, AdminProfileViewSet
|
||||
from rest_framework_nested import routers
|
||||
from rest_framework_jwt.views import obtain_jwt_token
|
||||
from .tiler import TileJson, Bounds, Metadata, Tiles, Export
|
||||
from .potree import Scene, CameraView
|
||||
from .workers import CheckTask, GetTaskResult
|
||||
from .users import UsersList
|
||||
from .externalauth import ExternalTokenAuth
|
||||
from webodm import settings
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
|
@ -26,6 +27,7 @@ tasks_router.register(r'tasks', TaskViewSet, basename='projects-tasks')
|
|||
admin_router = routers.DefaultRouter()
|
||||
admin_router.register(r'admin/users', AdminUserViewSet, basename='admin-users')
|
||||
admin_router.register(r'admin/groups', AdminGroupViewSet, basename='admin-groups')
|
||||
admin_router.register(r'admin/profiles', AdminProfileViewSet, basename='admin-groups')
|
||||
|
||||
urlpatterns = [
|
||||
url(r'processingnodes/options/$', ProcessingNodeOptionsView.as_view()),
|
||||
|
@ -56,9 +58,12 @@ urlpatterns = [
|
|||
url(r'^auth/', include('rest_framework.urls')),
|
||||
url(r'^token-auth/', obtain_jwt_token),
|
||||
|
||||
url(r'^plugins/(?P<plugin_name>[^/.]+)/(.*)$', api_view_handler)
|
||||
url(r'^plugins/(?P<plugin_name>[^/.]+)/(.*)$', api_view_handler),
|
||||
]
|
||||
|
||||
if settings.ENABLE_USERS_API:
|
||||
urlpatterns.append(url(r'users', UsersList.as_view()))
|
||||
|
||||
if settings.EXTERNAL_AUTH_ENDPOINT != '':
|
||||
urlpatterns.append(url(r'^external-token-auth/', ExternalTokenAuth.as_view()))
|
||||
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
import requests
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.models import User
|
||||
from nodeodm.models import ProcessingNode
|
||||
from webodm import settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
def get_user_from_external_auth_response(res):
|
||||
if 'message' in res or 'error' in res:
|
||||
return None
|
||||
|
||||
if 'user_id' in res and 'username' in res:
|
||||
try:
|
||||
user = User.objects.get(pk=res['user_id'])
|
||||
except User.DoesNotExist:
|
||||
user = User(pk=res['user_id'], username=res['username'])
|
||||
user.save()
|
||||
|
||||
# Update user info
|
||||
if user.username != res['username']:
|
||||
user.username = res['username']
|
||||
user.save()
|
||||
|
||||
maxQuota = -1
|
||||
if 'maxQuota' in res:
|
||||
maxQuota = res['maxQuota']
|
||||
if 'node' in res and 'limits' in res['node'] and 'maxQuota' in res['node']['limits']:
|
||||
maxQuota = res['node']['limits']['maxQuota']
|
||||
|
||||
# Update quotas
|
||||
if user.profile.quota != maxQuota:
|
||||
user.profile.quota = maxQuota
|
||||
user.save()
|
||||
|
||||
# Setup/update processing node
|
||||
if 'node' in res and 'hostname' in res['node'] and 'port' in res['node']:
|
||||
hostname = res['node']['hostname']
|
||||
port = res['node']['port']
|
||||
token = res['node'].get('token', '')
|
||||
|
||||
# Only add/update if a token is provided, since we use
|
||||
# tokens as unique identifiers for hostname/port updates
|
||||
if token != "":
|
||||
try:
|
||||
node = ProcessingNode.objects.get(token=token)
|
||||
if node.hostname != hostname or node.port != port:
|
||||
node.hostname = hostname
|
||||
node.port = port
|
||||
node.save()
|
||||
|
||||
except ProcessingNode.DoesNotExist:
|
||||
node = ProcessingNode(hostname=hostname, port=port, token=token)
|
||||
node.save()
|
||||
|
||||
if not user.has_perm('view_processingnode', node):
|
||||
assign_perm('view_processingnode', user, node)
|
||||
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
|
||||
class ExternalBackend(ModelBackend):
|
||||
def authenticate(self, request, username=None, password=None):
|
||||
if settings.EXTERNAL_AUTH_ENDPOINT == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
r = requests.post(settings.EXTERNAL_AUTH_ENDPOINT, {
|
||||
'username': username,
|
||||
'password': password
|
||||
}, headers={'Accept': 'application/json'})
|
||||
res = r.json()
|
||||
|
||||
return get_user_from_external_auth_response(res)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_user(self, user_id):
|
||||
if settings.EXTERNAL_AUTH_ENDPOINT == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
return User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return None
|
27
app/boot.py
27
app/boot.py
|
@ -14,7 +14,7 @@ from app.models import Preset
|
|||
from app.models import Theme
|
||||
from app.plugins import init_plugins
|
||||
from nodeodm.models import ProcessingNode
|
||||
# noinspection PyUnresolvedReferences
|
||||
# noinspection PyUnresolvedReferencesapp/boot.py#L20
|
||||
from webodm.settings import MEDIA_ROOT
|
||||
from . import signals
|
||||
import logging
|
||||
|
@ -26,7 +26,7 @@ from webodm.wsgi import booted
|
|||
def boot():
|
||||
# booted is a shared memory variable to keep track of boot status
|
||||
# as multiple gunicorn workers could trigger the boot sequence twice
|
||||
if (not settings.DEBUG and booted.value) or settings.MIGRATING: return
|
||||
if (not settings.DEBUG and booted.value) or settings.MIGRATING or settings.FLUSHING: return
|
||||
|
||||
booted.value = True
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
@ -34,7 +34,11 @@ def boot():
|
|||
logger.info("Booting WebODM {}".format(settings.VERSION))
|
||||
|
||||
if settings.DEBUG:
|
||||
logger.warning("Debug mode is ON (for development this is OK)")
|
||||
logger.warning("Debug mode is ON (for development this is OK)")
|
||||
|
||||
# Silence django's "Warning: Session data corrupted" messages
|
||||
session_logger = logging.getLogger("django.SuspiciousOperation.SuspiciousSession")
|
||||
session_logger.disabled = True
|
||||
|
||||
# Make sure our app/media/tmp folder exists
|
||||
if not os.path.exists(settings.MEDIA_TMP):
|
||||
|
@ -101,14 +105,12 @@ def add_default_presets():
|
|||
try:
|
||||
Preset.objects.update_or_create(name='Multispectral', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'radiometric-calibration', 'value': 'camera'},
|
||||
]})
|
||||
{'name': 'radiometric-calibration', 'value': 'camera'}]})
|
||||
Preset.objects.update_or_create(name='Volume Analysis', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'dsm', 'value': True},
|
||||
{'name': 'dem-resolution', 'value': '2'},
|
||||
{'name': 'pc-quality', 'value': 'high'},
|
||||
{'name': 'use-3dmesh', 'value': True},]})
|
||||
{'name': 'pc-quality', 'value': 'high'}]})
|
||||
Preset.objects.update_or_create(name='3D Model', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'mesh-octree-depth', 'value': "12"},
|
||||
|
@ -118,17 +120,13 @@ def add_default_presets():
|
|||
Preset.objects.update_or_create(name='Buildings', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'mesh-size', 'value': '300000'},
|
||||
{'name': 'pc-geometric', 'value': True},
|
||||
{'name': 'feature-quality', 'value': 'high'},
|
||||
{'name': 'pc-quality', 'value': 'high'}]})
|
||||
Preset.objects.update_or_create(name='Point of Interest', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'mesh-size', 'value': '300000'},
|
||||
{'name': 'use-3dmesh', 'value': True}]})
|
||||
Preset.objects.update_or_create(name='Forest', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'min-num-features', 'value': '18000'},
|
||||
{'name': 'use-3dmesh', 'value': True},
|
||||
{'name': 'feature-quality', 'value': 'ultra'}]})
|
||||
{'name': 'feature-quality', 'value': 'medium'}]})
|
||||
Preset.objects.update_or_create(name='DSM + DTM', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'dsm', 'value': True},
|
||||
|
@ -145,8 +143,7 @@ def add_default_presets():
|
|||
{'name': 'dsm', 'value': True},
|
||||
{'name': 'pc-quality', 'value': 'high'},
|
||||
{'name': 'dem-resolution', 'value': "2.0"},
|
||||
{'name': 'orthophoto-resolution', 'value': "2.0"},
|
||||
]})
|
||||
{'name': 'orthophoto-resolution', 'value': "2.0"}]})
|
||||
Preset.objects.update_or_create(name='Default', system=True,
|
||||
defaults={'options': [{'name': 'auto-boundary', 'value': True},
|
||||
{'name': 'dsm', 'value': True}]})
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import os
|
||||
import logging
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
class Console:
|
||||
def __init__(self, file):
|
||||
self.file = file
|
||||
self.base_dir = os.path.dirname(self.file)
|
||||
self.parent_dir = os.path.dirname(self.base_dir)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Console output: %s>" % self.file
|
||||
|
||||
def __str__(self):
|
||||
if not os.path.isfile(self.file):
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(self.file, 'r', encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except IOError:
|
||||
logger.warn("Cannot read console file: %s" % self.file)
|
||||
return ""
|
||||
|
||||
def __add__(self, other):
|
||||
self.append(other)
|
||||
return self
|
||||
|
||||
def output(self):
|
||||
return str(self)
|
||||
|
||||
def append(self, text):
|
||||
if os.path.isdir(self.parent_dir):
|
||||
try:
|
||||
# Write
|
||||
if not os.path.isdir(self.base_dir):
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
with open(self.file, "a", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
except IOError:
|
||||
logger.warn("Cannot append to console file: %s" % self.file)
|
||||
|
||||
def reset(self, text = ""):
|
||||
if os.path.isdir(self.parent_dir):
|
||||
try:
|
||||
if not os.path.isdir(self.base_dir):
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
with open(self.file, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
except IOError:
|
||||
logger.warn("Cannot reset console file: %s" % self.file)
|
|
@ -7,77 +7,3 @@ logger = logging.getLogger('app.logger')
|
|||
# Make the SETTINGS object available to all templates
|
||||
def load(request=None):
|
||||
return {'SETTINGS': Setting.objects.first()}
|
||||
|
||||
# Helper functions for libsass
|
||||
|
||||
def theme(color):
|
||||
"""Return a theme color from the currently selected theme"""
|
||||
try:
|
||||
return getattr(load()['SETTINGS'].theme, color)
|
||||
except Exception as e:
|
||||
logger.warning("Cannot load configuration from theme(): " + e.message)
|
||||
return "blue" # dah buh dih ah buh daa..
|
||||
|
||||
|
||||
def complementary(hexcolor):
|
||||
"""Returns complementary RGB color
|
||||
Example: complementaryColor('#FFFFFF') --> '#000000'
|
||||
"""
|
||||
if hexcolor[0] == '#':
|
||||
hexcolor = hexcolor[1:]
|
||||
rgb = (hexcolor[0:2], hexcolor[2:4], hexcolor[4:6])
|
||||
comp = ['%02X' % (255 - int(a, 16)) for a in rgb]
|
||||
return '#' + ''.join(comp)
|
||||
|
||||
|
||||
def scaleby(hexcolor, scalefactor, ignore_value = False):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``, but is color dependent, unless ignore_value is True
|
||||
scalefactor is now always between 0 and 1. A value of 0.8
|
||||
will cause bright colors to become darker and
|
||||
dark colors to become brigther by 20%
|
||||
"""
|
||||
|
||||
def calculate(hexcolor, scalefactor):
|
||||
"""
|
||||
Scales a hex string by ``scalefactor``. Returns scaled hex string.
|
||||
To darken the color, use a float value between 0 and 1.
|
||||
To brighten the color, use a float value greater than 1.
|
||||
|
||||
>>> colorscale("#DF3C3C", .5)
|
||||
#6F1E1E
|
||||
>>> colorscale("#52D24F", 1.6)
|
||||
#83FF7E
|
||||
>>> colorscale("#4F75D2", 1)
|
||||
#4F75D2
|
||||
"""
|
||||
|
||||
def clamp(val, minimum=0, maximum=255):
|
||||
if val < minimum:
|
||||
return minimum
|
||||
if val > maximum:
|
||||
return maximum
|
||||
return int(val)
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
|
||||
if scalefactor < 0 or len(hexcolor) != 6:
|
||||
return hexcolor
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
|
||||
r = clamp(r * scalefactor)
|
||||
g = clamp(g * scalefactor)
|
||||
b = clamp(b * scalefactor)
|
||||
|
||||
return "#%02x%02x%02x" % (r, g, b)
|
||||
|
||||
|
||||
hexcolor = hexcolor.strip('#')
|
||||
scalefactor = abs(float(scalefactor.value))
|
||||
scalefactor = min(1.0, max(0, scalefactor))
|
||||
|
||||
r, g, b = int(hexcolor[:2], 16), int(hexcolor[2:4], 16), int(hexcolor[4:], 16)
|
||||
value = max(r, g, b)
|
||||
|
||||
return calculate(hexcolor, scalefactor if ignore_value or value >= 127 else 2 - scalefactor)
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management import call_command
|
||||
from app.models import Project
|
||||
from webodm import settings
|
||||
|
||||
class Command(BaseCommand):
|
||||
requires_system_checks = []
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("action", type=str, choices=['mediapattern'])
|
||||
parser.add_argument("--skip-images", action='store_true', required=False, help="Skip images")
|
||||
parser.add_argument("--skip-no-quotas", action='store_true', required=False, help="Skip directories owned by users with no quota (0)")
|
||||
parser.add_argument("--skip-tiles", action='store_true', required=False, help="Skip tiled assets which can be regenerated from other data")
|
||||
parser.add_argument("--skip-legacy-textured-models", action='store_true', required=False, help="Skip textured models in OBJ format")
|
||||
|
||||
super(Command, self).add_arguments(parser)
|
||||
|
||||
def handle(self, **options):
|
||||
if options.get('action') == 'mediapattern':
|
||||
print("# BorgBackup pattern file for media directory")
|
||||
print("# Generated with WebODM")
|
||||
print("")
|
||||
|
||||
print("# Skip anything but project folder")
|
||||
for d in os.listdir(settings.MEDIA_ROOT):
|
||||
if d != "project":
|
||||
print(f"! {d}")
|
||||
|
||||
if options.get('skip_no_quotas'):
|
||||
skip_projects = Project.objects.filter(owner__profile__quota=0).order_by('id')
|
||||
else:
|
||||
skip_projects = []
|
||||
|
||||
print("")
|
||||
print("# Skip projects")
|
||||
for sp in skip_projects:
|
||||
print("- " + os.path.join("project", str(sp.id)))
|
||||
|
||||
if options.get('skip_images'):
|
||||
print("")
|
||||
print("# Skip images/other files")
|
||||
print("- project/*/task/*/*.*")
|
||||
|
||||
if options.get('skip_tiles'):
|
||||
print("")
|
||||
print("# Skip entwine/potree folders")
|
||||
print("! project/*/task/*/assets/entwine_pointcloud")
|
||||
print("! project/*/task/*/assets/potree_pointcloud")
|
||||
print("")
|
||||
print("# Skip tiles folders")
|
||||
print("! project/*/task/*/assets/*_tiles")
|
||||
|
||||
print("# Skip data")
|
||||
print("! project/*/task/*/data")
|
||||
|
||||
if options.get('skip_legacy_textured_models'):
|
||||
print("")
|
||||
print("# Skip OBJ texture model files")
|
||||
print("+ project/*/task/*/assets/odm_texturing/*.glb")
|
||||
print("- project/*/task/*/assets/odm_texturing")
|
||||
|
||||
|
|
@ -8,17 +8,14 @@ import uuid, os, pickle, tempfile
|
|||
from webodm import settings
|
||||
|
||||
tasks = []
|
||||
imageuploads = []
|
||||
task_ids = {} # map old task IDs --> new task IDs
|
||||
|
||||
def dump(apps, schema_editor):
|
||||
global tasks, imageuploads, task_ids
|
||||
global tasks, task_ids
|
||||
|
||||
Task = apps.get_model('app', 'Task')
|
||||
ImageUpload = apps.get_model('app', 'ImageUpload')
|
||||
|
||||
tasks = list(Task.objects.all().values('id', 'project'))
|
||||
imageuploads = list(ImageUpload.objects.all().values('id', 'task'))
|
||||
|
||||
# Generate UUIDs
|
||||
for task in tasks:
|
||||
|
@ -31,9 +28,9 @@ def dump(apps, schema_editor):
|
|||
task_ids[task['id']] = new_id
|
||||
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), "public_task_uuids_migration.pickle")
|
||||
pickle.dump((tasks, imageuploads, task_ids), open(tmp_path, 'wb'))
|
||||
pickle.dump((tasks, task_ids), open(tmp_path, 'wb'))
|
||||
|
||||
if len(tasks) > 0: print("Dumped tasks and imageuploads")
|
||||
if len(tasks) > 0: print("Dumped tasks")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
|
@ -8,7 +8,6 @@ import uuid, os, pickle, tempfile
|
|||
from webodm import settings
|
||||
|
||||
tasks = []
|
||||
imageuploads = []
|
||||
task_ids = {} # map old task IDs --> new task IDs
|
||||
|
||||
def task_path(project_id, task_id):
|
||||
|
@ -44,10 +43,10 @@ def create_uuids(apps, schema_editor):
|
|||
|
||||
|
||||
def restore(apps, schema_editor):
|
||||
global tasks, imageuploads, task_ids
|
||||
global tasks, task_ids
|
||||
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), "public_task_uuids_migration.pickle")
|
||||
tasks, imageuploads, task_ids = pickle.load(open(tmp_path, 'rb'))
|
||||
tasks, task_ids = pickle.load(open(tmp_path, 'rb'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-11-30 15:41
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import os, pickle, tempfile
|
||||
|
||||
from webodm import settings
|
||||
|
||||
tasks = []
|
||||
imageuploads = []
|
||||
task_ids = {} # map old task IDs --> new task IDs
|
||||
|
||||
|
||||
def restoreImageUploadFks(apps, schema_editor):
|
||||
global imageuploads, task_ids
|
||||
|
||||
ImageUpload = apps.get_model('app', 'ImageUpload')
|
||||
Task = apps.get_model('app', 'Task')
|
||||
|
||||
for img in imageuploads:
|
||||
i = ImageUpload.objects.get(pk=img['id'])
|
||||
old_image_path = i.image.name
|
||||
task_id = task_ids[img['task']]
|
||||
|
||||
# project/2/task/5/DJI_0032.JPG --> project/2/task/<NEW_TASK_ID>/DJI_0032.JPG
|
||||
dirs, filename = os.path.split(old_image_path)
|
||||
head, tail = os.path.split(dirs)
|
||||
new_image_path = os.path.join(head, str(task_id), filename)
|
||||
|
||||
i.task = Task.objects.get(id=task_id)
|
||||
i.image.name = new_image_path
|
||||
i.save()
|
||||
|
||||
print("{} --> {} (Task {})".format(old_image_path, new_image_path, str(task_id)))
|
||||
|
||||
|
||||
def restore(apps, schema_editor):
|
||||
global tasks, imageuploads, task_ids
|
||||
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), "public_task_uuids_migration.pickle")
|
||||
tasks, imageuploads, task_ids = pickle.load(open(tmp_path, 'rb'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0014_public_task_uuids'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(restore),
|
||||
migrations.RunPython(restoreImageUploadFks),
|
||||
]
|
|
@ -9,7 +9,7 @@ from webodm import settings
|
|||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0015_public_task_uuids'),
|
||||
('app', '0014_public_task_uuids'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
|
|
@ -10,7 +10,7 @@ def update_images_count(apps, schema_editor):
|
|||
|
||||
for t in Task.objects.all():
|
||||
print("Updating {}".format(t))
|
||||
t.images_count = t.imageupload_set.count()
|
||||
t.images_count = len(t.scan_images())
|
||||
t.save()
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Generated by Django 2.1.11 on 2019-09-07 13:48
|
||||
|
||||
import app.models.image_upload
|
||||
import app.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
|
@ -14,6 +14,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AlterField(
|
||||
model_name='imageupload',
|
||||
name='image',
|
||||
field=models.ImageField(help_text='File uploaded by a user', max_length=512, upload_to=app.models.image_upload.image_directory_path),
|
||||
field=models.ImageField(help_text='File uploaded by a user', max_length=512, upload_to=app.models.image_directory_path),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Generated by Django 2.1.15 on 2021-06-10 18:50
|
||||
|
||||
import app.models.image_upload
|
||||
import app.models.task
|
||||
from app.models import image_directory_path
|
||||
import colorfield.fields
|
||||
from django.conf import settings
|
||||
import django.contrib.gis.db.models.fields
|
||||
|
@ -60,7 +60,7 @@ class Migration(migrations.Migration):
|
|||
migrations.AlterField(
|
||||
model_name='imageupload',
|
||||
name='image',
|
||||
field=models.ImageField(help_text='File uploaded by a user', max_length=512, upload_to=app.models.image_upload.image_directory_path, verbose_name='Image'),
|
||||
field=models.ImageField(help_text='File uploaded by a user', max_length=512, upload_to=image_directory_path, verbose_name='Image'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='imageupload',
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 2.2.27 on 2023-03-07 15:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0032_task_epsg'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='tags',
|
||||
field=models.TextField(blank=True, db_index=True, default='', help_text='Project tags', verbose_name='Tags'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='tags',
|
||||
field=models.TextField(blank=True, db_index=True, default='', help_text='Task tags', verbose_name='Tags'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 2.2.27 on 2023-03-23 17:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0033_auto_20230307_1532'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='ImageUpload',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,44 @@
|
|||
# Generated by Django 2.2.27 on 2023-05-19 15:38
|
||||
|
||||
import rasterio
|
||||
import os
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
from webodm import settings
|
||||
|
||||
def update_orthophoto_bands_fields(apps, schema_editor):
|
||||
Task = apps.get_model('app', 'Task')
|
||||
|
||||
for t in Task.objects.all():
|
||||
|
||||
bands = []
|
||||
orthophoto_path = os.path.join(settings.MEDIA_ROOT, "project", str(t.project.id), "task", str(t.id), "assets", "odm_orthophoto", "odm_orthophoto.tif")
|
||||
|
||||
if os.path.isfile(orthophoto_path):
|
||||
try:
|
||||
with rasterio.open(orthophoto_path) as f:
|
||||
bands = [c.name for c in f.colorinterp]
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
print("Updating {} (with orthophoto bands: {})".format(t, str(bands)))
|
||||
|
||||
t.orthophoto_bands = bands
|
||||
t.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0034_delete_imageupload'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='orthophoto_bands',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, help_text='List of orthophoto bands', verbose_name='Orthophoto Bands'),
|
||||
),
|
||||
|
||||
migrations.RunPython(update_orthophoto_bands_fields),
|
||||
]
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 2.2.27 on 2023-08-21 14:50
|
||||
import os
|
||||
from django.db import migrations, models
|
||||
from webodm import settings
|
||||
|
||||
def task_path(project_id, task_id, *args):
|
||||
return os.path.join(settings.MEDIA_ROOT,
|
||||
"project",
|
||||
str(project_id),
|
||||
"task",
|
||||
str(task_id),
|
||||
*args)
|
||||
|
||||
def update_size(task):
|
||||
try:
|
||||
total_bytes = 0
|
||||
for dirpath, _, filenames in os.walk(task_path(task.project.id, task.id)):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not os.path.islink(fp):
|
||||
total_bytes += os.path.getsize(fp)
|
||||
task.size = (total_bytes / 1024 / 1024)
|
||||
task.save()
|
||||
print("Updated {} with size {}".format(task, task.size))
|
||||
except Exception as e:
|
||||
print("Cannot update size for task {}: {}".format(task, str(e)))
|
||||
|
||||
|
||||
|
||||
def update_task_sizes(apps, schema_editor):
|
||||
Task = apps.get_model('app', 'Task')
|
||||
|
||||
for t in Task.objects.all():
|
||||
update_size(t)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0035_task_orthophoto_bands'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='size',
|
||||
field=models.FloatField(blank=True, default=0.0, help_text='Size of the task on disk in megabytes', verbose_name='Size'),
|
||||
),
|
||||
|
||||
migrations.RunPython(update_task_sizes),
|
||||
]
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 2.2.27 on 2023-08-24 16:35
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def create_profiles(apps, schema_editor):
|
||||
User = apps.get_model('auth', 'User')
|
||||
Profile = apps.get_model('app', 'Profile')
|
||||
|
||||
for u in User.objects.all():
|
||||
p = Profile.objects.create(user=u)
|
||||
p.save()
|
||||
print("Created user profile for %s" % u.username)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('app', '0036_task_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quota', models.FloatField(blank=True, default=-1, help_text='Maximum disk quota in megabytes', verbose_name='Quota')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
|
||||
migrations.RunPython(create_profiles),
|
||||
]
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 2.2.27 on 2023-09-11 19:11
|
||||
import os
|
||||
from django.db import migrations
|
||||
from webodm import settings
|
||||
|
||||
def data_path(project_id, task_id, *args):
|
||||
return os.path.join(settings.MEDIA_ROOT,
|
||||
"project",
|
||||
str(project_id),
|
||||
"task",
|
||||
str(task_id),
|
||||
"data",
|
||||
*args)
|
||||
|
||||
def dump_console_outputs(apps, schema_editor):
|
||||
Task = apps.get_model('app', 'Task')
|
||||
|
||||
for t in Task.objects.all():
|
||||
if t.console_output is not None and len(t.console_output) > 0:
|
||||
dp = data_path(t.project.id, t.id)
|
||||
os.makedirs(dp, exist_ok=True)
|
||||
outfile = os.path.join(dp, "console_output.txt")
|
||||
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(t.console_output)
|
||||
print("Wrote console output for %s to %s" % (t, outfile))
|
||||
else:
|
||||
print("No task output for %s" % t)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0037_profile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(dump_console_outputs),
|
||||
migrations.RemoveField(
|
||||
model_name='task',
|
||||
name='console_output',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 2.2.27 on 2023-10-02 10:21
|
||||
|
||||
import rasterio
|
||||
import os
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
from webodm import settings
|
||||
|
||||
def update_orthophoto_bands_fields(apps, schema_editor):
|
||||
Task = apps.get_model('app', 'Task')
|
||||
|
||||
for t in Task.objects.all():
|
||||
|
||||
bands = []
|
||||
orthophoto_path = os.path.join(settings.MEDIA_ROOT, "project", str(t.project.id), "task", str(t.id), "assets", "odm_orthophoto", "odm_orthophoto.tif")
|
||||
|
||||
if os.path.isfile(orthophoto_path):
|
||||
try:
|
||||
with rasterio.open(orthophoto_path) as f:
|
||||
names = [c.name for c in f.colorinterp]
|
||||
for i, n in enumerate(names):
|
||||
bands.append({
|
||||
'name': n,
|
||||
'description': f.descriptions[i]
|
||||
})
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
print("Updating {} (with orthophoto bands: {})".format(t, str(bands)))
|
||||
|
||||
t.orthophoto_bands = bands
|
||||
t.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0038_remove_task_console_output'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_orthophoto_bands_fields),
|
||||
]
|
|
@ -1,4 +1,3 @@
|
|||
from .image_upload import ImageUpload, image_directory_path
|
||||
from .project import Project
|
||||
from .task import Task, validate_task_options, gcp_directory_path
|
||||
from .preset import Preset
|
||||
|
@ -6,4 +5,8 @@ from .theme import Theme
|
|||
from .setting import Setting
|
||||
from .plugin_datum import PluginDatum
|
||||
from .plugin import Plugin
|
||||
from .profile import Profile
|
||||
|
||||
# deprecated
|
||||
def image_directory_path(image_upload, filename):
|
||||
raise Exception("Deprecated")
|
|
@ -1,21 +0,0 @@
|
|||
from .task import Task, assets_directory_path
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
def image_directory_path(image_upload, filename):
|
||||
return assets_directory_path(image_upload.task.id, image_upload.task.project.id, filename)
|
||||
|
||||
|
||||
class ImageUpload(models.Model):
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, help_text=_("Task this image belongs to"), verbose_name=_("Task"))
|
||||
image = models.ImageField(upload_to=image_directory_path, help_text=_("File uploaded by a user"), max_length=512, verbose_name=_("Image"))
|
||||
|
||||
def __str__(self):
|
||||
return self.image.name
|
||||
|
||||
def path(self):
|
||||
return self.image.path
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Image Upload")
|
||||
verbose_name_plural = _("Image Uploads")
|
|
@ -0,0 +1,74 @@
|
|||
import time
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from app.models import Task
|
||||
from django.db.models import Sum
|
||||
from django.core.cache import cache
|
||||
from webodm import settings
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
quota = models.FloatField(default=-1, blank=True, help_text=_("Maximum disk quota in megabytes"), verbose_name=_("Quota"))
|
||||
|
||||
def has_quota(self):
|
||||
return self.quota != -1
|
||||
|
||||
def used_quota(self):
|
||||
q = Task.objects.filter(project__owner=self.user).aggregate(total=Sum('size'))['total']
|
||||
if q is None:
|
||||
q = 0
|
||||
return q
|
||||
|
||||
def has_exceeded_quota(self):
|
||||
if not self.has_quota():
|
||||
return False
|
||||
|
||||
q = self.used_quota()
|
||||
return q > self.quota
|
||||
|
||||
def used_quota_cached(self):
|
||||
k = f'used_quota_{self.user.id}'
|
||||
cached = cache.get(k)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
v = self.used_quota()
|
||||
cache.set(k, v, 1800) # 30 minutes
|
||||
return v
|
||||
|
||||
def has_exceeded_quota_cached(self):
|
||||
if not self.has_quota():
|
||||
return False
|
||||
|
||||
q = self.used_quota_cached()
|
||||
return q > self.quota
|
||||
|
||||
def clear_used_quota_cache(self):
|
||||
cache.delete(f'used_quota_{self.user.id}')
|
||||
|
||||
def get_quota_deadline(self):
|
||||
return cache.get(f'quota_deadline_{self.user.id}')
|
||||
|
||||
def set_quota_deadline(self, hours):
|
||||
k = f'quota_deadline_{self.user.id}'
|
||||
seconds = (hours * 60 * 60)
|
||||
v = time.time() + seconds
|
||||
cache.set(k, v, int(max(seconds * 10, settings.QUOTA_EXCEEDED_GRACE_PERIOD * 60 * 60)))
|
||||
return v
|
||||
|
||||
def clear_quota_deadline(self):
|
||||
cache.delete(f'quota_deadline_{self.user.id}')
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
Profile.objects.create(user=instance)
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
|
@ -25,7 +25,8 @@ class Project(models.Model):
|
|||
description = models.TextField(default="", blank=True, help_text=_("More in-depth description of the project"), verbose_name=_("Description"))
|
||||
created_at = models.DateTimeField(default=timezone.now, help_text=_("Creation date"), verbose_name=_("Created at"))
|
||||
deleting = models.BooleanField(db_index=True, default=False, help_text=_("Whether this project has been marked for deletion. Projects that have running tasks need to wait for tasks to be properly cleaned up before they can be deleted."), verbose_name=_("Deleting"))
|
||||
|
||||
tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Project tags"), verbose_name=_("Tags"))
|
||||
|
||||
def delete(self, *args):
|
||||
# No tasks?
|
||||
if self.task_set.count() == 0:
|
||||
|
@ -47,19 +48,24 @@ class Project(models.Model):
|
|||
def tasks(self):
|
||||
return self.task_set.only('id')
|
||||
|
||||
def tasks_count(self):
|
||||
return self.task_set.count()
|
||||
|
||||
def get_map_items(self):
|
||||
return [task.get_map_items() for task in self.task_set.filter(
|
||||
status=status_codes.COMPLETED
|
||||
).filter(Q(orthophoto_extent__isnull=False) | Q(dsm_extent__isnull=False) | Q(dtm_extent__isnull=False))
|
||||
.only('id', 'project_id')]
|
||||
|
||||
def duplicate(self):
|
||||
def duplicate(self, new_owner=None):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
project = Project.objects.get(pk=self.pk)
|
||||
project.pk = None
|
||||
project.name = gettext('Copy of %(task)s') % {'task': self.name}
|
||||
project.created_at = timezone.now()
|
||||
if new_owner is not None:
|
||||
project.owner = new_owner
|
||||
project.save()
|
||||
project.refresh_from_db()
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import logging
|
|||
import os
|
||||
import shutil
|
||||
import time
|
||||
import struct
|
||||
import uuid as uuid_module
|
||||
from app.vendor import zipfly
|
||||
|
||||
|
@ -21,6 +22,7 @@ from django.contrib.gis.gdal import GDALRaster
|
|||
from django.contrib.gis.gdal import OGRGeometry
|
||||
from django.contrib.gis.geos import GEOSGeometry
|
||||
from django.contrib.postgres import fields
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.core.exceptions import ValidationError, SuspiciousFileOperation
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
|
@ -45,6 +47,7 @@ from django.utils.translation import gettext_lazy as _, gettext
|
|||
|
||||
from functools import partial
|
||||
import subprocess
|
||||
from app.classes.console import Console
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
|
@ -135,15 +138,15 @@ def resize_image(image_path, resize_to, done=None):
|
|||
resized_width = int(width * ratio)
|
||||
resized_height = int(height * ratio)
|
||||
|
||||
im = im.resize((resized_width, resized_height), Image.BILINEAR)
|
||||
im = im.resize((resized_width, resized_height), Image.LANCZOS)
|
||||
params = {}
|
||||
if is_jpeg:
|
||||
params['quality'] = 100
|
||||
|
||||
if 'exif' in im.info:
|
||||
exif_dict = piexif.load(im.info['exif'])
|
||||
exif_dict['Exif'][piexif.ExifIFD.PixelXDimension] = resized_width
|
||||
exif_dict['Exif'][piexif.ExifIFD.PixelYDimension] = resized_height
|
||||
#exif_dict['Exif'][piexif.ExifIFD.PixelXDimension] = resized_width
|
||||
#exif_dict['Exif'][piexif.ExifIFD.PixelYDimension] = resized_height
|
||||
im.save(resized_image_path, exif=piexif.dump(exif_dict), **params)
|
||||
else:
|
||||
im.save(resized_image_path, **params)
|
||||
|
@ -155,7 +158,7 @@ def resize_image(image_path, resize_to, done=None):
|
|||
os.rename(resized_image_path, image_path)
|
||||
|
||||
logger.info("Resized {} to {}x{}".format(image_path, resized_width, resized_height))
|
||||
except (IOError, ValueError) as e:
|
||||
except (IOError, ValueError, struct.error) as e:
|
||||
logger.warning("Cannot resize {}: {}.".format(image_path, str(e)))
|
||||
if done is not None:
|
||||
done()
|
||||
|
@ -184,8 +187,10 @@ class Task(models.Model):
|
|||
'georeferenced_model.csv': os.path.join('odm_georeferencing', 'odm_georeferenced_model.csv'),
|
||||
'textured_model.zip': {
|
||||
'deferred_path': 'textured_model.zip',
|
||||
'deferred_compress_dir': 'odm_texturing'
|
||||
'deferred_compress_dir': 'odm_texturing',
|
||||
'deferred_exclude_files': ('odm_textured_model_geo.glb', )
|
||||
},
|
||||
'textured_model.glb': os.path.join('odm_texturing', 'odm_textured_model_geo.glb'),
|
||||
'3d_tiles_model.zip': {
|
||||
'deferred_path': '3d_tiles_model.zip',
|
||||
'deferred_compress_dir': os.path.join('3d_tiles', 'model')
|
||||
|
@ -244,7 +249,6 @@ class Task(models.Model):
|
|||
last_error = models.TextField(null=True, blank=True, help_text=_("The last processing error received"), verbose_name=_("Last Error"))
|
||||
options = fields.JSONField(default=dict, blank=True, help_text=_("Options that are being used to process this task"), validators=[validate_task_options], verbose_name=_("Options"))
|
||||
available_assets = fields.ArrayField(models.CharField(max_length=80), default=list, blank=True, help_text=_("List of available assets to download"), verbose_name=_("Available Assets"))
|
||||
console_output = models.TextField(null=False, default="", blank=True, help_text=_("Console output of the processing node"), verbose_name=_("Console Output"))
|
||||
|
||||
orthophoto_extent = GeometryField(null=True, blank=True, srid=4326, help_text=_("Extent of the orthophoto"), verbose_name=_("Orthophoto Extent"))
|
||||
dsm_extent = GeometryField(null=True, blank=True, srid=4326, help_text="Extent of the DSM", verbose_name=_("DSM Extent"))
|
||||
|
@ -274,7 +278,10 @@ class Task(models.Model):
|
|||
partial = models.BooleanField(default=False, help_text=_("A flag indicating whether this task is currently waiting for information or files to be uploaded before being considered for processing."), verbose_name=_("Partial"))
|
||||
potree_scene = fields.JSONField(default=dict, blank=True, help_text=_("Serialized potree scene information used to save/load measurements and camera view angle"), verbose_name=_("Potree Scene"))
|
||||
epsg = models.IntegerField(null=True, default=None, blank=True, help_text=_("EPSG code of the dataset (if georeferenced)"), verbose_name="EPSG")
|
||||
|
||||
tags = models.TextField(db_index=True, default="", blank=True, help_text=_("Task tags"), verbose_name=_("Tags"))
|
||||
orthophoto_bands = fields.JSONField(default=list, blank=True, help_text=_("List of orthophoto bands"), verbose_name=_("Orthophoto Bands"))
|
||||
size = models.FloatField(default=0.0, blank=True, help_text=_("Size of the task on disk in megabytes"), verbose_name=_("Size"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Task")
|
||||
verbose_name_plural = _("Tasks")
|
||||
|
@ -284,6 +291,8 @@ class Task(models.Model):
|
|||
|
||||
# To help keep track of changes to the project id
|
||||
self.__original_project_id = self.project.id
|
||||
|
||||
self.console = Console(self.data_path("console_output.txt"))
|
||||
|
||||
def __str__(self):
|
||||
name = self.name if self.name is not None else gettext("unnamed")
|
||||
|
@ -307,15 +316,6 @@ class Task(models.Model):
|
|||
shutil.move(old_task_folder, new_task_folder_parent)
|
||||
|
||||
logger.info("Moved task folder from {} to {}".format(old_task_folder, new_task_folder))
|
||||
|
||||
with transaction.atomic():
|
||||
for img in self.imageupload_set.all():
|
||||
prev_name = img.image.name
|
||||
img.image.name = assets_directory_path(self.id, new_project_id,
|
||||
os.path.basename(img.image.name))
|
||||
logger.info("Changing {} to {}".format(prev_name, img))
|
||||
img.save()
|
||||
|
||||
else:
|
||||
logger.warning("Project changed for task {}, but either {} doesn't exist, or {} already exists. This doesn't look right, so we will not move any files.".format(self,
|
||||
old_task_folder,
|
||||
|
@ -357,6 +357,12 @@ class Task(models.Model):
|
|||
"""
|
||||
return self.task_path("assets", *args)
|
||||
|
||||
def data_path(self, *args):
|
||||
"""
|
||||
Path to task data that does not fit in database fields (e.g. console output)
|
||||
"""
|
||||
return self.task_path("data", *args)
|
||||
|
||||
def task_path(self, *args):
|
||||
"""
|
||||
Get path relative to the root task directory
|
||||
|
@ -407,7 +413,9 @@ class Task(models.Model):
|
|||
'points': points,
|
||||
},
|
||||
'gsd': j.get('odm_processing_statistics', {}).get('average_gsd'),
|
||||
'area': j.get('processing_statistics', {}).get('area')
|
||||
'area': j.get('processing_statistics', {}).get('area'),
|
||||
'start_date': j.get('processing_statistics', {}).get('start_date'),
|
||||
'end_date': j.get('processing_statistics', {}).get('end_date'),
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
@ -425,16 +433,6 @@ class Task(models.Model):
|
|||
|
||||
logger.info("Duplicating {} to {}".format(self, task))
|
||||
|
||||
for img in self.imageupload_set.all():
|
||||
img.pk = None
|
||||
img.task = task
|
||||
|
||||
prev_name = img.image.name
|
||||
img.image.name = assets_directory_path(task.id, task.project.id,
|
||||
os.path.basename(img.image.name))
|
||||
|
||||
img.save()
|
||||
|
||||
if os.path.isdir(self.task_path()):
|
||||
try:
|
||||
# Try to use hard links first
|
||||
|
@ -444,30 +442,34 @@ class Task(models.Model):
|
|||
shutil.copytree(self.task_path(), task.task_path())
|
||||
else:
|
||||
logger.warning("Task {} doesn't have folder, will skip copying".format(self))
|
||||
|
||||
self.project.owner.profile.clear_used_quota_cache()
|
||||
return task
|
||||
except Exception as e:
|
||||
logger.warning("Cannot duplicate task: {}".format(str(e)))
|
||||
|
||||
return False
|
||||
|
||||
def get_asset_file_or_zipstream(self, asset):
|
||||
def get_asset_file_or_stream(self, asset):
|
||||
"""
|
||||
Get a stream to an asset
|
||||
:param asset: one of ASSETS_MAP keys
|
||||
:return: (path|stream, is_zipstream:bool)
|
||||
:return: (path|stream)
|
||||
"""
|
||||
if asset in self.ASSETS_MAP:
|
||||
value = self.ASSETS_MAP[asset]
|
||||
if isinstance(value, str):
|
||||
return self.assets_path(value), False
|
||||
return self.assets_path(value)
|
||||
|
||||
elif isinstance(value, dict):
|
||||
if 'deferred_path' in value and 'deferred_compress_dir' in value:
|
||||
zip_dir = self.assets_path(value['deferred_compress_dir'])
|
||||
paths = [{'n': os.path.relpath(os.path.join(dp, f), zip_dir), 'fs': os.path.join(dp, f)} for dp, dn, filenames in os.walk(zip_dir) for f in filenames]
|
||||
if 'deferred_exclude_files' in value and isinstance(value['deferred_exclude_files'], tuple):
|
||||
paths = [p for p in paths if os.path.basename(p['fs']) not in value['deferred_exclude_files']]
|
||||
if len(paths) == 0:
|
||||
raise FileNotFoundError("No files available for download")
|
||||
return zipfly.ZipStream(paths), True
|
||||
return zipfly.ZipStream(paths)
|
||||
else:
|
||||
raise FileNotFoundError("{} is not a valid asset (invalid dict values)".format(asset))
|
||||
else:
|
||||
|
@ -497,7 +499,7 @@ class Task(models.Model):
|
|||
raise FileNotFoundError("{} is not a valid asset".format(asset))
|
||||
|
||||
def handle_import(self):
|
||||
self.console_output += gettext("Importing assets...") + "\n"
|
||||
self.console += gettext("Importing assets...") + "\n"
|
||||
self.save()
|
||||
|
||||
zip_path = self.assets_path("all.zip")
|
||||
|
@ -622,7 +624,8 @@ class Task(models.Model):
|
|||
if not self.uuid and self.pending_action is None and self.status is None:
|
||||
logger.info("Processing... {}".format(self))
|
||||
|
||||
images = [image.path() for image in self.imageupload_set.all()]
|
||||
images_path = self.task_path()
|
||||
images = [os.path.join(images_path, i) for i in self.scan_images()]
|
||||
|
||||
# Track upload progress, but limit the number of DB updates
|
||||
# to every 2 seconds (and always record the 100% progress)
|
||||
|
@ -715,7 +718,7 @@ class Task(models.Model):
|
|||
self.options = list(filter(lambda d: d['name'] != 'rerun-from', self.options))
|
||||
self.upload_progress = 0
|
||||
|
||||
self.console_output = ""
|
||||
self.console.reset()
|
||||
self.processing_time = -1
|
||||
self.status = None
|
||||
self.last_error = None
|
||||
|
@ -746,10 +749,10 @@ class Task(models.Model):
|
|||
# Need to update status (first time, queued or running?)
|
||||
if self.uuid and self.status in [None, status_codes.QUEUED, status_codes.RUNNING]:
|
||||
# Update task info from processing node
|
||||
if not self.console_output:
|
||||
if not self.console.output():
|
||||
current_lines_count = 0
|
||||
else:
|
||||
current_lines_count = len(self.console_output.split("\n"))
|
||||
current_lines_count = len(self.console.output().split("\n"))
|
||||
|
||||
info = self.processing_node.get_task_info(self.uuid, current_lines_count)
|
||||
|
||||
|
@ -757,7 +760,7 @@ class Task(models.Model):
|
|||
self.status = info.status.value
|
||||
|
||||
if len(info.output) > 0:
|
||||
self.console_output += "\n".join(info.output) + '\n'
|
||||
self.console += "\n".join(info.output) + '\n'
|
||||
|
||||
# Update running progress
|
||||
self.running_progress = (info.progress / 100.0) * self.TASK_PROGRESS_LAST_VALUE
|
||||
|
@ -821,6 +824,11 @@ class Task(models.Model):
|
|||
else:
|
||||
# FAILED, CANCELED
|
||||
self.save()
|
||||
|
||||
if self.status == status_codes.FAILED:
|
||||
from app.plugins import signals as plugin_signals
|
||||
plugin_signals.task_failed.send_robust(sender=self.__class__, task_id=self.id)
|
||||
|
||||
else:
|
||||
# Still waiting...
|
||||
self.save()
|
||||
|
@ -888,9 +896,11 @@ class Task(models.Model):
|
|||
|
||||
self.update_available_assets_field()
|
||||
self.update_epsg_field()
|
||||
self.update_orthophoto_bands_field()
|
||||
self.update_size()
|
||||
self.potree_scene = {}
|
||||
self.running_progress = 1.0
|
||||
self.console_output += gettext("Done!") + "\n"
|
||||
self.console += gettext("Done!") + "\n"
|
||||
self.status = status_codes.COMPLETED
|
||||
self.save()
|
||||
|
||||
|
@ -909,8 +919,9 @@ class Task(models.Model):
|
|||
|
||||
def get_map_items(self):
|
||||
types = []
|
||||
if 'orthophoto.tif' in self.available_assets: types.append('orthophoto')
|
||||
if 'orthophoto.tif' in self.available_assets: types.append('plant')
|
||||
if 'orthophoto.tif' in self.available_assets:
|
||||
types.append('orthophoto')
|
||||
types.append('plant')
|
||||
if 'dsm.tif' in self.available_assets: types.append('dsm')
|
||||
if 'dtm.tif' in self.available_assets: types.append('dtm')
|
||||
|
||||
|
@ -929,7 +940,8 @@ class Task(models.Model):
|
|||
'public': self.public,
|
||||
'camera_shots': camera_shots,
|
||||
'ground_control_points': ground_control_points,
|
||||
'epsg': self.epsg
|
||||
'epsg': self.epsg,
|
||||
'orthophoto_bands': self.orthophoto_bands,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1003,6 +1015,27 @@ class Task(models.Model):
|
|||
if commit: self.save()
|
||||
|
||||
|
||||
def update_orthophoto_bands_field(self, commit=False):
|
||||
"""
|
||||
Updates the orthophoto bands field with the correct value
|
||||
:param commit: when True also saves the model, otherwise the user should manually call save()
|
||||
"""
|
||||
bands = []
|
||||
orthophoto_path = self.assets_path(self.ASSETS_MAP['orthophoto.tif'])
|
||||
|
||||
if os.path.isfile(orthophoto_path):
|
||||
with rasterio.open(orthophoto_path) as f:
|
||||
names = [c.name for c in f.colorinterp]
|
||||
for i, n in enumerate(names):
|
||||
bands.append({
|
||||
'name': n,
|
||||
'description': f.descriptions[i]
|
||||
})
|
||||
|
||||
self.orthophoto_bands = bands
|
||||
if commit: self.save()
|
||||
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
task_id = self.id
|
||||
from app.plugins import signals as plugin_signals
|
||||
|
@ -1019,6 +1052,8 @@ class Task(models.Model):
|
|||
except FileNotFoundError as e:
|
||||
logger.warning(e)
|
||||
|
||||
self.project.owner.profile.clear_used_quota_cache()
|
||||
|
||||
plugin_signals.task_removed.send_robust(sender=self.__class__, task_id=task_id)
|
||||
|
||||
def set_failure(self, error_message):
|
||||
|
@ -1083,8 +1118,8 @@ class Task(models.Model):
|
|||
"""
|
||||
gcp_path = self.find_all_files_matching(r'.*\.txt$')
|
||||
|
||||
# Skip geo.txt, image_groups.txt files
|
||||
gcp_path = list(filter(lambda p: os.path.basename(p).lower() not in ['geo.txt', 'image_groups.txt'], gcp_path))
|
||||
# Skip geo.txt, image_groups.txt, align.(las|laz|tif) files
|
||||
gcp_path = list(filter(lambda p: os.path.basename(p).lower() not in ['geo.txt', 'image_groups.txt', 'align.las', 'align.laz', 'align.tif'], gcp_path))
|
||||
if len(gcp_path) == 0: return None
|
||||
|
||||
# Assume we only have a single GCP file per task
|
||||
|
@ -1115,3 +1150,53 @@ class Task(models.Model):
|
|||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
def scan_images(self):
|
||||
tp = self.task_path()
|
||||
try:
|
||||
return [e.name for e in os.scandir(tp) if e.is_file()]
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_image_path(self, filename):
|
||||
p = self.task_path(filename)
|
||||
return path_traversal_check(p, self.task_path())
|
||||
|
||||
def handle_images_upload(self, files):
|
||||
uploaded = {}
|
||||
for file in files:
|
||||
name = file.name
|
||||
if name is None:
|
||||
continue
|
||||
|
||||
tp = self.task_path()
|
||||
if not os.path.exists(tp):
|
||||
os.makedirs(tp, exist_ok=True)
|
||||
|
||||
dst_path = self.get_image_path(name)
|
||||
|
||||
with open(dst_path, 'wb+') as fd:
|
||||
if isinstance(file, InMemoryUploadedFile):
|
||||
for chunk in file.chunks():
|
||||
fd.write(chunk)
|
||||
else:
|
||||
with open(file.temporary_file_path(), 'rb') as f:
|
||||
shutil.copyfileobj(f, fd)
|
||||
|
||||
uploaded[name] = os.path.getsize(dst_path)
|
||||
return uploaded
|
||||
|
||||
def update_size(self, commit=False):
|
||||
try:
|
||||
total_bytes = 0
|
||||
for dirpath, _, filenames in os.walk(self.task_path()):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
if not os.path.islink(fp):
|
||||
total_bytes += os.path.getsize(fp)
|
||||
self.size = (total_bytes / 1024 / 1024)
|
||||
if commit: self.save()
|
||||
|
||||
self.project.owner.profile.clear_used_quota_cache()
|
||||
except Exception as e:
|
||||
logger.warn("Cannot update size for task {}: {}".format(self, str(e)))
|
||||
|
|
|
@ -7,6 +7,8 @@ from django.db import models
|
|||
from colorfield.fields import ColorField
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.cache import cache
|
||||
from django.core.cache.utils import make_template_fragment_key
|
||||
|
||||
from webodm import settings
|
||||
|
||||
|
@ -54,14 +56,5 @@ def theme_post_save(sender, instance, created, **kwargs):
|
|||
|
||||
|
||||
def update_theme_css():
|
||||
"""
|
||||
Touch theme.scss to invalidate its cache and force
|
||||
compressor to regenerate it
|
||||
"""
|
||||
|
||||
theme_file = os.path.join('app', 'static', 'app', 'css', 'theme.scss')
|
||||
try:
|
||||
Path(theme_file).touch()
|
||||
logger.info("Regenerate cache for {}".format(theme_file))
|
||||
except:
|
||||
logger.warning("Failed to touch {}".format(theme_file))
|
||||
key = make_template_fragment_key("theme_css")
|
||||
cache.delete(key)
|
|
@ -110,7 +110,7 @@ def build_plugins():
|
|||
# Create entry configuration
|
||||
entry = {}
|
||||
for e in plugin.build_jsx_components():
|
||||
entry[os.path.splitext(os.path.basename(e))[0]] = [os.path.join('.', e)]
|
||||
entry[os.path.splitext(os.path.basename(e))[0]] = ['./' + e]
|
||||
wpc_content = tmpl.substitute({
|
||||
'entry_json': json.dumps(entry)
|
||||
})
|
||||
|
@ -210,9 +210,12 @@ def get_plugins():
|
|||
module = importlib.import_module("plugins.{}".format(dir))
|
||||
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError):
|
||||
module = importlib.import_module("coreplugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError) as plugin_error:
|
||||
try:
|
||||
module = importlib.import_module("coreplugins.{}".format(dir))
|
||||
plugin = (getattr(module, "Plugin"))()
|
||||
except (ImportError, AttributeError) as coreplugin_error:
|
||||
raise coreplugin_error from plugin_error
|
||||
|
||||
# Check version
|
||||
manifest = plugin.get_manifest()
|
||||
|
@ -237,7 +240,7 @@ def get_plugins():
|
|||
|
||||
plugins.append(plugin)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to instantiate plugin {}: {}".format(dir, e))
|
||||
logger.warning("Failed to instantiate plugin {}: {}: {}".format(dir, e, e.__cause__))
|
||||
|
||||
return plugins
|
||||
|
||||
|
@ -273,7 +276,7 @@ def get_plugin_by_name(name, only_active=True, refresh_cache_if_none=False):
|
|||
else:
|
||||
return res
|
||||
|
||||
def get_current_plugin():
|
||||
def get_current_plugin(only_active=False):
|
||||
"""
|
||||
When called from a python module inside a plugin's directory,
|
||||
it returns the plugin that this python module belongs to
|
||||
|
@ -289,7 +292,7 @@ def get_current_plugin():
|
|||
parts = relp.split(os.sep)
|
||||
if len(parts) > 0:
|
||||
plugin_name = parts[0]
|
||||
return get_plugin_by_name(plugin_name, only_active=False)
|
||||
return get_plugin_by_name(plugin_name, only_active=only_active)
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
@ -1,152 +0,0 @@
|
|||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
import platform
|
||||
|
||||
from webodm import settings
|
||||
|
||||
logger = logging.getLogger('app.logger')
|
||||
|
||||
class GrassEngine:
|
||||
def __init__(self):
|
||||
self.grass_binary = shutil.which('grass7') or \
|
||||
shutil.which('grass7.bat') or \
|
||||
shutil.which('grass72') or \
|
||||
shutil.which('grass72.bat') or \
|
||||
shutil.which('grass74') or \
|
||||
shutil.which('grass74.bat') or \
|
||||
shutil.which('grass76') or \
|
||||
shutil.which('grass76.bat') or \
|
||||
shutil.which('grass78') or \
|
||||
shutil.which('grass78.bat') or \
|
||||
shutil.which('grass80') or \
|
||||
shutil.which('grass80.bat')
|
||||
|
||||
if self.grass_binary is None:
|
||||
logger.warning("Could not find a GRASS 7 executable. GRASS scripts will not work.")
|
||||
|
||||
def create_context(self, serialized_context = {}):
|
||||
if self.grass_binary is None: raise GrassEngineException("GRASS engine is unavailable")
|
||||
return GrassContext(self.grass_binary, **serialized_context)
|
||||
|
||||
|
||||
class GrassContext:
|
||||
def __init__(self, grass_binary, tmpdir = None, script_opts = {}, location = None, auto_cleanup=True, python_path=None):
|
||||
self.grass_binary = grass_binary
|
||||
if tmpdir is None:
|
||||
tmpdir = os.path.basename(tempfile.mkdtemp('_grass_engine', dir=settings.MEDIA_TMP))
|
||||
self.tmpdir = tmpdir
|
||||
self.script_opts = script_opts.copy()
|
||||
self.location = location
|
||||
self.auto_cleanup = auto_cleanup
|
||||
self.python_path = python_path
|
||||
|
||||
def get_cwd(self):
|
||||
return os.path.join(settings.MEDIA_TMP, self.tmpdir)
|
||||
|
||||
def add_file(self, filename, source, use_as_location=False):
|
||||
param = os.path.splitext(filename)[0] # filename without extension
|
||||
|
||||
dst_path = os.path.abspath(os.path.join(self.get_cwd(), filename))
|
||||
with open(dst_path, 'w') as f:
|
||||
f.write(source)
|
||||
self.script_opts[param] = dst_path
|
||||
|
||||
if use_as_location:
|
||||
self.set_location(self.script_opts[param])
|
||||
|
||||
return dst_path
|
||||
|
||||
def add_param(self, param, value):
|
||||
self.script_opts[param] = value
|
||||
|
||||
def set_location(self, location):
|
||||
"""
|
||||
:param location: either a "epsg:XXXXX" string or a path to a geospatial file defining the location
|
||||
"""
|
||||
if not location.lower().startswith('epsg:'):
|
||||
location = os.path.abspath(location)
|
||||
self.location = location
|
||||
|
||||
def execute(self, script):
|
||||
"""
|
||||
:param script: path to .grass script
|
||||
:return: script output
|
||||
"""
|
||||
if self.location is None: raise GrassEngineException("Location is not set")
|
||||
|
||||
script = os.path.abspath(script)
|
||||
|
||||
# Make sure working directory exists
|
||||
if not os.path.exists(self.get_cwd()):
|
||||
os.mkdir(self.get_cwd())
|
||||
|
||||
# Create param list
|
||||
params = ["{}={}".format(opt,value) for opt,value in self.script_opts.items()]
|
||||
|
||||
# Track success, output
|
||||
success = False
|
||||
out = ""
|
||||
err = ""
|
||||
|
||||
# Setup env
|
||||
env = os.environ.copy()
|
||||
env["LC_ALL"] = "C.UTF-8"
|
||||
|
||||
if self.python_path:
|
||||
sep = ";" if platform.system() == "Windows" else ":"
|
||||
env["PYTHONPATH"] = "%s%s%s" % (self.python_path, sep, env.get("PYTHONPATH", ""))
|
||||
|
||||
# Execute it
|
||||
logger.info("Executing grass script from {}: {} -c {} location --exec python3 {} {}".format(self.get_cwd(), self.grass_binary, self.location, script, " ".join(params)))
|
||||
|
||||
command = [self.grass_binary, '-c', self.location, 'location', '--exec', 'python3', script] + params
|
||||
if platform.system() == "Windows":
|
||||
# communicate() hangs on Windows so we use check_output instead
|
||||
try:
|
||||
out = subprocess.check_output(command, cwd=self.get_cwd(), env=env).decode('utf-8').strip()
|
||||
success = True
|
||||
except:
|
||||
success = False
|
||||
err = out
|
||||
else:
|
||||
p = subprocess.Popen(command, cwd=self.get_cwd(), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
out, err = p.communicate()
|
||||
|
||||
out = out.decode('utf-8').strip()
|
||||
err = err.decode('utf-8').strip()
|
||||
success = p.returncode == 0
|
||||
|
||||
if success:
|
||||
return out
|
||||
else:
|
||||
raise GrassEngineException("Could not execute GRASS script {} from {}: {}".format(script, self.get_cwd(), err))
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'tmpdir': self.tmpdir,
|
||||
'script_opts': self.script_opts,
|
||||
'location': self.location,
|
||||
'auto_cleanup': self.auto_cleanup,
|
||||
'python_path': self.python_path,
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
if os.path.exists(self.get_cwd()):
|
||||
shutil.rmtree(self.get_cwd())
|
||||
|
||||
def __del__(self):
|
||||
if self.auto_cleanup:
|
||||
self.cleanup()
|
||||
|
||||
class GrassEngineException(Exception):
|
||||
pass
|
||||
|
||||
def cleanup_grass_context(serialized_context):
|
||||
ctx = grass.create_context(serialized_context)
|
||||
ctx.cleanup()
|
||||
|
||||
grass = GrassEngine()
|
|
@ -3,5 +3,6 @@ import django.dispatch
|
|||
task_completed = django.dispatch.Signal(providing_args=["task_id"])
|
||||
task_removing = django.dispatch.Signal(providing_args=["task_id"])
|
||||
task_removed = django.dispatch.Signal(providing_args=["task_id"])
|
||||
task_failed = django.dispatch.Signal(providing_args=["task_id"])
|
||||
|
||||
processing_node_removed = django.dispatch.Signal(providing_args=["processing_node_id"])
|
||||
|
|
|
@ -6,7 +6,7 @@ process.env.NODE_PATH = webodmRoot + "node_modules";
|
|||
require("module").Module._initPaths();
|
||||
|
||||
let path = require("path");
|
||||
let ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
|
@ -21,8 +21,9 @@ module.exports = {
|
|||
},
|
||||
|
||||
plugins: [
|
||||
new ExtractTextPlugin('[name].css', {
|
||||
allChunks: true
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].css',
|
||||
chunkFilename: '[id].css'
|
||||
})
|
||||
],
|
||||
|
||||
|
@ -34,7 +35,7 @@ module.exports = {
|
|||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
options: {
|
||||
plugins: [
|
||||
'@babel/syntax-class-properties',
|
||||
'@babel/proposal-class-properties'
|
||||
|
@ -49,22 +50,21 @@ module.exports = {
|
|||
},
|
||||
{
|
||||
test: /\.s?css$$/,
|
||||
use: ExtractTextPlugin.extract({
|
||||
use: [
|
||||
{ loader: 'css-loader' },
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
implementation: require("sass")
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
'css-loader',
|
||||
'sass-loader',
|
||||
]
|
||||
},
|
||||
{
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|svg)/,
|
||||
loader: "url-loader?limit=100000"
|
||||
}
|
||||
use: {
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 100000,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import inspect
|
||||
from worker.celery import app
|
||||
# noinspection PyUnresolvedReferences
|
||||
from worker.tasks import execute_grass_script
|
||||
|
||||
task = app.task
|
||||
|
||||
|
|
|
@ -51,15 +51,13 @@ def export_raster(input, output, **opts):
|
|||
output_raster = output
|
||||
jpg_background = 255 # white
|
||||
|
||||
# KMZ is special, we just export it as jpg with EPSG:4326
|
||||
# KMZ is special, we just export it as GeoTIFF
|
||||
# and then call GDAL to tile/package it
|
||||
kmz = export_format == "kmz"
|
||||
if kmz:
|
||||
export_format = "jpg"
|
||||
epsg = 4326
|
||||
export_format = "gtiff-rgb"
|
||||
path_base, _ = os.path.splitext(output)
|
||||
output_raster = path_base + ".jpg"
|
||||
jpg_background = 0 # black
|
||||
output_raster = path_base + ".kmz.tif"
|
||||
|
||||
if export_format == "jpg":
|
||||
driver = "JPEG"
|
||||
|
@ -283,4 +281,4 @@ def export_raster(input, output, **opts):
|
|||
if kmz:
|
||||
subprocess.check_output(["gdal_translate", "-of", "KMLSUPEROVERLAY",
|
||||
"-co", "Name={}".format(name),
|
||||
"-co", "FORMAT=JPEG", output_raster, output])
|
||||
"-co", "FORMAT=AUTO", output_raster, output])
|
||||
|
|
|
@ -50,11 +50,26 @@ body {
|
|||
margin-right: 0;
|
||||
}
|
||||
|
||||
.navbar-top-links .dropdown-menu li a {
|
||||
.navbar-top-links .dropdown-menu li a{
|
||||
padding: 3px 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.navbar-top-links .dropdown-menu li div.info-item{
|
||||
padding: 3px 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.navbar-top-links .dropdown-menu li div.info-item.quotas{
|
||||
min-width: 232px;
|
||||
}
|
||||
|
||||
.navbar-top-links .dropdown-menu li .progress{
|
||||
margin-bottom: 0;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
|
||||
.navbar-top-links .dropdown-menu li a div {
|
||||
white-space: normal;
|
||||
}
|
||||
|
|
|
@ -1,264 +0,0 @@
|
|||
/* Primary */
|
||||
body,
|
||||
ul#side-menu.nav a,
|
||||
.console,
|
||||
.alert,
|
||||
.form-control,
|
||||
.dropdown-menu > li > a,
|
||||
.theme-color-primary,
|
||||
{
|
||||
color: theme("primary");
|
||||
}
|
||||
.theme-border-primary{
|
||||
border-color: theme("primary");
|
||||
}
|
||||
.tooltip{
|
||||
.tooltip-inner{
|
||||
background-color: theme("primary");
|
||||
}
|
||||
&.left .tooltip-arrow{ border-left-color: theme("primary"); }
|
||||
&.top .tooltip-arrow{ border-top-color: theme("primary"); }
|
||||
&.bottom .tooltip-arrow{ border-bottom-color: theme("primary"); }
|
||||
&.right .tooltip-arrow{ border-right-color: theme("primary"); }
|
||||
}
|
||||
.theme-fill-primary{
|
||||
fill: theme("primary");
|
||||
}
|
||||
.theme-stroke-primary{
|
||||
stroke: theme("primary");
|
||||
}
|
||||
|
||||
/* Secondary */
|
||||
body,
|
||||
.navbar-default,
|
||||
.console,
|
||||
.alert,
|
||||
.modal-content,
|
||||
.form-control,
|
||||
.dropdown-menu,
|
||||
.theme-secondary
|
||||
{
|
||||
background-color: theme("secondary");
|
||||
}
|
||||
|
||||
.tooltip > .tooltip-inner{
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
.alert{
|
||||
.close:hover, .close:focus{
|
||||
color: complementary(theme("secondary"));
|
||||
}
|
||||
}
|
||||
|
||||
.pagination li > a,
|
||||
.pagination .disabled > a,
|
||||
.pagination .disabled > a:hover, .pagination .disabled > a:focus{
|
||||
color: scaleby(theme("primary"), 0.7);
|
||||
background-color: theme("secondary");
|
||||
border-color: scaleby(theme("secondary"), 0.7);
|
||||
}
|
||||
.pagination li > a{
|
||||
color: theme("primary");
|
||||
}
|
||||
|
||||
.btn-secondary, .btn-secondary:active, .btn-secondary.active, .open>.dropdown-toggle.btn-secondary{
|
||||
background-color: theme("secondary");
|
||||
border-color: theme("secondary");
|
||||
color: theme("primary");
|
||||
|
||||
&:hover, &:active, &:focus{
|
||||
background-color: scalebyiv(theme("secondary"), 0.90);
|
||||
border-color: scalebyiv(theme("secondary"), 0.90);
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Tertiary */
|
||||
a, a:hover, a:focus{
|
||||
color: theme("tertiary");
|
||||
}
|
||||
.progress-bar-success{
|
||||
background-color: theme("tertiary");
|
||||
}
|
||||
|
||||
/* Button primary */
|
||||
#navbar-top .navbar-top-links,{
|
||||
a:hover,a:focus,.open > a{
|
||||
background-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:focus{
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#navbar-top ul#side-menu a:hover, #navbar-top ul#side-menu a.active:hover{
|
||||
background-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
.btn-primary, .btn-primary:active, .btn-primary.active, .open>.dropdown-toggle.btn-primary{
|
||||
background-color: theme("button_primary");
|
||||
border-color: theme("button_primary");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active{
|
||||
background-color: scalebyiv(theme("button_primary"), 0.90);
|
||||
border-color: scalebyiv(theme("button_primary"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Button default */
|
||||
.btn-default, .btn-default:active, .btn-default.active, .open>.dropdown-toggle.btn-default{
|
||||
background-color: theme("button_default");
|
||||
border-color: theme("button_default");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active{
|
||||
background-color: scalebyiv(theme("button_default"), 0.90);
|
||||
border-color: scalebyiv(theme("button_default"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
.pagination>.active>a, .pagination>.active>span, .pagination>.active>a:hover, .pagination>.active>span:hover, .pagination>.active>a:focus, .pagination>.active>span:focus,
|
||||
.pagination .active > a:hover, .pagination .active > a:focus,
|
||||
.pagination li > a:hover, .pagination li > a:focus{
|
||||
background-color: theme("button_default");
|
||||
color: theme("secondary");
|
||||
}
|
||||
|
||||
/* Button danger */
|
||||
.btn-danger, .btn-danger:active, .btn-danger.active, .open>.dropdown-toggle.btn-danger{
|
||||
background-color: theme("button_danger");
|
||||
border-color: theme("button_danger");
|
||||
color: theme("secondary");
|
||||
|
||||
&:hover, &:active, &:focus, &[disabled]:hover, &[disabled]:focus, &[disabled]:active {
|
||||
background-color: scalebyiv(theme("button_danger"), 0.90);
|
||||
border-color: scalebyiv(theme("button_danger"), 0.90);
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
.theme-color-button-danger{
|
||||
color: theme("button_danger");
|
||||
}
|
||||
|
||||
.theme-color-button-primary{
|
||||
color: theme("button_primary");
|
||||
}
|
||||
|
||||
/* Header background */
|
||||
#navbar-top{
|
||||
background-color: theme("header_background");
|
||||
}
|
||||
|
||||
/* Header primary */
|
||||
.navbar-default .navbar-link,
|
||||
#navbar-top .navbar-top-links a.dropdown-toggle{
|
||||
color: theme("header_primary");
|
||||
|
||||
&:hover{
|
||||
color: theme("secondary");
|
||||
}
|
||||
}
|
||||
|
||||
/* Border */
|
||||
.sidebar ul li,
|
||||
.project-list-item,
|
||||
#page-wrapper,
|
||||
table-bordered>thead>tr>th, .table-bordered>thead>tr>th, table-bordered>tbody>tr>th, .table-bordered>tbody>tr>th, table-bordered>tfoot>tr>th, .table-bordered>tfoot>tr>th, table-bordered>thead>tr>td, .table-bordered>thead>tr>td, table-bordered>tbody>tr>td, .table-bordered>tbody>tr>td, table-bordered>tfoot>tr>td, .table-bordered>tfoot>tr>td,
|
||||
footer,
|
||||
.modal-content,
|
||||
.modal-header,
|
||||
.modal-footer,
|
||||
.dropdown-menu
|
||||
{
|
||||
border-color: theme("border");
|
||||
}
|
||||
.dropdown-menu .divider{
|
||||
background-color: theme("border");
|
||||
}
|
||||
.popover-title{
|
||||
border-bottom-color: theme("border");
|
||||
}
|
||||
.theme-border{
|
||||
border-color: theme("border");
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.task-list-item:nth-child(odd),
|
||||
.table-striped>tbody>tr:nth-of-type(odd),
|
||||
select.form-control option[disabled],
|
||||
.theme-background-highlight{
|
||||
background-color: theme("highlight");
|
||||
}
|
||||
.dropdown-menu > li > a{
|
||||
&:hover, &:focus{
|
||||
background-color: theme("highlight");
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
pre.prettyprint,
|
||||
.form-control{
|
||||
border-color: theme('highlight');
|
||||
&:focus{
|
||||
border-color: scalebyiv(theme('highlight'), 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog warning */
|
||||
.alert-warning{
|
||||
border-color: theme("dialog_warning");
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.task-list-item .status-label.done, .theme-background-success{
|
||||
background-color: theme("success");
|
||||
}
|
||||
|
||||
/* Failed */
|
||||
.task-list-item .status-label.error, .theme-background-failed{
|
||||
background-color: theme("failed");
|
||||
}
|
||||
|
||||
/* ModelView.jsx specific */
|
||||
.model-view #potree_sidebar_container {
|
||||
.dropdown-menu > li > a{
|
||||
color: theme("primary");
|
||||
}
|
||||
}
|
||||
|
||||
/* MapView.jsx specific */
|
||||
.leaflet-bar a, .leaflet-control > a{
|
||||
background-color: theme("secondary") !important;
|
||||
border-color: theme("secondary") !important;
|
||||
color: theme("primary") !important;
|
||||
|
||||
&:hover{
|
||||
background-color: scalebyiv(theme("secondary"), 0.90) !important;
|
||||
border-color: scalebyiv(theme("secondary"), 0.90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper{
|
||||
background-color: theme("secondary") !important;
|
||||
color: theme("primary") !important;
|
||||
a{
|
||||
color: theme("tertiary") !important;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container{
|
||||
a.leaflet-popup-close-button{
|
||||
color: theme("primary") !important;
|
||||
&:hover{
|
||||
color: complementary(theme("secondary")) !important;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -132,7 +132,7 @@ class Console extends React.Component {
|
|||
let lines = this.state.lines;
|
||||
if (this.props.maximumLines && lines.length > this.props.maximumLines){
|
||||
lines = lines.slice(-this.props.maximumLines);
|
||||
lines.unshift('... ' + interpolate(_("output truncated at %(count)s lines"), { lines: this.props.maximumLines }) + ' ...');
|
||||
lines.unshift('... ' + interpolate(_("output truncated at %(count)s lines"), { count: this.props.maximumLines }) + ' ...');
|
||||
}
|
||||
|
||||
const items = [
|
||||
|
|
|
@ -28,10 +28,12 @@ class Dashboard extends React.Component {
|
|||
return $.ajax({
|
||||
url: `/api/projects/`,
|
||||
type: 'POST',
|
||||
data: {
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
name: project.name,
|
||||
description: project.descr
|
||||
}
|
||||
description: project.descr,
|
||||
tags: project.tags
|
||||
})
|
||||
}).done(() => {
|
||||
this.projectList.refresh();
|
||||
});
|
||||
|
@ -39,13 +41,15 @@ class Dashboard extends React.Component {
|
|||
|
||||
render() {
|
||||
const projectList = ({ location, history }) => {
|
||||
let q = Utils.queryParams(location),
|
||||
page = parseInt(q.page !== undefined ? q.page : 1);
|
||||
let q = Utils.queryParams(location);
|
||||
if (q.page === undefined) q.page = 1;
|
||||
else q.page = parseInt(q.page);
|
||||
|
||||
return <ProjectList
|
||||
source={`/api/projects/?ordering=-created_at&page=${page}`}
|
||||
source={`/api/projects/${Utils.toSearchQuery(q)}`}
|
||||
ref={(domNode) => { this.projectList = domNode; }}
|
||||
currentPage={page}
|
||||
currentPage={q.page}
|
||||
currentSearch={q.search}
|
||||
history={history}
|
||||
/>;
|
||||
};
|
||||
|
|
|
@ -8,7 +8,7 @@ import { _, interpolate } from './classes/gettext';
|
|||
class MapView extends React.Component {
|
||||
static defaultProps = {
|
||||
mapItems: [],
|
||||
selectedMapType: 'orthophoto',
|
||||
selectedMapType: 'auto',
|
||||
title: "",
|
||||
public: false,
|
||||
shareButtons: true
|
||||
|
@ -16,7 +16,7 @@ class MapView extends React.Component {
|
|||
|
||||
static propTypes = {
|
||||
mapItems: PropTypes.array.isRequired, // list of dictionaries where each dict is a {mapType: 'orthophoto', url: <tiles.json>},
|
||||
selectedMapType: PropTypes.oneOf(['orthophoto', 'plant', 'dsm', 'dtm']),
|
||||
selectedMapType: PropTypes.oneOf(['auto', 'orthophoto', 'plant', 'dsm', 'dtm']),
|
||||
title: PropTypes.string,
|
||||
public: PropTypes.bool,
|
||||
shareButtons: PropTypes.bool
|
||||
|
@ -25,9 +25,30 @@ class MapView extends React.Component {
|
|||
constructor(props){
|
||||
super(props);
|
||||
|
||||
let selectedMapType = props.selectedMapType;
|
||||
|
||||
// Automatically select type based on available tiles
|
||||
// and preference order (below)
|
||||
if (props.selectedMapType === "auto"){
|
||||
let preferredTypes = ['orthophoto', 'dsm', 'dtm'];
|
||||
|
||||
for (let i = 0; i < this.props.mapItems.length; i++){
|
||||
let mapItem = this.props.mapItems[i];
|
||||
for (let j = 0; j < preferredTypes.length; j++){
|
||||
if (mapItem.tiles.find(t => t.type === preferredTypes[j])){
|
||||
selectedMapType = preferredTypes[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectedMapType !== "auto") break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedMapType === "auto") selectedMapType = "orthophoto"; // Hope for the best
|
||||
|
||||
this.state = {
|
||||
selectedMapType: props.selectedMapType,
|
||||
tiles: this.getTilesByMapType(props.selectedMapType)
|
||||
selectedMapType,
|
||||
tiles: this.getTilesByMapType(selectedMapType)
|
||||
};
|
||||
|
||||
this.getTilesByMapType = this.getTilesByMapType.bind(this);
|
||||
|
@ -101,7 +122,7 @@ class MapView extends React.Component {
|
|||
{this.props.title ?
|
||||
<h3><i className="fa fa-globe"></i> {this.props.title}</h3>
|
||||
: ""}
|
||||
|
||||
|
||||
<div className="map-container">
|
||||
<Map
|
||||
tiles={this.state.tiles}
|
||||
|
|
|
@ -10,10 +10,12 @@ import PropTypes from 'prop-types';
|
|||
import * as THREE from 'THREE';
|
||||
import $ from 'jquery';
|
||||
import { _, interpolate } from './classes/gettext';
|
||||
import { getUnitSystem, setUnitSystem } from './classes/Units';
|
||||
|
||||
require('./vendor/OBJLoader');
|
||||
require('./vendor/MTLLoader');
|
||||
require('./vendor/ColladaLoader');
|
||||
require('./vendor/GLTFLoader');
|
||||
require('./vendor/DRACOLoader');
|
||||
|
||||
class SetCameraView extends React.Component{
|
||||
static propTypes = {
|
||||
|
@ -150,14 +152,10 @@ class ModelView extends React.Component {
|
|||
this.pointCloud = null;
|
||||
this.modelReference = null;
|
||||
|
||||
this.toggleTexturedModel = this.toggleTexturedModel.bind(this);
|
||||
this.toggleCameras = this.toggleCameras.bind(this);
|
||||
|
||||
|
||||
this.cameraMeshes = [];
|
||||
}
|
||||
|
||||
assetsPath(){
|
||||
assetsPath = () => {
|
||||
return `/api/projects/${this.props.task.project}/tasks/${this.props.task.id}/assets`
|
||||
}
|
||||
|
||||
|
@ -224,23 +222,28 @@ class ModelView extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
texturedModelDirectoryPath(){
|
||||
texturedModelDirectoryPath = () => {
|
||||
return this.assetsPath() + '/odm_texturing/';
|
||||
}
|
||||
|
||||
hasGeoreferencedAssets(){
|
||||
hasGeoreferencedAssets = () => {
|
||||
return this.props.task.available_assets.indexOf('orthophoto.tif') !== -1;
|
||||
}
|
||||
|
||||
hasTexturedModel(){
|
||||
hasTexturedModel = () => {
|
||||
return this.props.task.available_assets.indexOf('textured_model.zip') !== -1;
|
||||
}
|
||||
|
||||
hasCameras(){
|
||||
getTexturedModelType = () => {
|
||||
if (this.props.task.available_assets.indexOf('textured_model.glb') !== -1) return 'gltf';
|
||||
else return 'obj';
|
||||
}
|
||||
|
||||
hasCameras = () => {
|
||||
return this.props.task.available_assets.indexOf('shots.geojson') !== -1;
|
||||
}
|
||||
|
||||
objFilePath(cb){
|
||||
objFilePath = (cb) => {
|
||||
// Mostly for backward compatibility
|
||||
// as newer versions of ODM do not have
|
||||
// a odm_textured_model.obj
|
||||
|
@ -257,7 +260,11 @@ class ModelView extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
mtlFilename(cb){
|
||||
glbFilePath = () => {
|
||||
return this.texturedModelDirectoryPath() + 'odm_textured_model_geo.glb';
|
||||
}
|
||||
|
||||
mtlFilename = (cb) => {
|
||||
// Mostly for backward compatibility
|
||||
// as newer versions of ODM do not have
|
||||
// a odm_textured_model.mtl
|
||||
|
@ -292,9 +299,23 @@ class ModelView extends React.Component {
|
|||
window.viewer = new Potree.Viewer(container);
|
||||
viewer.setEDLEnabled(true);
|
||||
viewer.setFOV(60);
|
||||
viewer.setPointBudget(1*1000*1000);
|
||||
viewer.setPointBudget(10*1000*1000);
|
||||
viewer.setEDLEnabled(true);
|
||||
viewer.loadSettingsFromURL();
|
||||
|
||||
const currentUnit = getUnitSystem();
|
||||
const origSetUnit = viewer.setLengthUnitAndDisplayUnit;
|
||||
viewer.setLengthUnitAndDisplayUnit = (lengthUnit, displayUnit) => {
|
||||
if (displayUnit === 'm') setUnitSystem('metric');
|
||||
else if (displayUnit === 'ft'){
|
||||
// Potree doesn't have US/international imperial, so
|
||||
// we default to international unless the user has previously
|
||||
// selected US
|
||||
if (currentUnit === 'metric') setUnitSystem("imperial");
|
||||
else setUnitSystem(currentUnit);
|
||||
}
|
||||
origSetUnit.call(viewer, lengthUnit, displayUnit);
|
||||
};
|
||||
|
||||
viewer.loadGUI(() => {
|
||||
viewer.setLanguage('en');
|
||||
|
@ -329,7 +350,7 @@ class ModelView extends React.Component {
|
|||
directional.position.z = 99999999999;
|
||||
viewer.scene.scene.add( directional );
|
||||
|
||||
this.pointCloudFilePath(pointCloudPath => {
|
||||
this.pointCloudFilePath(pointCloudPath =>{
|
||||
Potree.loadPointCloud(pointCloudPath, "Point Cloud", e => {
|
||||
if (e.type == "loading_failed"){
|
||||
this.setState({error: "Could not load point cloud. This task doesn't seem to have one. Try processing the task again."});
|
||||
|
@ -345,6 +366,12 @@ class ModelView extends React.Component {
|
|||
|
||||
viewer.fitToScreen();
|
||||
|
||||
if (getUnitSystem() === 'metric'){
|
||||
viewer.setLengthUnitAndDisplayUnit('m', 'm');
|
||||
}else{
|
||||
viewer.setLengthUnitAndDisplayUnit('m', 'ft');
|
||||
}
|
||||
|
||||
// Load saved scene (if any)
|
||||
$.ajax({
|
||||
type: "GET",
|
||||
|
@ -457,13 +484,13 @@ class ModelView extends React.Component {
|
|||
|
||||
if ( intersects.length > 0){
|
||||
const intersection = intersects[0];
|
||||
return intersection.object;
|
||||
return intersection.object.parent.parent;
|
||||
}
|
||||
}
|
||||
|
||||
setCameraOpacity(camera, opacity){
|
||||
camera.material.forEach(m => {
|
||||
m.opacity = opacity;
|
||||
camera.traverse(obj => {
|
||||
if (obj.material) obj.material.opacity = opacity;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -520,30 +547,34 @@ class ModelView extends React.Component {
|
|||
}
|
||||
|
||||
if (this.hasCameras()){
|
||||
const colladaLoader = new THREE.ColladaLoader();
|
||||
const fileloader = new THREE.FileLoader();
|
||||
|
||||
colladaLoader.load('/static/app/models/camera.dae', ( collada ) => {
|
||||
const dae = collada.scene;
|
||||
this.loadGltf('/static/app/models/camera.glb', (err, gltf) => {
|
||||
if (err){
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraObj = gltf.scene;
|
||||
|
||||
fileloader.load(`/api/projects/${task.project}/tasks/${task.id}/download/shots.geojson`, ( data ) => {
|
||||
const geojson = JSON.parse(data);
|
||||
const cameraObj = dae.children[0];
|
||||
cameraObj.material.forEach(m => {
|
||||
m.transparent = true;
|
||||
m.opacity = 0.7;
|
||||
cameraObj.traverse(obj => {
|
||||
if (obj.material){
|
||||
obj.material.transparent = true;
|
||||
obj.material.opacity = 0.7;
|
||||
}
|
||||
});
|
||||
|
||||
// const cameraObj = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial());
|
||||
|
||||
// TODO: instancing doesn't seem to work :/
|
||||
// const cameraMeshes = new THREE.InstancedMesh( cameraObj.geometry, cameraObj.material, geojson.features.length );
|
||||
// const dummy = new THREE.Object3D();
|
||||
|
||||
let i = 0;
|
||||
geojson.features.forEach(feat => {
|
||||
const material = cameraObj.material.map(m => m.clone());
|
||||
const cameraMesh = new THREE.Mesh(cameraObj.geometry, material);
|
||||
const cameraMesh = cameraObj.clone();
|
||||
cameraMesh.traverse((node) => {
|
||||
if (node.isMesh) {
|
||||
node.material = node.material.clone();
|
||||
}
|
||||
});
|
||||
|
||||
cameraMesh.matrixAutoUpdate = false;
|
||||
let scale = 1.0;
|
||||
// if (!this.pointCloud.projection) scale = 0.1;
|
||||
|
@ -553,7 +584,7 @@ class ModelView extends React.Component {
|
|||
viewer.scene.scene.add(cameraMesh);
|
||||
|
||||
cameraMesh._feat = feat;
|
||||
this.cameraMeshes.push(cameraMesh);
|
||||
this.cameraMeshes.push(cameraMesh.children[0].children[1]);
|
||||
|
||||
i++;
|
||||
});
|
||||
|
@ -573,17 +604,38 @@ class ModelView extends React.Component {
|
|||
// }
|
||||
}
|
||||
|
||||
toggleCameras(e){
|
||||
toggleCameras = (e) => {
|
||||
if (this.cameraMeshes.length === 0){
|
||||
this.loadCameras();
|
||||
if (this.cameraMeshes.length === 0) return;
|
||||
}
|
||||
|
||||
const isVisible = this.cameraMeshes[0].visible;
|
||||
this.cameraMeshes.forEach(cam => cam.visible = !isVisible);
|
||||
this.cameraMeshes.forEach(cam => {
|
||||
cam.visible = !isVisible;
|
||||
cam.parent.visible = cam.visible;
|
||||
});
|
||||
}
|
||||
|
||||
toggleTexturedModel(e){
|
||||
loadGltf = (url, cb) => {
|
||||
if (!this.gltfLoader) this.gltfLoader = new THREE.GLTFLoader();
|
||||
if (!this.dracoLoader) {
|
||||
this.dracoLoader = new THREE.DRACOLoader();
|
||||
this.dracoLoader.setDecoderPath( '/static/app/js/vendor/draco/' );
|
||||
this.gltfLoader.setDRACOLoader( this.dracoLoader );
|
||||
}
|
||||
|
||||
// Load a glTF resource
|
||||
this.gltfLoader.load(url,
|
||||
gltf => { cb(null, gltf) },
|
||||
xhr => {
|
||||
// called while loading is progressing
|
||||
},
|
||||
error => { cb(error); }
|
||||
);
|
||||
}
|
||||
|
||||
toggleTexturedModel = (e) => {
|
||||
const value = e.target.checked;
|
||||
|
||||
if (value){
|
||||
|
@ -592,34 +644,57 @@ class ModelView extends React.Component {
|
|||
|
||||
this.setState({initializingModel: true});
|
||||
|
||||
const mtlLoader = new THREE.MTLLoader();
|
||||
mtlLoader.setPath(this.texturedModelDirectoryPath());
|
||||
const addObject = (object, offset) => {
|
||||
object.translateX(offset.x);
|
||||
object.translateY(offset.y);
|
||||
|
||||
this.mtlFilename(mtlPath => {
|
||||
mtlLoader.load(mtlPath, (materials) => {
|
||||
materials.preload();
|
||||
viewer.scene.scene.add(object);
|
||||
|
||||
this.modelReference = object;
|
||||
this.setPointCloudsVisible(false);
|
||||
|
||||
this.setState({
|
||||
initializingModel: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.getTexturedModelType() === 'gltf'){
|
||||
this.loadGltf(this.glbFilePath(), (err, gltf) => {
|
||||
if (err){
|
||||
this.setState({initializingModel: false, error: err});
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = {x: 0, y: 0};
|
||||
if (gltf.scene.CESIUM_RTC && gltf.scene.CESIUM_RTC.center){
|
||||
offset.x = gltf.scene.CESIUM_RTC.center[0];
|
||||
offset.y = gltf.scene.CESIUM_RTC.center[1];
|
||||
}
|
||||
|
||||
addObject(gltf.scene, offset);
|
||||
});
|
||||
}else{
|
||||
// Legacy OBJ
|
||||
|
||||
const mtlLoader = new THREE.MTLLoader();
|
||||
mtlLoader.setPath(this.texturedModelDirectoryPath());
|
||||
|
||||
const objLoader = new THREE.OBJLoader();
|
||||
objLoader.setMaterials(materials);
|
||||
this.objFilePath(filePath => {
|
||||
objLoader.load(filePath, (object) => {
|
||||
this.loadGeoreferencingOffset((offset) => {
|
||||
object.translateX(offset.x);
|
||||
object.translateY(offset.y);
|
||||
|
||||
viewer.scene.scene.add(object);
|
||||
|
||||
this.modelReference = object;
|
||||
this.setPointCloudsVisible(false);
|
||||
|
||||
this.setState({
|
||||
initializingModel: false,
|
||||
this.mtlFilename(mtlPath => {
|
||||
mtlLoader.load(mtlPath, (materials) => {
|
||||
materials.preload();
|
||||
|
||||
const objLoader = new THREE.OBJLoader();
|
||||
objLoader.setMaterials(materials);
|
||||
this.objFilePath(filePath => {
|
||||
objLoader.load(filePath, (object) => {
|
||||
this.loadGeoreferencingOffset((offset) => {
|
||||
addObject(object, offset);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}else{
|
||||
// Already initialized
|
||||
this.modelReference.visible = true;
|
||||
|
|
|
@ -54,6 +54,7 @@ const api = {
|
|||
new AssetDownload(_("Point Cloud (3D Tiles)"),"3d_tiles_pointcloud.zip","fa fa-cube"),
|
||||
new AssetDownload(_("Textured Model"),"textured_model.zip","fab fa-connectdevelop"),
|
||||
new AssetDownload(_("Textured Model (3D Tiles)"),"3d_tiles_model.zip","fab fa-connectdevelop"),
|
||||
new AssetDownload(_("Textured Model (glTF)"),"textured_model.glb","fab fa-connectdevelop"),
|
||||
new AssetDownload(_("Camera Parameters"),"cameras.json","fa fa-camera"),
|
||||
new AssetDownload(_("Camera Shots"),"shots.geojson","fa fa-camera"),
|
||||
new AssetDownload(_("Ground Control Points"),"ground_control_points.geojson","far fa-dot-circle"),
|
||||
|
|
|
@ -18,6 +18,14 @@ class Storage{
|
|||
console.warn("Failed to call setItem " + key, e);
|
||||
}
|
||||
}
|
||||
|
||||
static removeItem(key){
|
||||
try{
|
||||
localStorage.removeItem(key);
|
||||
}catch(e){
|
||||
console.warn("Failed to call removeItem " + key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Storage;
|
|
@ -0,0 +1,21 @@
|
|||
export default {
|
||||
userTags: function(tags){
|
||||
// Tags starting with a "." are considered hidden or system tags
|
||||
// and should not be displayed to end users via the UI
|
||||
if (Array.isArray(tags)){
|
||||
return tags.filter(t => !t.startsWith("."));
|
||||
}else return [];
|
||||
},
|
||||
|
||||
systemTags: function(tags){
|
||||
if (Array.isArray(tags)){
|
||||
return tags.filter(t => t.startsWith("."));
|
||||
}else return [];
|
||||
},
|
||||
|
||||
combine: function(user, system){
|
||||
if (Array.isArray(user) && Array.isArray(system)){
|
||||
return user.concat(system);
|
||||
}else throw Error("Invalid parameters");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,378 @@
|
|||
import { _ } from './gettext';
|
||||
|
||||
const types = {
|
||||
LENGTH: 1,
|
||||
AREA: 2,
|
||||
VOLUME: 3
|
||||
};
|
||||
|
||||
const units = {
|
||||
acres: {
|
||||
factor: (1 / (0.3048 * 0.3048)) / 43560,
|
||||
abbr: 'ac',
|
||||
round: 5,
|
||||
label: _("Acres"),
|
||||
type: types.AREA
|
||||
},
|
||||
acres_us: {
|
||||
factor: Math.pow(3937 / 1200, 2) / 43560,
|
||||
abbr: 'ac (US)',
|
||||
round: 5,
|
||||
label: _("Acres"),
|
||||
type: types.AREA
|
||||
},
|
||||
feet: {
|
||||
factor: 1 / 0.3048,
|
||||
abbr: 'ft',
|
||||
round: 4,
|
||||
label: _("Feet"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
feet_us:{
|
||||
factor: 3937 / 1200,
|
||||
abbr: 'ft (US)',
|
||||
round: 4,
|
||||
label: _("Feet"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
hectares: {
|
||||
factor: 0.0001,
|
||||
abbr: 'ha',
|
||||
round: 4,
|
||||
label: _("Hectares"),
|
||||
type: types.AREA
|
||||
},
|
||||
meters: {
|
||||
factor: 1,
|
||||
abbr: 'm',
|
||||
round: 3,
|
||||
label: _("Meters"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
kilometers: {
|
||||
factor: 0.001,
|
||||
abbr: 'km',
|
||||
round: 5,
|
||||
label: _("Kilometers"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
centimeters: {
|
||||
factor: 100,
|
||||
abbr: 'cm',
|
||||
round: 1,
|
||||
label: _("Centimeters"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
miles: {
|
||||
factor: (1 / 0.3048) / 5280,
|
||||
abbr: 'mi',
|
||||
round: 5,
|
||||
label: _("Miles"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
miles_us: {
|
||||
factor: (3937 / 1200) / 5280,
|
||||
abbr: 'mi (US)',
|
||||
round: 5,
|
||||
label: _("Miles"),
|
||||
type: types.LENGTH
|
||||
},
|
||||
sqfeet: {
|
||||
factor: 1 / (0.3048 * 0.3048),
|
||||
abbr: 'ft²',
|
||||
round: 2,
|
||||
label: _("Square Feet"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqfeet_us: {
|
||||
factor: Math.pow(3937 / 1200, 2),
|
||||
abbr: 'ft² (US)',
|
||||
round: 2,
|
||||
label: _("Square Feet"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmeters: {
|
||||
factor: 1,
|
||||
abbr: 'm²',
|
||||
round: 2,
|
||||
label: _("Square Meters"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqkilometers: {
|
||||
factor: 0.000001,
|
||||
abbr: 'km²',
|
||||
round: 5,
|
||||
label: _("Square Kilometers"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmiles: {
|
||||
factor: Math.pow((1 / 0.3048) / 5280, 2),
|
||||
abbr: 'mi²',
|
||||
round: 5,
|
||||
label: _("Square Miles"),
|
||||
type: types.AREA
|
||||
},
|
||||
sqmiles_us: {
|
||||
factor: Math.pow((3937 / 1200) / 5280, 2),
|
||||
abbr: 'mi² (US)',
|
||||
round: 5,
|
||||
label: _("Square Miles"),
|
||||
type: types.AREA
|
||||
},
|
||||
cbmeters:{
|
||||
factor: 1,
|
||||
abbr: 'm³',
|
||||
round: 4,
|
||||
label: _("Cubic Meters"),
|
||||
type: types.VOLUME
|
||||
},
|
||||
cbyards:{
|
||||
factor: Math.pow(1/(0.3048*3), 3),
|
||||
abbr: 'yd³',
|
||||
round: 4,
|
||||
label: _("Cubic Yards"),
|
||||
type: types.VOLUME
|
||||
},
|
||||
cbyards_us:{
|
||||
factor: Math.pow(3937/3600, 3),
|
||||
abbr: 'yd³ (US)',
|
||||
round: 4,
|
||||
label: _("Cubic Yards"),
|
||||
type: types.VOLUME
|
||||
}
|
||||
};
|
||||
|
||||
class ValueUnit{
|
||||
constructor(value, unit){
|
||||
this.value = value;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
toString(opts = {}){
|
||||
const mul = Math.pow(10, opts.precision !== undefined ? opts.precision : this.unit.round);
|
||||
const rounded = (Math.round(this.value * mul) / mul).toString();
|
||||
|
||||
let withCommas = "";
|
||||
let parts = rounded.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
withCommas = parts.join(".");
|
||||
|
||||
return `${withCommas} ${this.unit.abbr}`;
|
||||
}
|
||||
}
|
||||
|
||||
class NanUnit{
|
||||
constructor(){
|
||||
this.value = NaN;
|
||||
this.unit = units.meters; // Don't matter
|
||||
}
|
||||
toString(){
|
||||
return "NaN";
|
||||
}
|
||||
}
|
||||
|
||||
class UnitSystem{
|
||||
lengthUnit(meters, opts = {}){ throw new Error("Not implemented"); }
|
||||
areaUnit(sqmeters, opts = {}){ throw new Error("Not implemented"); }
|
||||
volumeUnit(cbmeters, opts = {}){ throw new Error("Not implemented"); }
|
||||
|
||||
getName(){ throw new Error("Not implemented"); }
|
||||
|
||||
area(sqmeters, opts = {}){
|
||||
sqmeters = parseFloat(sqmeters);
|
||||
if (isNaN(sqmeters)) return NanUnit();
|
||||
|
||||
const unit = this.areaUnit(sqmeters, opts);
|
||||
const val = unit.factor * sqmeters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
|
||||
length(meters, opts = {}){
|
||||
meters = parseFloat(meters);
|
||||
if (isNaN(meters)) return NanUnit();
|
||||
|
||||
const unit = this.lengthUnit(meters, opts);
|
||||
const val = unit.factor * meters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
|
||||
volume(cbmeters, opts = {}){
|
||||
cbmeters = parseFloat(cbmeters);
|
||||
if (isNaN(cbmeters)) return NanUnit();
|
||||
|
||||
const unit = this.volumeUnit(cbmeters, opts);
|
||||
const val = unit.factor * cbmeters;
|
||||
return new ValueUnit(val, unit);
|
||||
}
|
||||
};
|
||||
|
||||
function toMetric(valueUnit, unit){
|
||||
let value = NaN;
|
||||
if (typeof valueUnit === "object" && unit === undefined){
|
||||
value = valueUnit.value;
|
||||
unit = valueUnit.unit;
|
||||
}else{
|
||||
value = parseFloat(valueUnit);
|
||||
}
|
||||
if (isNaN(value)) return NanUnit();
|
||||
|
||||
const val = value / unit.factor;
|
||||
if (unit.type === types.LENGTH){
|
||||
return new ValueUnit(val, units.meters);
|
||||
}else if (unit.type === types.AREA){
|
||||
return new ValueUnit(val, unit.sqmeters);
|
||||
}else if (unit.type === types.VOLUME){
|
||||
return new ValueUnit(val, unit.cbmeters);
|
||||
}else{
|
||||
throw new Error(`Unrecognized unit type: ${unit.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
class MetricSystem extends UnitSystem{
|
||||
getName(){
|
||||
return _("Metric");
|
||||
}
|
||||
|
||||
lengthUnit(meters, opts = {}){
|
||||
if (opts.fixedUnit) return units.meters;
|
||||
|
||||
if (meters < 1) return units.centimeters;
|
||||
else if (meters >= 1000) return units.kilometers;
|
||||
else return units.meters;
|
||||
}
|
||||
|
||||
areaUnit(sqmeters, opts = {}){
|
||||
if (opts.fixedUnit) return units.sqmeters;
|
||||
|
||||
if (sqmeters >= 10000 && sqmeters < 1000000) return units.hectares;
|
||||
else if (sqmeters >= 1000000) return units.sqkilometers;
|
||||
return units.sqmeters;
|
||||
}
|
||||
|
||||
volumeUnit(cbmeters, opts = {}){
|
||||
return units.cbmeters;
|
||||
}
|
||||
}
|
||||
|
||||
class ImperialSystem extends UnitSystem{
|
||||
getName(){
|
||||
return _("Imperial");
|
||||
}
|
||||
|
||||
feet(){
|
||||
return units.feet;
|
||||
}
|
||||
|
||||
sqfeet(){
|
||||
return units.sqfeet;
|
||||
}
|
||||
|
||||
miles(){
|
||||
return units.miles;
|
||||
}
|
||||
|
||||
sqmiles(){
|
||||
return units.sqmiles;
|
||||
}
|
||||
|
||||
acres(){
|
||||
return units.acres;
|
||||
}
|
||||
|
||||
cbyards(){
|
||||
return units.cbyards;
|
||||
}
|
||||
|
||||
lengthUnit(meters, opts = {}){
|
||||
if (opts.fixedUnit) return this.feet();
|
||||
|
||||
const feet = this.feet().factor * meters;
|
||||
if (feet >= 5280) return this.miles();
|
||||
else return this.feet();
|
||||
}
|
||||
|
||||
areaUnit(sqmeters, opts = {}){
|
||||
if (opts.fixedUnit) return this.sqfeet();
|
||||
|
||||
const sqfeet = this.sqfeet().factor * sqmeters;
|
||||
if (sqfeet >= 43560 && sqfeet < 27878400) return this.acres();
|
||||
else if (sqfeet >= 27878400) return this.sqmiles();
|
||||
else return this.sqfeet();
|
||||
}
|
||||
|
||||
volumeUnit(cbmeters, opts = {}){
|
||||
return this.cbyards();
|
||||
}
|
||||
}
|
||||
|
||||
class ImperialUSSystem extends ImperialSystem{
|
||||
getName(){
|
||||
return _("Imperial (US)");
|
||||
}
|
||||
|
||||
feet(){
|
||||
return units.feet_us;
|
||||
}
|
||||
|
||||
sqfeet(){
|
||||
return units.sqfeet_us;
|
||||
}
|
||||
|
||||
miles(){
|
||||
return units.miles_us;
|
||||
}
|
||||
|
||||
sqmiles(){
|
||||
return units.sqmiles_us;
|
||||
}
|
||||
|
||||
acres(){
|
||||
return units.acres_us;
|
||||
}
|
||||
|
||||
cbyards(){
|
||||
return units.cbyards_us;
|
||||
}
|
||||
}
|
||||
|
||||
const systems = {
|
||||
metric: new MetricSystem(),
|
||||
imperial: new ImperialSystem(),
|
||||
imperialUS: new ImperialUSSystem()
|
||||
}
|
||||
|
||||
// Expose to allow every part of the app to access this information
|
||||
function getUnitSystem(){
|
||||
return localStorage.getItem("unit_system") || "metric";
|
||||
}
|
||||
function setUnitSystem(system){
|
||||
let prevSystem = getUnitSystem();
|
||||
localStorage.setItem("unit_system", system);
|
||||
if (prevSystem !== system){
|
||||
document.dispatchEvent(new CustomEvent("onUnitSystemChanged", { detail: system }));
|
||||
}
|
||||
}
|
||||
|
||||
function onUnitSystemChanged(callback){
|
||||
document.addEventListener("onUnitSystemChanged", callback);
|
||||
}
|
||||
|
||||
function offUnitSystemChanged(callback){
|
||||
document.removeEventListener("onUnitSystemChanged", callback);
|
||||
}
|
||||
|
||||
function unitSystem(){
|
||||
return systems[getUnitSystem()];
|
||||
}
|
||||
|
||||
export {
|
||||
systems,
|
||||
types,
|
||||
toMetric,
|
||||
unitSystem,
|
||||
getUnitSystem,
|
||||
setUnitSystem,
|
||||
onUnitSystemChanged,
|
||||
offUnitSystemChanged
|
||||
};
|
||||
|
|
@ -93,6 +93,20 @@ export default {
|
|||
saveAs: function(text, filename){
|
||||
var blob = new Blob([text], {type: "text/plain;charset=utf-8"});
|
||||
FileSaver.saveAs(blob, filename);
|
||||
},
|
||||
|
||||
// http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
|
||||
bytesToSize: function(bytes, decimals = 2){
|
||||
if(bytes == 0) return '0 byte';
|
||||
var k = 1000; // or 1024 for binary
|
||||
var dm = decimals || 3;
|
||||
var sizes = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
isMobile: function(){
|
||||
return navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(android)|(webOS)/i);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ export default {
|
|||
}).fail(error => {
|
||||
console.warn(error);
|
||||
if (errorCount++ < 10) setTimeout(() => check(), 2000);
|
||||
else cb(JSON.stringify(error));
|
||||
else cb(error.statusText);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -15,13 +15,6 @@ export default class ApiFactory{
|
|||
// are more robust as we can detect more easily if
|
||||
// things break
|
||||
|
||||
// TODO: we should consider refactoring this code
|
||||
// to use functions instead of events. Originally
|
||||
// we chose to use events because that would have
|
||||
// decreased coupling, but since all API pubsub activity
|
||||
// evolved to require a call to the PluginsAPI object, we might have
|
||||
// added a bunch of complexity for no real advantage here.
|
||||
|
||||
const addEndpoint = (obj, eventName, preTrigger = () => {}) => {
|
||||
const emitResponse = response => {
|
||||
// Timeout needed for modules that have no dependencies
|
||||
|
@ -99,6 +92,26 @@ export default class ApiFactory{
|
|||
obj = Object.assign(obj, api.helpers);
|
||||
}
|
||||
|
||||
// Handle syncronous function on/off/export
|
||||
(api.functions || []).forEach(func => {
|
||||
let callbacks = [];
|
||||
obj[func] = (...args) => {
|
||||
for (let i = 0; i < callbacks.length; i++){
|
||||
if ((callbacks[i])(...args)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onName = "on" + func[0].toUpperCase() + func.slice(1);
|
||||
const offName = "off" + func[0].toUpperCase() + func.slice(1);
|
||||
obj[onName] = f => {
|
||||
callbacks.push(f);
|
||||
};
|
||||
obj[offName] = f => {
|
||||
callbacks = callbacks.filter(cb => cb !== f);
|
||||
};
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,11 @@ export default {
|
|||
endpoints: [
|
||||
["willAddControls", leafletPreCheck],
|
||||
["didAddControls", layersControlPreCheck],
|
||||
["addActionButton", leafletPreCheck],
|
||||
["addActionButton", leafletPreCheck]
|
||||
],
|
||||
|
||||
functions: [
|
||||
"handleClick"
|
||||
]
|
||||
};
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ if (!Object.values) {
|
|||
}
|
||||
|
||||
// Do not apply to WebODM, can cause confusion
|
||||
const OPTS_BLACKLIST = ['build-overviews', 'ignore-gsd', 'orthophoto-no-tiled', 'orthophoto-compression', 'orthophoto-png', 'orthophoto-kmz', 'pc-copc', 'pc-las', 'pc-ply', 'pc-csv', 'pc-ept', 'cog'];
|
||||
const OPTS_BLACKLIST = ['build-overviews', 'orthophoto-no-tiled', 'orthophoto-compression', 'orthophoto-png', 'orthophoto-kmz', 'pc-copc', 'pc-las', 'pc-ply', 'pc-csv', 'pc-ept', 'cog', 'gltf'];
|
||||
|
||||
class EditPresetDialog extends React.Component {
|
||||
static defaultProps = {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import '../css/EditProjectDialog.scss';
|
||||
import FormDialog from './FormDialog';
|
||||
import PropTypes from 'prop-types';
|
||||
import ErrorMessage from './ErrorMessage';
|
||||
import EditPermissionsPanel from './EditPermissionsPanel';
|
||||
import TagsField from './TagsField';
|
||||
import { _ } from '../classes/gettext';
|
||||
|
||||
class EditProjectDialog extends React.Component {
|
||||
|
@ -10,11 +12,12 @@ class EditProjectDialog extends React.Component {
|
|||
projectName: "",
|
||||
projectDescr: "",
|
||||
projectId: -1,
|
||||
projectTags: [],
|
||||
title: _("New Project"),
|
||||
saveLabel: _("Create Project"),
|
||||
savingLabel: _("Creating project..."),
|
||||
saveIcon: "glyphicon glyphicon-plus",
|
||||
deleteWarning: _("All tasks, images and models associated with this project will be permanently deleted. Are you sure you want to continue?"),
|
||||
deleteWarning: "",
|
||||
show: false,
|
||||
showDuplicate: false,
|
||||
showPermissions: false,
|
||||
|
@ -25,6 +28,7 @@ class EditProjectDialog extends React.Component {
|
|||
projectName: PropTypes.string,
|
||||
projectDescr: PropTypes.string,
|
||||
projectId: PropTypes.number,
|
||||
projectTags: PropTypes.array,
|
||||
saveAction: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func,
|
||||
deleteAction: PropTypes.func,
|
||||
|
@ -46,7 +50,9 @@ class EditProjectDialog extends React.Component {
|
|||
name: props.projectName,
|
||||
descr: props.projectDescr !== null ? props.projectDescr : "",
|
||||
duplicating: false,
|
||||
error: ""
|
||||
tags: props.projectTags,
|
||||
error: "",
|
||||
showTagsField: !!props.projectTags.length
|
||||
};
|
||||
|
||||
this.reset = this.reset.bind(this);
|
||||
|
@ -60,6 +66,8 @@ class EditProjectDialog extends React.Component {
|
|||
name: this.props.projectName,
|
||||
descr: this.props.projectDescr,
|
||||
duplicating: false,
|
||||
tags: this.props.projectTags,
|
||||
showTagsField: !!this.props.projectTags.length,
|
||||
error: ""
|
||||
});
|
||||
}
|
||||
|
@ -68,6 +76,7 @@ class EditProjectDialog extends React.Component {
|
|||
const res = {
|
||||
name: this.state.name,
|
||||
descr: this.state.descr,
|
||||
tags: this.state.tags
|
||||
};
|
||||
|
||||
if (this.editPermissionsPanel){
|
||||
|
@ -128,7 +137,26 @@ class EditProjectDialog extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
toggleTagsField = () => {
|
||||
if (!this.state.showTagsField){
|
||||
setTimeout(() => {
|
||||
if (this.tagsField) this.tagsField.focus();
|
||||
}, 0);
|
||||
}
|
||||
this.setState({showTagsField: !this.state.showTagsField});
|
||||
}
|
||||
|
||||
render(){
|
||||
let tagsField = "";
|
||||
if (this.state.showTagsField){
|
||||
tagsField = (<div className="form-group">
|
||||
<label className="col-sm-2 control-label">{_("Tags")}</label>
|
||||
<div className="col-sm-10">
|
||||
<TagsField onUpdate={(tags) => this.state.tags = tags } tags={this.state.tags} ref={domNode => this.tagsField = domNode}/>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog {...this.props}
|
||||
getFormData={this.getFormData}
|
||||
|
@ -137,12 +165,16 @@ class EditProjectDialog extends React.Component {
|
|||
leftButtons={this.props.showDuplicate ? [<button key="duplicate" disabled={this.duplicating} onClick={this.handleDuplicate} className="btn btn-default"><i className={"fa " + (this.state.duplicating ? "fa-circle-notch fa-spin fa-fw" : "fa-copy")}></i> Duplicate</button>] : undefined}
|
||||
ref={(domNode) => { this.dialog = domNode; }}>
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
<div className="form-group">
|
||||
<div className="form-group edit-project-dialog">
|
||||
<label className="col-sm-2 control-label">{_("Name")}</label>
|
||||
<div className="col-sm-10">
|
||||
<input type="text" className="form-control" ref={(domNode) => { this.nameInput = domNode; }} value={this.state.name} onChange={this.handleChange('name')} />
|
||||
<div className="col-sm-10 name-fields">
|
||||
<input type="text" className="form-control" ref={(domNode) => { this.nameInput = domNode; }} value={this.state.name} onChange={this.handleChange('name')} onKeyPress={e => this.dialog.handleEnter(e)} />
|
||||
<button type="button" title={_("Add tags")} onClick={this.toggleTagsField} className="btn btn-sm btn-secondary toggle-tags">
|
||||
<i className="fa fa-tag"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tagsField}
|
||||
<div className="form-group">
|
||||
<label className="col-sm-2 control-label">{_("Description (optional)")}</label>
|
||||
<div className="col-sm-10">
|
||||
|
|
|
@ -5,6 +5,7 @@ import EditPresetDialog from './EditPresetDialog';
|
|||
import ErrorMessage from './ErrorMessage';
|
||||
import PropTypes from 'prop-types';
|
||||
import Storage from '../classes/Storage';
|
||||
import TagsField from './TagsField';
|
||||
import $ from 'jquery';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
|
||||
|
@ -45,14 +46,18 @@ class EditTaskForm extends React.Component {
|
|||
processingNodes: [],
|
||||
selectedPreset: null,
|
||||
presets: [],
|
||||
tags: props.task !== null ? Utils.clone(props.task.tags) : [],
|
||||
|
||||
editingPreset: false,
|
||||
|
||||
loadingTaskName: false
|
||||
loadingTaskName: false,
|
||||
|
||||
showTagsField: props.task !== null ? !!props.task.tags.length : false
|
||||
};
|
||||
|
||||
this.handleNameChange = this.handleNameChange.bind(this);
|
||||
this.handleSelectNode = this.handleSelectNode.bind(this);
|
||||
this.firstEnabledNode = this.firstEnabledNode.bind(this);
|
||||
this.loadProcessingNodes = this.loadProcessingNodes.bind(this);
|
||||
this.retryLoad = this.retryLoad.bind(this);
|
||||
this.selectNodeByKey = this.selectNodeByKey.bind(this);
|
||||
|
@ -80,10 +85,29 @@ class EditTaskForm extends React.Component {
|
|||
this.state.selectedPreset;
|
||||
}
|
||||
|
||||
checkFilesCount(filesCount){
|
||||
if (!this.state.selectedNode) return true;
|
||||
if (filesCount === 0) return true;
|
||||
if (this.state.selectedNode.max_images === null) return true;
|
||||
return this.state.selectedNode.max_images >= filesCount;
|
||||
}
|
||||
|
||||
selectedNodeMaxImages(){
|
||||
if (!this.state.selectedNode) return null;
|
||||
return this.state.selectedNode.max_images;
|
||||
}
|
||||
|
||||
notifyFormLoaded(){
|
||||
if (this.props.onFormLoaded && this.formReady()) this.props.onFormLoaded();
|
||||
}
|
||||
|
||||
firstEnabledNode(){
|
||||
for (let i = 0; i < this.state.processingNodes.length; i++){
|
||||
if (this.state.processingNodes[i].enabled) return this.state.processingNodes[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
loadProcessingNodes(){
|
||||
const failed = () => {
|
||||
this.setState({error: _("Could not load list of processing nodes. Are you connected to the internet?")});
|
||||
|
@ -103,8 +127,6 @@ class EditTaskForm extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
let now = new Date();
|
||||
|
||||
let nodes = json.map(node => {
|
||||
return {
|
||||
id: node.id,
|
||||
|
@ -112,41 +134,24 @@ class EditTaskForm extends React.Component {
|
|||
label: `${node.label} (queue: ${node.queue_count})`,
|
||||
options: node.available_options,
|
||||
queue_count: node.queue_count,
|
||||
max_images: node.max_images,
|
||||
enabled: node.online,
|
||||
url: `http://${node.hostname}:${node.port}`
|
||||
};
|
||||
});
|
||||
|
||||
let autoNode = null;
|
||||
// Find a node with lowest queue count
|
||||
let minQueueCount = Math.min(...nodes.filter(node => node.enabled).map(node => node.queue_count));
|
||||
let minQueueCountNodes = nodes.filter(node => node.enabled && node.queue_count === minQueueCount);
|
||||
|
||||
// If the user has selected auto, and a processing node has been assigned
|
||||
// we need attempt to find the "auto" node to be the one that has been assigned
|
||||
if (this.props.task && this.props.task.processing_node && this.props.task.auto_processing_node){
|
||||
autoNode = nodes.find(node => node.id === this.props.task.processing_node);
|
||||
if (minQueueCountNodes.length === 0){
|
||||
noProcessingNodesError(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoNode){
|
||||
// Find a node with lowest queue count
|
||||
let minQueueCount = Math.min(...nodes.filter(node => node.enabled).map(node => node.queue_count));
|
||||
let minQueueCountNodes = nodes.filter(node => node.enabled && node.queue_count === minQueueCount);
|
||||
|
||||
if (minQueueCountNodes.length === 0){
|
||||
noProcessingNodesError(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose at random
|
||||
autoNode = minQueueCountNodes[~~(Math.random() * minQueueCountNodes.length)];
|
||||
}
|
||||
|
||||
nodes.unshift({
|
||||
id: autoNode.id,
|
||||
key: "auto",
|
||||
label: "Auto",
|
||||
options: autoNode.options,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Choose at random
|
||||
let lowestQueueNode = minQueueCountNodes[~~(Math.random() * minQueueCountNodes.length)];
|
||||
|
||||
this.setState({
|
||||
processingNodes: nodes,
|
||||
loadedProcessingNodes: true
|
||||
|
@ -155,14 +160,14 @@ class EditTaskForm extends React.Component {
|
|||
// Have we specified a node?
|
||||
if (this.props.task && this.props.task.processing_node){
|
||||
if (this.props.task.auto_processing_node){
|
||||
this.selectNodeByKey("auto");
|
||||
this.selectNodeByKey(lowestQueueNode.key);
|
||||
}else{
|
||||
this.selectNodeByKey(this.props.task.processing_node);
|
||||
}
|
||||
}else if (this.props.selectedNode){
|
||||
this.selectNodeByKey(this.props.selectedNode);
|
||||
}else{
|
||||
this.selectNodeByKey("auto");
|
||||
this.selectNodeByKey(lowestQueueNode.key);
|
||||
}
|
||||
|
||||
this.notifyFormLoaded();
|
||||
|
@ -328,8 +333,11 @@ class EditTaskForm extends React.Component {
|
|||
let node = this.state.processingNodes.find(node => node.key == key);
|
||||
if (node) this.setState({selectedNode: node});
|
||||
else{
|
||||
console.warn(`Node ${key} does not exist, selecting auto`);
|
||||
this.selectNodeByKey("auto");
|
||||
console.log(`Node ${key} does not exist, selecting first enabled`);
|
||||
const n = this.firstEnabledNode();
|
||||
if (n){
|
||||
this.selectNodeByKey(n.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -361,12 +369,13 @@ class EditTaskForm extends React.Component {
|
|||
}
|
||||
|
||||
getTaskInfo(){
|
||||
const { name, selectedNode, selectedPreset } = this.state;
|
||||
const { name, selectedNode, selectedPreset, tags } = this.state;
|
||||
|
||||
return {
|
||||
name: name !== "" ? name : this.namePlaceholder,
|
||||
name: name !== "" ? name : this.state.namePlaceholder,
|
||||
selectedNode: selectedNode,
|
||||
options: this.getAvailableOptionsOnly(selectedPreset.options, selectedNode.options)
|
||||
options: this.getAvailableOptionsOnly(selectedPreset.options, selectedNode.options),
|
||||
tags
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -492,6 +501,15 @@ class EditTaskForm extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
toggleTagsField = () => {
|
||||
if (!this.state.showTagsField){
|
||||
setTimeout(() => {
|
||||
if (this.tagsField) this.tagsField.focus();
|
||||
}, 0);
|
||||
}
|
||||
this.setState({showTagsField: !this.state.showTagsField});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error){
|
||||
return (<div className="edit-task-panel">
|
||||
|
@ -520,10 +538,10 @@ class EditTaskForm extends React.Component {
|
|||
|
||||
{!this.state.presetActionPerforming ?
|
||||
<div className="btn-group presets-dropdown">
|
||||
<button type="button" className="btn btn-default" title={_("Edit Task Options")} onClick={this.handleEditPreset}>
|
||||
<button type="button" className="btn btn-sm btn-default" title={_("Edit Task Options")} onClick={this.handleEditPreset}>
|
||||
<i className="fa fa-sliders-h"></i> {_("Edit")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-default dropdown-toggle" data-toggle="dropdown">
|
||||
<button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
|
||||
<span className="caret"></span>
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
|
@ -550,8 +568,19 @@ class EditTaskForm extends React.Component {
|
|||
<ErrorMessage className="preset-error" bind={[this, 'presetError']} />
|
||||
</div>);
|
||||
|
||||
let tagsField = "";
|
||||
if (this.state.showTagsField){
|
||||
tagsField = (<div className="form-group">
|
||||
<label className="col-sm-2 control-label">{_("Tags")}</label>
|
||||
<div className="col-sm-10">
|
||||
<TagsField onUpdate={(tags) => this.state.tags = tags } tags={this.state.tags} ref={domNode => this.tagsField = domNode}/>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
taskOptions = (
|
||||
<div>
|
||||
{tagsField}
|
||||
<div className="form-group">
|
||||
<label className="col-sm-2 control-label">{_("Processing Node")}</label>
|
||||
<div className="col-sm-10">
|
||||
|
@ -595,7 +624,7 @@ class EditTaskForm extends React.Component {
|
|||
<div className="edit-task-form">
|
||||
<div className="form-group">
|
||||
<label className="col-sm-2 control-label">{_("Name")}</label>
|
||||
<div className="col-sm-10">
|
||||
<div className="col-sm-10 name-fields">
|
||||
{this.state.loadingTaskName ?
|
||||
<i className="fa fa-circle-notch fa-spin fa-fw name-loading"></i>
|
||||
: ""}
|
||||
|
@ -603,8 +632,12 @@ class EditTaskForm extends React.Component {
|
|||
onChange={this.handleNameChange}
|
||||
className="form-control"
|
||||
placeholder={this.state.namePlaceholder}
|
||||
value={this.state.name}
|
||||
value={this.state.name}
|
||||
/>
|
||||
<button type="button" title={_("Add tags")} onClick={this.toggleTagsField} className="btn btn-sm btn-secondary toggle-tags">
|
||||
<i className="fa fa-tag"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{taskOptions}
|
||||
|
|
|
@ -53,7 +53,7 @@ class EditTaskPanel extends React.Component {
|
|||
this.setState({saving: false});
|
||||
this.props.onSave(json);
|
||||
}).fail(() => {
|
||||
this.setState({saving: false, error: _("Could not update task information. Plese try again.")});
|
||||
this.setState({saving: false, error: _("Could not update task information. Please try again.")});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -102,6 +102,12 @@ class FormDialog extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleEnter = e => {
|
||||
if (e.key === 'Enter' || e.keyCode === 13){
|
||||
this.handleSave(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleSave(e){
|
||||
e.preventDefault();
|
||||
|
||||
|
@ -138,7 +144,7 @@ class FormDialog extends React.Component {
|
|||
|
||||
handleDelete(){
|
||||
if (this.props.deleteAction){
|
||||
if (this.props.deleteWarning === false || window.confirm(this.props.deleteWarning)){
|
||||
if (!this.props.deleteWarning || window.confirm(this.props.deleteWarning)){
|
||||
this.setState({deleting: true});
|
||||
this.props.deleteAction()
|
||||
.fail(e => {
|
||||
|
|
|
@ -3,20 +3,29 @@ import PropTypes from 'prop-types';
|
|||
import '../css/Histogram.scss';
|
||||
import d3 from 'd3';
|
||||
import { _ } from '../classes/gettext';
|
||||
import { onUnitSystemChanged, offUnitSystemChanged } from '../classes/Units';
|
||||
|
||||
export default class Histogram extends React.Component {
|
||||
static defaultProps = {
|
||||
width: 280,
|
||||
colorMap: null,
|
||||
unitForward: value => value,
|
||||
unitBackward: value => value,
|
||||
onUpdate: null,
|
||||
loading: false,
|
||||
min: null,
|
||||
max: null
|
||||
};
|
||||
static propTypes = {
|
||||
statistics: PropTypes.object.isRequired,
|
||||
colorMap: PropTypes.array,
|
||||
unitForward: PropTypes.func,
|
||||
unitBackward: PropTypes.func,
|
||||
width: PropTypes.number,
|
||||
onUpdate: PropTypes.func,
|
||||
loading: PropTypes.bool
|
||||
loading: PropTypes.bool,
|
||||
min: PropTypes.number,
|
||||
max: PropTypes.number
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
|
@ -53,9 +62,19 @@ export default class Histogram extends React.Component {
|
|||
this.rangeX = [minX, maxX];
|
||||
this.rangeY = [minY, maxY];
|
||||
|
||||
let min = minX;
|
||||
let max = maxX;
|
||||
|
||||
if (this.props.min !== null && this.props.max !== null){
|
||||
min = this.props.min;
|
||||
max = this.props.max;
|
||||
}
|
||||
|
||||
const st = {
|
||||
min: minX.toFixed(3),
|
||||
max: maxX.toFixed(3)
|
||||
min: min,
|
||||
max: max,
|
||||
minInput: this.props.unitForward(min).toFixed(3),
|
||||
maxInput: this.props.unitForward(max).toFixed(3)
|
||||
};
|
||||
|
||||
if (!this.state){
|
||||
|
@ -99,11 +118,14 @@ export default class Histogram extends React.Component {
|
|||
let x = d3.scale.linear()
|
||||
.domain(this.rangeX)
|
||||
.range([0, width]);
|
||||
let tickFormat = x => {
|
||||
return this.props.unitForward(x).toFixed(0);
|
||||
};
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis theme-fill-primary")
|
||||
.attr("transform", "translate(0," + (height - 5) + ")")
|
||||
.call(d3.svg.axis().scale(x).tickValues(this.rangeX).orient("bottom"));
|
||||
.call(d3.svg.axis().scale(x).tickValues(this.rangeX).tickFormat(tickFormat).orient("bottom"));
|
||||
|
||||
// add the y Axis
|
||||
let y = d3.scale.linear()
|
||||
|
@ -181,7 +203,7 @@ export default class Histogram extends React.Component {
|
|||
maxLine.setAttribute('x2', newX);
|
||||
|
||||
if (prevX !== newX){
|
||||
self.setState({max: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX).toFixed(3)});
|
||||
self.setState({max: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX)});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -199,7 +221,7 @@ export default class Histogram extends React.Component {
|
|||
minLine.setAttribute('x2', newX);
|
||||
|
||||
if (prevX !== newX){
|
||||
self.setState({min: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX).toFixed(3)});
|
||||
self.setState({min: (self.rangeX[0] + ((self.rangeX[1] - self.rangeX[0]) / width) * newX)});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -232,9 +254,29 @@ export default class Histogram extends React.Component {
|
|||
|
||||
componentDidMount(){
|
||||
this.redraw();
|
||||
onUnitSystemChanged(this.handleUnitSystemChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
offUnitSystemChanged(this.handleUnitSystemChanged);
|
||||
}
|
||||
|
||||
handleUnitSystemChanged = e => {
|
||||
this.redraw();
|
||||
this.setState({
|
||||
minInput: this.props.unitForward(this.state.min).toFixed(3),
|
||||
maxInput: this.props.unitForward(this.state.max).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState){
|
||||
if (prevState.min !== this.state.min || prevState.max !== this.state.max){
|
||||
this.setState({
|
||||
minInput: this.props.unitForward(this.state.min).toFixed(3),
|
||||
maxInput: this.props.unitForward(this.state.max).toFixed(3)
|
||||
});
|
||||
}
|
||||
|
||||
if (prevState.min !== this.state.min ||
|
||||
prevState.max !== this.state.max ||
|
||||
prevProps.colorMap !== this.props.colorMap ||
|
||||
|
@ -271,27 +313,45 @@ export default class Histogram extends React.Component {
|
|||
}
|
||||
|
||||
handleChangeMax = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
this.setState({maxInput: e.target.value});
|
||||
}
|
||||
|
||||
if (val >= this.state.min && val <= this.rangeX[1]){
|
||||
this.setState({max: val});
|
||||
handleMaxBlur = (e) => {
|
||||
let val = parseFloat(e.target.value);
|
||||
if (!isNaN(val)){
|
||||
val = this.props.unitBackward(val);
|
||||
val = Math.max(this.state.min, Math.min(this.rangeX[1], val));
|
||||
this.setState({max: val, maxInput: val.toFixed(3)});
|
||||
}
|
||||
}
|
||||
|
||||
handleChangeMin = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
handleMaxKeyDown = (e) => {
|
||||
if (e.key === 'Enter') this.handleMaxBlur(e);
|
||||
}
|
||||
|
||||
if (val <= this.state.max && val >= this.rangeX[0]){
|
||||
this.setState({min: val});
|
||||
handleChangeMin = (e) => {
|
||||
this.setState({minInput: e.target.value});
|
||||
}
|
||||
|
||||
handleMinBlur = (e) => {
|
||||
let val = parseFloat(e.target.value);
|
||||
if (!isNaN(val)){
|
||||
val = this.props.unitBackward(val);
|
||||
val = Math.max(this.rangeX[0], Math.min(this.state.max, val));
|
||||
this.setState({min: val, minInput: val.toFixed(3)});
|
||||
}
|
||||
};
|
||||
|
||||
handleMinKeyDown = (e) => {
|
||||
if (e.key === 'Enter') this.handleMinBlur(e);
|
||||
}
|
||||
|
||||
render(){
|
||||
return (<div className={"histogram " + (this.props.loading ? "disabled" : "")}>
|
||||
<div ref={(domNode) => { this.hgContainer = domNode; }}>
|
||||
</div>
|
||||
<label>{_("Min:")}</label> <input onChange={this.handleChangeMin} type="number" className="form-control min-max" size={5} value={this.state.min} />
|
||||
<label>{_("Max:")}</label> <input onChange={this.handleChangeMax} type="number" className="form-control min-max" size={5} value={this.state.max} />
|
||||
<label>{_("Min:")}</label> <input onKeyDown={this.handleMinKeyDown} onBlur={this.handleMinBlur} onChange={this.handleChangeMin} type="number" className="form-control min-max" size={5} value={this.state.minInput} />
|
||||
<label>{_("Max:")}</label> <input onKeyDown={this.handleMaxKeyDown} onBlur={this.handleMaxBlur} onChange={this.handleChangeMax} type="number" className="form-control min-max" size={5} value={this.state.maxInput} />
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,8 @@ class ImportTaskPanel extends React.Component {
|
|||
clickable: this.uploadButton,
|
||||
chunkSize: 2147483647,
|
||||
timeout: 2147483647,
|
||||
|
||||
chunking: true,
|
||||
chunkSize: 16000000, // 16MB
|
||||
headers: {
|
||||
[csrf.header]: csrf.token
|
||||
}
|
||||
|
@ -69,6 +70,7 @@ class ImportTaskPanel extends React.Component {
|
|||
this.setState({uploading: false, progress: 0, totalBytes: 0, totalBytesSent: 0});
|
||||
})
|
||||
.on("uploadprogress", (file, progress, bytesSent) => {
|
||||
if (progress == 100) return; // Workaround for chunked upload progress bar jumping around
|
||||
this.setState({
|
||||
progress,
|
||||
totalBytes: file.size,
|
||||
|
|
|
@ -65,7 +65,6 @@ export default class LayersControlLayer extends React.Component {
|
|||
exportLoading: false,
|
||||
error: ""
|
||||
};
|
||||
|
||||
this.rescale = params.rescale || "";
|
||||
}
|
||||
|
||||
|
@ -134,7 +133,7 @@ export default class LayersControlLayer extends React.Component {
|
|||
|
||||
// Check if bands need to be switched
|
||||
const algo = this.getAlgorithm(e.target.value);
|
||||
if (algo && algo['filters'].indexOf(bands) === -1) bands = algo['filters'][0]; // Pick first
|
||||
if (algo && algo['filters'].indexOf(bands) === -1 && bands !== "auto") bands = algo['filters'][0]; // Pick first
|
||||
|
||||
this.setState({formula: e.target.value, bands});
|
||||
}
|
||||
|
@ -170,7 +169,14 @@ export default class LayersControlLayer extends React.Component {
|
|||
// Update rescale values
|
||||
const { statistics } = this.tmeta;
|
||||
if (statistics && statistics["1"]){
|
||||
this.rescale = `${statistics["1"]["min"]},${statistics["1"]["max"]}`;
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["percentiles"][0]);
|
||||
max = Math.max(statistics[b]["percentiles"][1]);
|
||||
}
|
||||
this.rescale = `${min},${max}`;
|
||||
}
|
||||
|
||||
this.updateLayer();
|
||||
|
@ -262,7 +268,7 @@ export default class LayersControlLayer extends React.Component {
|
|||
render(){
|
||||
const { colorMap, bands, hillshade, formula, histogramLoading, exportLoading } = this.state;
|
||||
const { meta, tmeta } = this;
|
||||
const { color_maps, algorithms } = tmeta;
|
||||
const { color_maps, algorithms, auto_bands } = tmeta;
|
||||
const algo = this.getAlgorithm(formula);
|
||||
|
||||
let cmapValues = null;
|
||||
|
@ -270,6 +276,16 @@ export default class LayersControlLayer extends React.Component {
|
|||
cmapValues = (color_maps.find(c => c.key === colorMap) || {}).color_map;
|
||||
}
|
||||
|
||||
let hmin = null;
|
||||
let hmax = null;
|
||||
if (this.rescale){
|
||||
let parts = decodeURIComponent(this.rescale).split(",");
|
||||
if (parts.length === 2 && parts[0] && parts[1]){
|
||||
hmin = parseFloat(parts[0]);
|
||||
hmax = parseFloat(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return (<div className="layers-control-layer">
|
||||
{!this.props.overlay ? <ExpandButton bind={[this, 'expanded']} /> : <div className="overlayIcon"><i className={meta.icon || "fa fa-vector-square fa-fw"}></i></div>}<Checkbox bind={[this, 'visible']}/>
|
||||
<a title={meta.name} className="layer-label" href="javascript:void(0);" onClick={this.handleLayerClick}>{meta.name}</a>
|
||||
|
@ -278,8 +294,12 @@ export default class LayersControlLayer extends React.Component {
|
|||
<div className="layer-expanded">
|
||||
<Histogram width={274}
|
||||
loading={histogramLoading}
|
||||
statistics={tmeta.statistics}
|
||||
statistics={tmeta.statistics}
|
||||
unitForward={meta.unitForward}
|
||||
unitBackward={meta.unitBackward}
|
||||
colorMap={cmapValues}
|
||||
min={hmin}
|
||||
max={hmax}
|
||||
onUpdate={this.handleHistogramUpdate} />
|
||||
|
||||
<ErrorMessage bind={[this, "error"]} />
|
||||
|
@ -298,13 +318,17 @@ export default class LayersControlLayer extends React.Component {
|
|||
|
||||
{bands !== "" && algo ?
|
||||
<div className="row form-group form-inline">
|
||||
<label className="col-sm-3 control-label">{_("Filter:")}</label>
|
||||
<label className="col-sm-3 control-label">{_("Bands:")}</label>
|
||||
<div className="col-sm-9 ">
|
||||
{histogramLoading ?
|
||||
<i className="fa fa-circle-notch fa-spin fa-fw" /> :
|
||||
<select className="form-control" value={bands} onChange={this.handleSelectBands}>
|
||||
[<select key="sel" className="form-control" value={bands} onChange={this.handleSelectBands} title={auto_bands.filter !== "" && bands == "auto" ? auto_bands.filter : ""}>
|
||||
<option key="auto" value="auto">{_("Automatic")}</option>
|
||||
{algo.filters.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>}
|
||||
</select>,
|
||||
bands == "auto" && !auto_bands.match ?
|
||||
<i key="ico" style={{marginLeft: '4px'}} title={interpolate(_("Not every band for %(name)s could be automatically identified."), {name: algo.id}) + "\n" + _("Your sensor might not have the proper bands for using this algorithm.")} className="fa fa-exclamation-circle info-button"></i>
|
||||
: ""]}
|
||||
</div>
|
||||
</div> : ""}
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ import '../css/Map.scss';
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import Leaflet from 'leaflet';
|
||||
import async from 'async';
|
||||
import '../vendor/leaflet/L.Control.MousePosition.css';
|
||||
import '../vendor/leaflet/L.Control.MousePosition';
|
||||
import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css';
|
||||
import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers';
|
||||
// import '../vendor/leaflet/L.TileLayer.NoGap';
|
||||
|
@ -26,8 +24,11 @@ import LayersControl from './LayersControl';
|
|||
import update from 'immutability-helper';
|
||||
import Utils from '../classes/Utils';
|
||||
import '../vendor/leaflet/Leaflet.Ajax';
|
||||
import '../vendor/leaflet/Leaflet.Awesome-markers';
|
||||
import 'rbush';
|
||||
import '../vendor/leaflet/leaflet-markers-canvas';
|
||||
import { _ } from '../classes/gettext';
|
||||
import UnitSelector from './UnitSelector';
|
||||
import { unitSystem, toMetric } from '../classes/Units';
|
||||
|
||||
class Map extends React.Component {
|
||||
static defaultProps = {
|
||||
|
@ -93,6 +94,16 @@ class Map extends React.Component {
|
|||
return "";
|
||||
}
|
||||
|
||||
hasBands = (bands, orthophoto_bands) => {
|
||||
if (!orthophoto_bands) return false;
|
||||
|
||||
for (let i = 0; i < bands.length; i++){
|
||||
if (orthophoto_bands.find(b => b.description !== null && b.description.toLowerCase() === bands[i].toLowerCase()) === undefined) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
loadImageryLayers(forceAddLayers = false){
|
||||
// Cancel previous requests
|
||||
if (this.tileJsonRequests) {
|
||||
|
@ -124,9 +135,30 @@ class Map extends React.Component {
|
|||
const { url, meta, type } = tile;
|
||||
|
||||
let metaUrl = url + "metadata";
|
||||
let unitForward = value => value;
|
||||
let unitBackward = value => value;
|
||||
|
||||
if (type == "plant") metaUrl += "?formula=NDVI&bands=RGN&color_map=rdylgn";
|
||||
if (type == "dsm" || type == "dtm") metaUrl += "?hillshade=6&color_map=viridis";
|
||||
if (type == "plant"){
|
||||
if (meta.task && meta.task.orthophoto_bands && meta.task.orthophoto_bands.length === 2){
|
||||
// Single band, probably thermal dataset, in any case we can't render NDVI
|
||||
// because it requires 3 bands
|
||||
metaUrl += "?formula=Celsius&bands=L&color_map=magma";
|
||||
}else if (meta.task && meta.task.orthophoto_bands){
|
||||
let formula = this.hasBands(["red", "green", "nir"], meta.task.orthophoto_bands) ? "NDVI" : "VARI";
|
||||
metaUrl += `?formula=${formula}&bands=auto&color_map=rdylgn`;
|
||||
}else{
|
||||
// This should never happen?
|
||||
metaUrl += "?formula=NDVI&bands=RGN&color_map=rdylgn";
|
||||
}
|
||||
}else if (type == "dsm" || type == "dtm"){
|
||||
metaUrl += "?hillshade=6&color_map=viridis";
|
||||
unitForward = value => {
|
||||
return unitSystem().length(value, { fixedUnit: true }).value;
|
||||
};
|
||||
unitBackward = value => {
|
||||
return toMetric(value).value;
|
||||
};
|
||||
}
|
||||
|
||||
this.tileJsonRequests.push($.getJSON(metaUrl)
|
||||
.done(mres => {
|
||||
|
@ -145,7 +177,22 @@ class Map extends React.Component {
|
|||
const params = Utils.queryParams({search: tileUrl.slice(tileUrl.indexOf("?"))});
|
||||
if (statistics["1"]){
|
||||
// Add rescale
|
||||
params["rescale"] = encodeURIComponent(`${statistics["1"]["min"]},${statistics["1"]["max"]}`);
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
if (type === 'plant'){
|
||||
// percentile
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["percentiles"][0]);
|
||||
max = Math.max(statistics[b]["percentiles"][1]);
|
||||
}
|
||||
}else{
|
||||
// min/max
|
||||
for (let b in statistics){
|
||||
min = Math.min(statistics[b]["min"]);
|
||||
max = Math.max(statistics[b]["max"]);
|
||||
}
|
||||
}
|
||||
params["rescale"] = encodeURIComponent(`${min},${max}`);
|
||||
}else{
|
||||
console.warn("Cannot find min/max statistics for dataset, setting to -1,1");
|
||||
params["rescale"] = encodeURIComponent("-1,1");
|
||||
|
@ -171,6 +218,8 @@ class Map extends React.Component {
|
|||
// Associate metadata with this layer
|
||||
meta.name = name + ` (${this.typeToHuman(type)})`;
|
||||
meta.metaUrl = metaUrl;
|
||||
meta.unitForward = unitForward;
|
||||
meta.unitBackward = unitBackward;
|
||||
layer[Symbol.for("meta")] = meta;
|
||||
layer[Symbol.for("tile-meta")] = mres;
|
||||
|
||||
|
@ -228,41 +277,45 @@ class Map extends React.Component {
|
|||
// Add camera shots layer if available
|
||||
if (meta.task && meta.task.camera_shots && !this.addedCameraShots){
|
||||
|
||||
const shotsLayer = new L.GeoJSON.AJAX(meta.task.camera_shots, {
|
||||
style: function (feature) {
|
||||
return {
|
||||
opacity: 1,
|
||||
fillOpacity: 0.7,
|
||||
color: "#000000"
|
||||
}
|
||||
},
|
||||
pointToLayer: function (feature, latlng) {
|
||||
return new L.CircleMarker(latlng, {
|
||||
color: '#3498db',
|
||||
fillColor: '#3498db',
|
||||
fillOpacity: 0.9,
|
||||
radius: 10,
|
||||
weight: 1
|
||||
});
|
||||
},
|
||||
onEachFeature: function (feature, layer) {
|
||||
if (feature.properties && feature.properties.filename) {
|
||||
let root = null;
|
||||
const lazyrender = () => {
|
||||
if (!root) root = document.createElement("div");
|
||||
ReactDOM.render(<ImagePopup task={meta.task} feature={feature}/>, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
layer.bindPopup(L.popup(
|
||||
{
|
||||
lazyrender,
|
||||
maxHeight: 450,
|
||||
minWidth: 320
|
||||
}));
|
||||
}
|
||||
}
|
||||
var camIcon = L.icon({
|
||||
iconUrl: "/static/app/js/icons/marker-camera.png",
|
||||
iconSize: [41, 46],
|
||||
iconAnchor: [17, 46],
|
||||
});
|
||||
|
||||
const shotsLayer = new L.MarkersCanvas();
|
||||
$.getJSON(meta.task.camera_shots)
|
||||
.done((shots) => {
|
||||
if (shots.type === 'FeatureCollection'){
|
||||
let markers = [];
|
||||
|
||||
shots.features.forEach(s => {
|
||||
let marker = L.marker(
|
||||
[s.geometry.coordinates[1], s.geometry.coordinates[0]],
|
||||
{ icon: camIcon }
|
||||
);
|
||||
markers.push(marker);
|
||||
|
||||
if (s.properties && s.properties.filename){
|
||||
let root = null;
|
||||
const lazyrender = () => {
|
||||
if (!root) root = document.createElement("div");
|
||||
ReactDOM.render(<ImagePopup task={meta.task} feature={s}/>, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
marker.bindPopup(L.popup(
|
||||
{
|
||||
lazyrender,
|
||||
maxHeight: 450,
|
||||
minWidth: 320
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
shotsLayer.addMarkers(markers, this.map);
|
||||
}
|
||||
});
|
||||
shotsLayer[Symbol.for("meta")] = {name: name + " " + _("(Cameras)"), icon: "fa fa-camera fa-fw"};
|
||||
|
||||
this.setState(update(this.state, {
|
||||
|
@ -274,44 +327,45 @@ class Map extends React.Component {
|
|||
|
||||
// Add ground control points layer if available
|
||||
if (meta.task && meta.task.ground_control_points && !this.addedGroundControlPoints){
|
||||
const gcpMarker = L.AwesomeMarkers.icon({
|
||||
icon: 'dot-circle',
|
||||
markerColor: 'blue',
|
||||
prefix: 'fa'
|
||||
const gcpIcon = L.icon({
|
||||
iconUrl: "/static/app/js/icons/marker-gcp.png",
|
||||
iconSize: [41, 46],
|
||||
iconAnchor: [17, 46],
|
||||
});
|
||||
|
||||
const gcpLayer = new L.MarkersCanvas();
|
||||
$.getJSON(meta.task.ground_control_points)
|
||||
.done((gcps) => {
|
||||
if (gcps.type === 'FeatureCollection'){
|
||||
let markers = [];
|
||||
|
||||
const gcpLayer = new L.GeoJSON.AJAX(meta.task.ground_control_points, {
|
||||
style: function (feature) {
|
||||
return {
|
||||
opacity: 1,
|
||||
fillOpacity: 0.7,
|
||||
color: "#000000"
|
||||
}
|
||||
},
|
||||
pointToLayer: function (feature, latlng) {
|
||||
return new L.marker(latlng, {
|
||||
icon: gcpMarker
|
||||
});
|
||||
},
|
||||
onEachFeature: function (feature, layer) {
|
||||
if (feature.properties && feature.properties.observations) {
|
||||
// TODO!
|
||||
let root = null;
|
||||
const lazyrender = () => {
|
||||
gcps.features.forEach(gcp => {
|
||||
let marker = L.marker(
|
||||
[gcp.geometry.coordinates[1], gcp.geometry.coordinates[0]],
|
||||
{ icon: gcpIcon }
|
||||
);
|
||||
markers.push(marker);
|
||||
|
||||
if (gcp.properties && gcp.properties.observations){
|
||||
let root = null;
|
||||
const lazyrender = () => {
|
||||
if (!root) root = document.createElement("div");
|
||||
ReactDOM.render(<GCPPopup task={meta.task} feature={feature}/>, root);
|
||||
ReactDOM.render(<GCPPopup task={meta.task} feature={gcp}/>, root);
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
layer.bindPopup(L.popup(
|
||||
{
|
||||
lazyrender,
|
||||
maxHeight: 450,
|
||||
minWidth: 320
|
||||
}));
|
||||
marker.bindPopup(L.popup(
|
||||
{
|
||||
lazyrender,
|
||||
maxHeight: 450,
|
||||
minWidth: 320
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
gcpLayer.addMarkers(markers, this.map);
|
||||
}
|
||||
});
|
||||
});
|
||||
gcpLayer[Symbol.for("meta")] = {name: name + " " + _("(GCPs)"), icon: "far fa-dot-circle fa-fw"};
|
||||
|
||||
this.setState(update(this.state, {
|
||||
|
@ -341,7 +395,7 @@ class Map extends React.Component {
|
|||
|
||||
this.map = Leaflet.map(this.container, {
|
||||
scrollWheelZoom: true,
|
||||
positionControl: true,
|
||||
positionControl: false,
|
||||
zoomControl: false,
|
||||
minZoom: 0,
|
||||
maxZoom: 24
|
||||
|
@ -353,12 +407,23 @@ class Map extends React.Component {
|
|||
|
||||
PluginsAPI.Map.triggerWillAddControls({
|
||||
map: this.map,
|
||||
tiles
|
||||
tiles,
|
||||
mapView: this
|
||||
});
|
||||
|
||||
let scaleControl = Leaflet.control.scale({
|
||||
maxWidth: 250,
|
||||
}).addTo(this.map);
|
||||
const UnitsCtrl = Leaflet.Control.extend({
|
||||
options: {
|
||||
position: 'bottomleft'
|
||||
},
|
||||
|
||||
onAdd: function () {
|
||||
this.container = Leaflet.DomUtil.create('div', 'leaflet-control-units-selection leaflet-control');
|
||||
Leaflet.DomEvent.disableClickPropagation(this.container);
|
||||
ReactDOM.render(<UnitSelector />, this.container);
|
||||
return this.container;
|
||||
}
|
||||
});
|
||||
new UnitsCtrl().addTo(this.map);
|
||||
|
||||
//add zoom control with your options
|
||||
let zoomControl = Leaflet.control.zoom({
|
||||
|
@ -384,12 +449,14 @@ class Map extends React.Component {
|
|||
|
||||
const customLayer = L.layerGroup();
|
||||
customLayer.on("add", a => {
|
||||
const defaultCustomBm = window.localStorage.getItem('lastCustomBasemap') || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
let url = window.prompt([_('Enter a tile URL template. Valid coordinates are:'),
|
||||
_('{z}, {x}, {y} for Z/X/Y tile scheme'),
|
||||
_('{-y} for flipped TMS-style Y coordinates'),
|
||||
'',
|
||||
_('Example:'),
|
||||
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'].join("\n"), 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png');
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png'].join("\n"), defaultCustomBm);
|
||||
|
||||
if (url){
|
||||
customLayer.clearLayers();
|
||||
|
@ -400,6 +467,7 @@ _('Example:'),
|
|||
});
|
||||
customLayer.addLayer(l);
|
||||
l.bringToBack();
|
||||
window.localStorage.setItem('lastCustomBasemap', url);
|
||||
}
|
||||
});
|
||||
this.basemaps[_("Custom")] = customLayer;
|
||||
|
@ -464,7 +532,11 @@ _('Example:'),
|
|||
});
|
||||
new AddOverlayCtrl().addTo(this.map);
|
||||
|
||||
this.map.fitWorld();
|
||||
this.map.fitBounds([
|
||||
[13.772919746115805,
|
||||
45.664640939831735],
|
||||
[13.772825784981254,
|
||||
45.664591558975154]]);
|
||||
this.map.attributionControl.setPrefix("");
|
||||
|
||||
this.setState({showLoading: true});
|
||||
|
@ -473,6 +545,8 @@ _('Example:'),
|
|||
this.map.fitBounds(this.mapBounds);
|
||||
|
||||
this.map.on('click', e => {
|
||||
if (PluginsAPI.Map.handleClick(e)) return;
|
||||
|
||||
// Find first tile layer at the selected coordinates
|
||||
for (let layer of this.state.imageryLayers){
|
||||
if (layer._map && layer.options.bounds.contains(e.latlng)){
|
||||
|
@ -526,7 +600,6 @@ _('Example:'),
|
|||
tiles: tiles,
|
||||
controls:{
|
||||
autolayers: this.autolayers,
|
||||
scale: scaleControl,
|
||||
zoom: zoomControl
|
||||
}
|
||||
});
|
||||
|
@ -576,7 +649,7 @@ _('Example:'),
|
|||
<div style={{height: "100%"}} className="map">
|
||||
<ErrorMessage bind={[this, 'error']} />
|
||||
<div className="opacity-slider theme-secondary hidden-xs">
|
||||
{_("Opacity:")} <input type="range" step="1" value={this.state.opacity} onChange={this.updateOpacity} />
|
||||
<div className="opacity-slider-label">{_("Opacity:")}</div> <input type="range" step="1" value={this.state.opacity} onChange={this.updateOpacity} />
|
||||
</div>
|
||||
|
||||
<Standby
|
||||
|
@ -597,6 +670,7 @@ _('Example:'),
|
|||
ref={(ref) => { this.shareButton = ref; }}
|
||||
task={this.state.singleTask}
|
||||
linksTarget="map"
|
||||
queryParams={{t: this.props.mapType}}
|
||||
/>
|
||||
: ""}
|
||||
<SwitchModeButton
|
||||
|
|
|
@ -124,11 +124,24 @@ class NewTaskPanel extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
let filesCountOk = true;
|
||||
if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false;
|
||||
|
||||
return (
|
||||
<div className="new-task-panel theme-background-highlight">
|
||||
<div className="form-horizontal">
|
||||
<div className={this.state.inReview ? "disabled" : ""}>
|
||||
<p>{interpolate(_("%(count)s files selected. Please check these additional options:"), { count: this.props.filesCount})}</p>
|
||||
|
||||
{!filesCountOk ?
|
||||
<div className="alert alert-warning">
|
||||
{interpolate(_("Number of files selected exceeds the maximum of %(count)s allowed on this processing node."), { count: this.taskForm.selectedNodeMaxImages() })}
|
||||
<button onClick={this.props.onCancel} type="button" className="btn btn-xs btn-primary redo">
|
||||
<span><i className="glyphicon glyphicon-remove-circle"></i> {_("Cancel")}</span>
|
||||
</button>
|
||||
</div>
|
||||
: ""}
|
||||
|
||||
<EditTaskForm
|
||||
selectedNode={Storage.getItem("last_processing_node") || "auto"}
|
||||
onFormLoaded={this.handleFormTaskLoaded}
|
||||
|
@ -186,7 +199,7 @@ class NewTaskPanel extends React.Component {
|
|||
{this.state.loading ?
|
||||
<button type="submit" className="btn btn-primary" disabled={true}><i className="fa fa-circle-notch fa-spin fa-fw"></i>{_("Loading…")}</button>
|
||||
:
|
||||
<button type="submit" className="btn btn-primary" onClick={this.save} disabled={this.props.filesCount <= 1}><i className="glyphicon glyphicon-saved"></i> {!this.state.inReview ? _("Review") : _("Start Processing")}</button>
|
||||
<button type="submit" className="btn btn-primary" onClick={this.save} disabled={this.props.filesCount < 1 || !filesCountOk}><i className="glyphicon glyphicon-saved"></i> {!this.state.inReview ? _("Review") : _("Start Processing")}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,45 +1,190 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import '../css/Paginator.scss';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import SortPanel from './SortPanel';
|
||||
import Utils from '../classes/Utils';
|
||||
import { _ } from '../classes/gettext';
|
||||
|
||||
let decodeSearch = (search) => {
|
||||
return window.decodeURI(search.replace(/:/g, "#"));
|
||||
};
|
||||
|
||||
class Paginator extends React.Component {
|
||||
render() {
|
||||
const { itemsPerPage, totalItems, currentPage } = this.props;
|
||||
let paginator = null;
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
if (itemsPerPage && itemsPerPage && totalItems > itemsPerPage){
|
||||
const numPages = Math.ceil(totalItems / itemsPerPage),
|
||||
pages = [...Array(numPages).keys()]; // [0, 1, 2, ...numPages]
|
||||
|
||||
paginator = (
|
||||
<div className={this.props.className}>
|
||||
<ul className="pagination pagination-sm">
|
||||
<li className={currentPage === 1 ? "disabled" : ""}>
|
||||
<Link to={{search: "?page=1"}}>
|
||||
<span>«</span>
|
||||
</Link>
|
||||
</li>
|
||||
{pages.map(page => {
|
||||
return (<li
|
||||
key={page + 1}
|
||||
className={currentPage === (page + 1) ? "active" : ""}
|
||||
><Link to={{search: "?page=" + (page + 1)}}>{page + 1}</Link></li>);
|
||||
})}
|
||||
<li className={currentPage === numPages ? "disabled" : ""}>
|
||||
<Link to={{search: "?page=" + numPages}}>
|
||||
<span>»</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
const q = Utils.queryParams(props.location);
|
||||
|
||||
this.state = {
|
||||
searchText: decodeSearch(q.search || ""),
|
||||
sortKey: q.ordering || "-created_at"
|
||||
}
|
||||
|
||||
return (<div>
|
||||
{paginator}
|
||||
{this.props.children}
|
||||
{paginator}
|
||||
</div>);
|
||||
this.sortItems = [{
|
||||
key: "created_at",
|
||||
label: _("Created on")
|
||||
},{
|
||||
key: "name",
|
||||
label: _("Name")
|
||||
},{
|
||||
key: "tags",
|
||||
label: _("Tags")
|
||||
},{
|
||||
key: "owner",
|
||||
label: _("Owner")
|
||||
}];
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
document.addEventListener("onProjectListTagClicked", this.addTagAndSearch);
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
document.removeEventListener("onProjectListTagClicked", this.addTagAndSearch);
|
||||
}
|
||||
|
||||
closeSearch = () => {
|
||||
this.searchContainer.classList.remove("open");
|
||||
}
|
||||
|
||||
toggleSearch = e => {
|
||||
e.stopPropagation();
|
||||
setTimeout(() => {
|
||||
this.searchInput.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
handleSearchChange = e => {
|
||||
this.setState({searchText: e.target.value});
|
||||
}
|
||||
|
||||
handleSearchKeyDown = e => {
|
||||
if (e.key === "Enter"){
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
search = () => {
|
||||
this.props.history.push({search: this.getQueryForPage(1)});
|
||||
this.closeSearch();
|
||||
}
|
||||
|
||||
clearSearch = () => {
|
||||
this.setState({searchText: ""});
|
||||
setTimeout(() => {
|
||||
this.search();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
sortChanged = key => {
|
||||
this.setState({sortKey: key});
|
||||
setTimeout(() => {
|
||||
this.props.history.push({search: this.getQueryForPage(this.props.currentPage)});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getQueryForPage = (num) => {
|
||||
return Utils.toSearchQuery({
|
||||
page: num,
|
||||
ordering: this.state.sortKey,
|
||||
search: this.state.searchText.replace(/#/g, ":")
|
||||
});
|
||||
}
|
||||
|
||||
addTagAndSearch = e => {
|
||||
const tag = e.detail;
|
||||
if (tag === undefined) return;
|
||||
|
||||
let { searchText } = this.state;
|
||||
if (searchText === "") searchText += "#" + tag;
|
||||
else searchText += " #" + tag;
|
||||
|
||||
this.setState({searchText});
|
||||
setTimeout(() => {
|
||||
this.search();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { itemsPerPage, totalItems, currentPage } = this.props;
|
||||
const { searchText } = this.state;
|
||||
|
||||
let paginator = null;
|
||||
let clearSearch = null;
|
||||
let toolbar = (<ul className={"pagination pagination-sm toolbar " + (totalItems == 0 && !searchText ? "hidden " : " ") + (totalItems / itemsPerPage <= 1 ? "no-margin" : "")}>
|
||||
<li className="btn-group" ref={domNode => { this.searchContainer = domNode; }}>
|
||||
<a href="javascript:void(0);" className="dropdown-toggle"
|
||||
data-toggle-outside
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false"
|
||||
onClick={this.toggleSearch}
|
||||
title={_("Search")}><i className="fa fa-search"></i></a>
|
||||
<ul className="dropdown-menu dropdown-menu-right search-popup">
|
||||
<li>
|
||||
<input type="text"
|
||||
ref={(domNode) => { this.searchInput = domNode}}
|
||||
className="form-control search theme-border-secondary-07"
|
||||
placeholder={_("Search names or #tags")}
|
||||
spellCheck="false"
|
||||
autoComplete="false"
|
||||
value={searchText}
|
||||
onKeyDown={this.handleSearchKeyDown}
|
||||
onChange={this.handleSearchChange} />
|
||||
<button onClick={this.search} className="btn btn-sm btn-default"><i className="fa fa-search"></i></button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li className="btn-group">
|
||||
<a href="javascript:void(0);" className="dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i className="fa fa-sort-alpha-down" title={_("Sort")}></i></a>
|
||||
<SortPanel selected={this.state.sortKey} items={this.sortItems} onChange={this.sortChanged} />
|
||||
</li>
|
||||
</ul>);
|
||||
|
||||
if (this.props.currentSearch){
|
||||
let currentSearch = decodeSearch(this.props.currentSearch);
|
||||
clearSearch = (<span className="clear-search">{_("Search results for:")} <span className="query">{currentSearch}</span> <a href="javascript:void(0);" onClick={this.clearSearch}>×</a></span>);
|
||||
}
|
||||
|
||||
if (itemsPerPage && itemsPerPage && totalItems > itemsPerPage){
|
||||
const numPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const MAX_PAGE_BUTTONS = 7;
|
||||
|
||||
let rangeStart = Math.max(1, currentPage - Math.floor(MAX_PAGE_BUTTONS / 2));
|
||||
let rangeEnd = rangeStart + Math.min(numPages, MAX_PAGE_BUTTONS);
|
||||
if (rangeEnd > numPages){
|
||||
rangeStart -= rangeEnd - numPages - 1;
|
||||
rangeEnd -= rangeEnd - numPages - 1
|
||||
}
|
||||
let pages = [...Array(rangeEnd - rangeStart).keys()].map(i => i + rangeStart - 1);
|
||||
|
||||
paginator = (
|
||||
<ul className="pagination pagination-sm">
|
||||
<li className={currentPage === 1 ? "disabled" : ""}>
|
||||
<Link to={{search: this.getQueryForPage(1)}}>
|
||||
<span>«</span>
|
||||
</Link>
|
||||
</li>
|
||||
{pages.map(page => {
|
||||
return (<li
|
||||
key={page + 1}
|
||||
className={currentPage === (page + 1) ? "active" : ""}
|
||||
><Link to={{search: this.getQueryForPage(page + 1)}}>{page + 1}</Link></li>);
|
||||
})}
|
||||
<li className={currentPage === numPages ? "disabled" : ""}>
|
||||
<Link to={{search: this.getQueryForPage(numPages)}}>
|
||||
<span>»</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
<div key="0" className="text-right paginator">{clearSearch}{toolbar}{paginator}</div>,
|
||||
this.props.children,
|
||||
<div key="2" className="text-right paginator">{paginator}</div>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export default Paginator;
|
||||
export default withRouter(Paginator);
|
||||
|
|
|
@ -4,6 +4,10 @@ import PropTypes from 'prop-types';
|
|||
import $ from 'jquery';
|
||||
import { _ } from '../classes/gettext';
|
||||
|
||||
const warnings = {
|
||||
'ignore-gsd': _("You might run out of memory if you use this option.")
|
||||
};
|
||||
|
||||
class ProcessingNodeOption extends React.Component {
|
||||
static defaultProps = {};
|
||||
|
||||
|
@ -102,8 +106,17 @@ class ProcessingNodeOption extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
handleHelp = e => {
|
||||
e.preventDefault();
|
||||
if (window.__taskOptionsDocsLink){
|
||||
window.open(window.__taskOptionsDocsLink + "#" + encodeURIComponent(this.props.name), "task-options")
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let inputControl = "";
|
||||
let warningMsg = "";
|
||||
|
||||
if (this.props.type !== 'bool'){
|
||||
if (this.isEnumType()){
|
||||
// Enum
|
||||
|
@ -146,23 +159,31 @@ class ProcessingNodeOption extends React.Component {
|
|||
let loadFileControl = "";
|
||||
if (this.supportsFileAPI() && this.props.domain === 'json'){
|
||||
loadFileControl = ([
|
||||
<button key="btn" type="file" className="btn glyphicon glyphicon-import btn-primary" data-toggle="tooltip" data-placement="left" title={_("Click to import a .JSON file")} onClick={() => this.loadFile()}></button>,
|
||||
<button key="btn" type="file" className="btn glyphicon glyphicon-import btn-primary" data-toggle="tooltip" data-placement="left" title={_("Click to import a JSON file")} onClick={() => this.loadFile()}></button>,
|
||||
<input key="file-ctrl" className="file-control" type="file"
|
||||
accept="text/plain,application/json,application/geo+json"
|
||||
accept="text/plain,application/json,application/geo+json,.geojson"
|
||||
onChange={this.handleFileSelect}
|
||||
ref={(domNode) => { this.fileControl = domNode}} />
|
||||
]);
|
||||
}
|
||||
|
||||
if (warnings[this.props.name] !== undefined && this.state.value !== ""){
|
||||
warningMsg = (<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle"></i> {warnings[this.props.name]}
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="processing-node-option form-inline form-group form-horizontal" ref={this.setTooltips}>
|
||||
<label>{this.props.name} {(!this.isEnumType() && this.props.domain ? `(${this.props.domain})` : "")} <i data-toggle="tooltip" data-placement="bottom" title={this.props.help} onClick={e => e.preventDefault()} className="fa fa-info-circle info-button"></i></label><br/>
|
||||
<label>{this.props.name} {(!this.isEnumType() && this.props.domain ? `(${this.props.domain})` : "")} <i data-toggle="tooltip" data-placement="bottom" title={this.props.help} onClick={this.handleHelp} className="fa fa-info-circle info-button help-button"></i></label><br/>
|
||||
{inputControl}
|
||||
{loadFileControl}
|
||||
|
||||
{this.state.value !== "" ?
|
||||
<button type="submit" className="btn glyphicon glyphicon glyphicon-repeat btn-default" data-toggle="tooltip" data-placement="top" title={_("Reset to default")} onClick={this.resetToDefault}></button> :
|
||||
""}
|
||||
|
||||
{warningMsg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import Paginator from './Paginator';
|
|||
import ErrorMessage from './ErrorMessage';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
import PropTypes from 'prop-types';
|
||||
import Utils from '../classes/Utils';
|
||||
|
||||
class ProjectList extends Paginated {
|
||||
static propTypes = {
|
||||
|
@ -33,8 +34,23 @@ class ProjectList extends Paginated {
|
|||
this.refresh();
|
||||
}
|
||||
|
||||
getParametersHash(source){
|
||||
if (!source) return "";
|
||||
if (source.indexOf("?") === -1) return "";
|
||||
|
||||
let search = source.substr(source.indexOf("?"));
|
||||
let q = Utils.queryParams({search});
|
||||
|
||||
// All parameters that can change via history.push without
|
||||
// triggering a reload of the project list should go here
|
||||
delete q.project_task_open;
|
||||
delete q.project_task_expanded;
|
||||
|
||||
return JSON.stringify(q);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps){
|
||||
if (prevProps.source !== this.props.source){
|
||||
if (this.getParametersHash(prevProps.source) !== this.getParametersHash(this.props.source)){
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
@ -101,8 +117,8 @@ class ProjectList extends Paginated {
|
|||
}else{
|
||||
return (<div className="project-list">
|
||||
<ErrorMessage bind={[this, 'error']} />
|
||||
<Paginator className="text-right" {...this.state.pagination} {...this.props}>
|
||||
<ul className={"list-group project-list " + (this.state.refreshing ? "refreshing" : "")}>
|
||||
<Paginator {...this.state.pagination} {...this.props}>
|
||||
<ul key="1" className={"list-group project-list " + (this.state.refreshing ? "refreshing" : "")}>
|
||||
{this.state.projects.map(p => (
|
||||
<ProjectListItem
|
||||
ref={(domNode) => { this["projectListItem_" + p.id] = domNode }}
|
||||
|
|
|
@ -7,11 +7,13 @@ import ImportTaskPanel from './ImportTaskPanel';
|
|||
import UploadProgressBar from './UploadProgressBar';
|
||||
import ErrorMessage from './ErrorMessage';
|
||||
import EditProjectDialog from './EditProjectDialog';
|
||||
import SortPanel from './SortPanel';
|
||||
import Dropzone from '../vendor/dropzone';
|
||||
import csrf from '../django/csrf';
|
||||
import HistoryNav from '../classes/HistoryNav';
|
||||
import PropTypes from 'prop-types';
|
||||
import ResizeModes from '../classes/ResizeModes';
|
||||
import Tags from '../classes/Tags';
|
||||
import exifr from '../vendor/exifr';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
import $ from 'jquery';
|
||||
|
@ -37,12 +39,28 @@ class ProjectListItem extends React.Component {
|
|||
data: props.data,
|
||||
refreshing: false,
|
||||
importing: false,
|
||||
buttons: []
|
||||
buttons: [],
|
||||
sortKey: "-created_at",
|
||||
filterTags: [],
|
||||
selectedTags: [],
|
||||
filterText: ""
|
||||
};
|
||||
|
||||
this.sortItems = [{
|
||||
key: "created_at",
|
||||
label: _("Created on")
|
||||
},{
|
||||
key: "name",
|
||||
label: _("Name")
|
||||
},{
|
||||
key: "tags",
|
||||
label: _("Tags")
|
||||
}];
|
||||
|
||||
this.toggleTaskList = this.toggleTaskList.bind(this);
|
||||
this.closeUploadError = this.closeUploadError.bind(this);
|
||||
this.cancelUpload = this.cancelUpload.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
this.handleTaskSaved = this.handleTaskSaved.bind(this);
|
||||
this.viewMap = this.viewMap.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
|
@ -75,6 +93,13 @@ class ProjectListItem extends React.Component {
|
|||
if (this.refreshRequest) this.refreshRequest.abort();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState){
|
||||
if (prevState.filterText !== this.state.filterText ||
|
||||
prevState.selectedTags.length !== this.state.selectedTags.length){
|
||||
if (this.taskList) this.taskList.applyFilter(this.state.filterText, this.state.selectedTags);
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultUploadState(){
|
||||
return {
|
||||
uploading: false,
|
||||
|
@ -115,10 +140,11 @@ class ProjectListItem extends React.Component {
|
|||
url : 'TO_BE_CHANGED',
|
||||
parallelUploads: 6,
|
||||
uploadMultiple: false,
|
||||
acceptedFiles: "image/*,text/*",
|
||||
acceptedFiles: "image/*,text/*,.las,.laz,video/*,.srt",
|
||||
autoProcessQueue: false,
|
||||
createImageThumbnails: false,
|
||||
clickable: this.uploadButton,
|
||||
maxFilesize: 131072, // 128G
|
||||
chunkSize: 2147483647,
|
||||
timeout: 2147483647,
|
||||
|
||||
|
@ -167,7 +193,7 @@ class ProjectListItem extends React.Component {
|
|||
.on("complete", (file) => {
|
||||
// Retry
|
||||
const retry = () => {
|
||||
const MAX_RETRIES = 10;
|
||||
const MAX_RETRIES = 20;
|
||||
|
||||
if (file.retries < MAX_RETRIES){
|
||||
// Update progress
|
||||
|
@ -183,7 +209,9 @@ class ProjectListItem extends React.Component {
|
|||
file.deltaBytesSent = 0;
|
||||
file.trackedBytesSent = 0;
|
||||
file.retries++;
|
||||
this.dz.processQueue();
|
||||
setTimeout(() => {
|
||||
this.dz.processQueue();
|
||||
}, 5000 * file.retries);
|
||||
}else{
|
||||
throw new Error(interpolate(_('Cannot upload %(filename)s, exceeded max retries (%(max_retries)s)'), {filename: file.name, max_retries: MAX_RETRIES}));
|
||||
}
|
||||
|
@ -191,11 +219,19 @@ class ProjectListItem extends React.Component {
|
|||
|
||||
try{
|
||||
if (file.status === "error"){
|
||||
if ((file.size / 1024 / 1024) > this.dz.options.maxFilesize) {
|
||||
// Delete from upload queue
|
||||
this.setUploadState({
|
||||
totalCount: this.state.upload.totalCount - 1,
|
||||
totalBytes: this.state.upload.totalBytes - file.size
|
||||
});
|
||||
throw new Error(interpolate(_('Cannot upload %(filename)s, file is too large! Default MaxFileSize is %(maxFileSize)s MB!'), { filename: file.name, maxFileSize: this.dz.options.maxFilesize }));
|
||||
}
|
||||
retry();
|
||||
}else{
|
||||
// Check response
|
||||
let response = JSON.parse(file.xhr.response);
|
||||
if (response.success){
|
||||
if (response.success && response.uploaded && response.uploaded[file.name] === file.size){
|
||||
// Update progress by removing the tracked progress and
|
||||
// use the file size as the true number of bytes
|
||||
let totalBytesSent = this.state.upload.totalBytesSent + file.size;
|
||||
|
@ -215,13 +251,19 @@ class ProjectListItem extends React.Component {
|
|||
}
|
||||
}
|
||||
}catch(e){
|
||||
this.setUploadState({error: `${e.message}`, uploading: false});
|
||||
this.dz.cancelUpload();
|
||||
if (this.manuallyCanceled){
|
||||
// Manually canceled, ignore error
|
||||
this.setUploadState({uploading: false});
|
||||
}else{
|
||||
this.setUploadState({error: `${e.message}`, uploading: false});
|
||||
}
|
||||
|
||||
if (this.dz.files.length) this.dz.cancelUpload();
|
||||
}
|
||||
})
|
||||
.on("queuecomplete", () => {
|
||||
const remainingFilesCount = this.state.upload.totalCount - this.state.upload.uploadedCount;
|
||||
if (remainingFilesCount === 0){
|
||||
if (remainingFilesCount === 0 && this.state.upload.uploadedCount > 0){
|
||||
// All files have uploaded!
|
||||
this.setUploadState({uploading: false});
|
||||
|
||||
|
@ -242,7 +284,6 @@ class ProjectListItem extends React.Component {
|
|||
}else if (this.dz.getQueuedFiles() === 0){
|
||||
// Done but didn't upload all?
|
||||
this.setUploadState({
|
||||
totalCount: this.state.upload.totalCount - remainingFilesCount,
|
||||
uploading: false,
|
||||
error: interpolate(_('%(count)s files cannot be uploaded. As a reminder, only images (.jpg, .tif, .png) and GCP files (.txt) can be uploaded. Try again.'), { count: remainingFilesCount })
|
||||
});
|
||||
|
@ -299,10 +340,26 @@ class ProjectListItem extends React.Component {
|
|||
this.setUploadState({error: ""});
|
||||
}
|
||||
|
||||
cancelUpload(e){
|
||||
cancelUpload(){
|
||||
this.dz.removeAllFiles(true);
|
||||
}
|
||||
|
||||
handleCancel(){
|
||||
this.manuallyCanceled = true;
|
||||
this.cancelUpload();
|
||||
if (this.dz._taskInfo && this.dz._taskInfo.id !== undefined){
|
||||
$.ajax({
|
||||
url: `/api/projects/${this.state.data.id}/tasks/${this.dz._taskInfo.id}/remove/`,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
type: 'POST'
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.manuallyCanceled = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
taskDeleted(){
|
||||
this.refresh();
|
||||
}
|
||||
|
@ -376,6 +433,20 @@ class ProjectListItem extends React.Component {
|
|||
this.editProjectDialog.show();
|
||||
}
|
||||
|
||||
handleHideProject = (deleteWarning, deleteAction) => {
|
||||
return () => {
|
||||
if (window.confirm(deleteWarning)){
|
||||
this.setState({error: "", refreshing: true});
|
||||
deleteAction()
|
||||
.fail(e => {
|
||||
this.setState({error: e.message || (e.responseJSON || {}).detail || e.responseText || _("Could not delete item")});
|
||||
}).always(() => {
|
||||
this.setState({refreshing: false});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateProject(project){
|
||||
return $.ajax({
|
||||
url: `/api/projects/${this.state.data.id}/edit/`,
|
||||
|
@ -383,6 +454,7 @@ class ProjectListItem extends React.Component {
|
|||
data: JSON.stringify({
|
||||
name: project.name,
|
||||
description: project.descr,
|
||||
tags: project.tags,
|
||||
permissions: project.permissions
|
||||
}),
|
||||
dataType: 'json',
|
||||
|
@ -467,10 +539,68 @@ class ProjectListItem extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
sortChanged = key => {
|
||||
if (this.taskList){
|
||||
this.setState({sortKey: key});
|
||||
setTimeout(() => {
|
||||
this.taskList.refresh();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
handleTagClick = tag => {
|
||||
return e => {
|
||||
const evt = new CustomEvent("onProjectListTagClicked", { detail: tag });
|
||||
document.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
|
||||
tagsChanged = (filterTags) => {
|
||||
this.setState({filterTags, selectedTags: []});
|
||||
}
|
||||
|
||||
handleFilterTextChange = e => {
|
||||
this.setState({filterText: e.target.value});
|
||||
}
|
||||
|
||||
toggleTag = t => {
|
||||
return () => {
|
||||
if (this.state.selectedTags.indexOf(t) === -1){
|
||||
this.setState(update(this.state, { selectedTags: {$push: [t]} }));
|
||||
}else{
|
||||
this.setState({selectedTags: this.state.selectedTags.filter(tag => tag !== t)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectTag = t => {
|
||||
if (this.state.selectedTags.indexOf(t) === -1){
|
||||
this.setState(update(this.state, { selectedTags: {$push: [t]} }));
|
||||
}
|
||||
}
|
||||
|
||||
clearFilter = () => {
|
||||
this.setState({
|
||||
filterText: "",
|
||||
selectedTags: []
|
||||
});
|
||||
}
|
||||
|
||||
onOpenFilter = () => {
|
||||
if (this.state.filterTags.length === 0){
|
||||
setTimeout(() => {
|
||||
this.filterTextInput.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { refreshing, data } = this.state;
|
||||
const { refreshing, data, filterTags } = this.state;
|
||||
const numTasks = data.tasks.length;
|
||||
const canEdit = this.hasPermission("change");
|
||||
const userTags = Tags.userTags(data.tags);
|
||||
let deleteWarning = _("All tasks, images and models associated with this project will be permanently deleted. Are you sure you want to continue?");
|
||||
if (!data.owned) deleteWarning = _("This project was shared with you. It will not be deleted, but simply hidden from your dashboard. Continue?")
|
||||
|
||||
return (
|
||||
<li className={"project-list-item list-group-item " + (refreshing ? "refreshing" : "")}
|
||||
|
@ -490,6 +620,8 @@ class ProjectListItem extends React.Component {
|
|||
projectName={data.name}
|
||||
projectDescr={data.description}
|
||||
projectId={data.id}
|
||||
projectTags={data.tags}
|
||||
deleteWarning={deleteWarning}
|
||||
saveAction={this.updateProject}
|
||||
showPermissions={this.hasPermission("change")}
|
||||
deleteAction={this.hasPermission("delete") ? this.handleDelete : undefined}
|
||||
|
@ -520,18 +652,17 @@ class ProjectListItem extends React.Component {
|
|||
<button disabled={this.state.upload.error !== ""}
|
||||
type="button"
|
||||
className={"btn btn-danger btn-sm " + (!this.state.upload.uploading ? "hide" : "")}
|
||||
onClick={this.cancelUpload}>
|
||||
onClick={this.handleCancel}>
|
||||
<i className="glyphicon glyphicon-remove-circle"></i>
|
||||
Cancel Upload
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-default btn-sm" onClick={this.viewMap}>
|
||||
<i className="fa fa-globe"></i> {_("View Map")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="project-name">
|
||||
{data.name}
|
||||
{userTags.length > 0 ?
|
||||
userTags.map((t, i) => <div key={i} className="tag-badge small-badge" onClick={this.handleTagClick(t)}>{t}</div>)
|
||||
: ""}
|
||||
</div>
|
||||
<div className="project-description">
|
||||
{data.description}
|
||||
|
@ -540,17 +671,71 @@ class ProjectListItem extends React.Component {
|
|||
{numTasks > 0 ?
|
||||
<span>
|
||||
<i className='fa fa-tasks'></i>
|
||||
<a href="javascript:void(0);" onClick={this.toggleTaskList}>
|
||||
<a href="javascript:void(0);" onClick={this.toggleTaskList}>
|
||||
{interpolate(_("%(count)s Tasks"), { count: numTasks})} <i className={'fa fa-caret-' + (this.state.showTaskList ? 'down' : 'right')}></i>
|
||||
</a>
|
||||
</span>
|
||||
: ""}
|
||||
|
||||
{this.state.showTaskList && numTasks > 1 ?
|
||||
<div className="task-filters">
|
||||
<div className="btn-group">
|
||||
{this.state.selectedTags.length || this.state.filterText !== "" ?
|
||||
<a className="quick-clear-filter" href="javascript:void(0)" onClick={this.clearFilter}>×</a>
|
||||
: ""}
|
||||
<i className='fa fa-filter'></i>
|
||||
<a href="javascript:void(0);" onClick={this.onOpenFilter} className="dropdown-toggle" data-toggle-outside data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{_("Filter")}
|
||||
</a>
|
||||
<ul className="dropdown-menu dropdown-menu-right filter-dropdown">
|
||||
<li className="filter-text-container">
|
||||
<input type="text" className="form-control filter-text theme-border-secondary-07"
|
||||
value={this.state.filterText}
|
||||
ref={domNode => {this.filterTextInput = domNode}}
|
||||
placeholder=""
|
||||
spellCheck="false"
|
||||
autoComplete="false"
|
||||
onChange={this.handleFilterTextChange} />
|
||||
</li>
|
||||
{filterTags.map(t => <li key={t} className="tag-selection">
|
||||
<input type="checkbox"
|
||||
className="filter-checkbox"
|
||||
id={"filter-tag-" + data.id + "-" + t}
|
||||
checked={this.state.selectedTags.indexOf(t) !== -1}
|
||||
onChange={this.toggleTag(t)} /> <label className="filter-checkbox-label" htmlFor={"filter-tag-" + data.id + "-" + t}>{t}</label>
|
||||
</li>)}
|
||||
|
||||
<li className="clear-container"><input type="button" onClick={this.clearFilter} className="btn btn-default btn-xs" value={_("Clear")}/></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<i className='fa fa-sort-alpha-down'></i>
|
||||
<a href="javascript:void(0);" className="dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{_("Sort")}
|
||||
</a>
|
||||
<SortPanel selected="-created_at" items={this.sortItems} onChange={this.sortChanged} />
|
||||
</div>
|
||||
</div> : ""}
|
||||
|
||||
{numTasks > 0 ?
|
||||
[<i key="edit-icon" className='fa fa-globe'></i>
|
||||
,<a key="edit-text" href="javascript:void(0);" onClick={this.viewMap}>
|
||||
{_("View Map")}
|
||||
</a>]
|
||||
: ""}
|
||||
|
||||
{canEdit ?
|
||||
[<i key="edit-icon" className='far fa-edit'></i>
|
||||
,<a key="edit-text" href="javascript:void(0);" onClick={this.handleEditProject}> {_("Edit")}
|
||||
</a>]
|
||||
: ""}
|
||||
|
||||
{!canEdit && !data.owned ?
|
||||
[<i key="edit-icon" className='far fa-eye-slash'></i>
|
||||
,<a key="edit-text" href="javascript:void(0);" onClick={this.handleHideProject(deleteWarning, this.handleDelete)}> {_("Delete")}
|
||||
</a>]
|
||||
: ""}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<i className="drag-drop-icon fa fa-inbox"></i>
|
||||
|
@ -586,10 +771,12 @@ class ProjectListItem extends React.Component {
|
|||
{this.state.showTaskList ?
|
||||
<TaskList
|
||||
ref={this.setRef("taskList")}
|
||||
source={`/api/projects/${data.id}/tasks/?ordering=-created_at`}
|
||||
source={`/api/projects/${data.id}/tasks/?ordering=${this.state.sortKey}`}
|
||||
onDelete={this.taskDeleted}
|
||||
onTaskMoved={this.taskMoved}
|
||||
hasPermission={this.hasPermission}
|
||||
onTagsChanged={this.tagsChanged}
|
||||
onTagClicked={this.selectTag}
|
||||
history={this.props.history}
|
||||
/> : ""}
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@ class ShareButton extends React.Component {
|
|||
static propTypes = {
|
||||
task: PropTypes.object.isRequired,
|
||||
linksTarget: PropTypes.oneOf(['map', '3d']).isRequired,
|
||||
popupPlacement: PropTypes.string
|
||||
popupPlacement: PropTypes.string,
|
||||
queryParams: PropTypes.object
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
|
@ -45,6 +46,7 @@ class ShareButton extends React.Component {
|
|||
taskChanged={this.handleTaskChanged}
|
||||
placement={this.props.popupPlacement}
|
||||
linksTarget={this.props.linksTarget}
|
||||
queryParams={this.props.queryParams}
|
||||
/>;
|
||||
|
||||
return (
|
||||
|
|
|
@ -15,7 +15,8 @@ class SharePopup extends React.Component{
|
|||
task: PropTypes.object.isRequired,
|
||||
linksTarget: PropTypes.oneOf(['map', '3d']).isRequired,
|
||||
placement: PropTypes.string,
|
||||
taskChanged: PropTypes.func
|
||||
taskChanged: PropTypes.func,
|
||||
queryParams: PropTypes.object
|
||||
};
|
||||
static defaultProps = {
|
||||
placement: 'top',
|
||||
|
@ -38,7 +39,11 @@ class SharePopup extends React.Component{
|
|||
}
|
||||
|
||||
getRelShareLink = () => {
|
||||
return `/public/task/${this.props.task.id}/${this.props.linksTarget}/`;
|
||||
let url = `/public/task/${this.props.task.id}/${this.props.linksTarget}/`;
|
||||
if (this.props.queryParams){
|
||||
url += Utils.toSearchQuery(this.props.queryParams);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
|
@ -86,8 +91,8 @@ class SharePopup extends React.Component{
|
|||
}
|
||||
|
||||
render(){
|
||||
const shareLink = Utils.absoluteUrl(this.state.relShareLink);
|
||||
const iframeUrl = Utils.absoluteUrl(`public/task/${this.state.task.id}/iframe/${this.props.linksTarget}/`);
|
||||
const shareLink = Utils.absoluteUrl(this.getRelShareLink());
|
||||
const iframeUrl = Utils.absoluteUrl(`public/task/${this.state.task.id}/iframe/${this.props.linksTarget}/${Utils.toSearchQuery(this.props.queryParams)}`);
|
||||
const iframeCode = `<iframe scrolling="no" title="WebODM" width="61.8033%" height="360" frameBorder="0" src="${iframeUrl}"></iframe>`;
|
||||
|
||||
return (<div onMouseDown={e => { e.stopPropagation(); }} className={"sharePopup " + this.props.placement}>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import '../css/SortPanel.scss';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _ } from '../classes/gettext';
|
||||
|
||||
class SortPanel extends React.Component {
|
||||
static defaultProps = {
|
||||
items: [],
|
||||
onChange: () => {},
|
||||
selected: null
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.object),
|
||||
onChange: PropTypes.func,
|
||||
selected: PropTypes.string
|
||||
};
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
items: props.items
|
||||
}
|
||||
|
||||
if (props.selected){
|
||||
let normSortKey = props.selected.replace("-", "");
|
||||
this.state.items.forEach(s => {
|
||||
if (s.key === normSortKey) s.selected = props.selected[0] === "-" ? "desc" : "asc";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (key, order) => {
|
||||
return () => {
|
||||
this.state.items.forEach(i => {
|
||||
i.selected = i.key === key ? order : false;
|
||||
});
|
||||
this.setState({
|
||||
items: this.state.items
|
||||
})
|
||||
this.props.onChange(order === "desc" ? "-" + key : key);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<ul className="dropdown-menu dropdown-menu-right sort-items">
|
||||
<li className="sort-order-label">{_("Descending")}</li>
|
||||
{this.state.items.map(i =>
|
||||
<li key={i.key}><a onClick={this.handleClick(i.key, "desc")} className="sort-item">
|
||||
{ i.label } {i.selected === "desc" ? <i className="fa fa-check"></i> : ""}
|
||||
</a></li>
|
||||
)}
|
||||
<li className="sort-order-label">{_("Ascending")}</li>
|
||||
{this.state.items.map(i =>
|
||||
<li key={i.key}><a onClick={this.handleClick(i.key, "asc")} className="sort-item">
|
||||
{ i.label } {i.selected === "asc" ? <i className="fa fa-check"></i> : ""}
|
||||
</a></li>
|
||||
)}
|
||||
</ul>);
|
||||
}
|
||||
}
|
||||
|
||||
export default SortPanel;
|
|
@ -0,0 +1,225 @@
|
|||
import React from 'react';
|
||||
import '../css/TagsField.scss';
|
||||
import PropTypes from 'prop-types';
|
||||
import update from 'immutability-helper';
|
||||
import { _ } from '../classes/gettext';
|
||||
import Tags from '../classes/Tags';
|
||||
|
||||
class TagsField extends React.Component {
|
||||
static defaultProps = {
|
||||
tags: [],
|
||||
onUpdate: () => {}
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
onUpdate: PropTypes.func
|
||||
};
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
userTags: Tags.userTags(props.tags),
|
||||
systemTags: Tags.systemTags(props.tags)
|
||||
}
|
||||
|
||||
this.dzList = [];
|
||||
this.domTags = [];
|
||||
}
|
||||
|
||||
componentDidUpdate(){
|
||||
this.props.onUpdate(Tags.combine(this.state.userTags, this.state.systemTags));
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
this.restoreDropzones();
|
||||
}
|
||||
|
||||
disableDropzones(){
|
||||
if (this.disabledDz) return;
|
||||
let parent = this.domNode.parentElement;
|
||||
while(parent){
|
||||
if (parent.dropzone){
|
||||
parent.dropzone.removeListeners();
|
||||
this.dzList.push(parent.dropzone);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
this.disabledDz = true;
|
||||
}
|
||||
|
||||
restoreDropzones(){
|
||||
if (!this.disabledDz) return;
|
||||
|
||||
this.dzList.forEach(dz => {
|
||||
dz.restoreListeners();
|
||||
});
|
||||
this.dzList = [];
|
||||
this.disabledDz = false;
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.key === "Tab" || e.key === "Enter" || e.key === "," || e.key === " "){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.addTag();
|
||||
}else if (e.key === "Backspace" && this.inputText.innerText === ""){
|
||||
this.removeTag(this.state.userTags.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
focus = () => {
|
||||
this.inputText.focus();
|
||||
}
|
||||
|
||||
stop = e => {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleRemoveTag = idx => {
|
||||
return e => {
|
||||
e.stopPropagation();
|
||||
this.removeTag(idx);
|
||||
}
|
||||
}
|
||||
|
||||
removeTag = idx => {
|
||||
this.setState(update(this.state, { userTags: { $splice: [[idx, 1]] } }));
|
||||
}
|
||||
|
||||
addTag = () => {
|
||||
let text = this.inputText.innerText;
|
||||
if (text !== ""){
|
||||
// Do not allow system tags
|
||||
if (!text.startsWith(".")){
|
||||
|
||||
// Only lower case text allowed
|
||||
text = text.toLowerCase();
|
||||
|
||||
// Check for dulicates
|
||||
if (this.state.userTags.indexOf(text) === -1){
|
||||
this.setState(update(this.state, {
|
||||
userTags: {$push: [text]}
|
||||
}));
|
||||
}
|
||||
}
|
||||
this.inputText.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
handleDragStart = tag => {
|
||||
return e => {
|
||||
this.disableDropzones();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData("application/tag", tag);
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop = e => {
|
||||
e.preventDefault();
|
||||
const dragTag = e.dataTransfer.getData("application/tag");
|
||||
const [moveTag, side] = this.findClosestTag(e.clientX, e.clientY);
|
||||
|
||||
const { userTags } = this.state;
|
||||
if (moveTag){
|
||||
const dragIdx = userTags.indexOf(dragTag);
|
||||
const moveIdx = userTags.indexOf(moveTag);
|
||||
if (dragIdx !== -1 && moveIdx !== -1){
|
||||
if (dragIdx === moveIdx) return;
|
||||
else{
|
||||
// Put drag tag in front of move tag
|
||||
let insertIdx = side === "right" ? moveIdx + 1 : moveIdx;
|
||||
userTags.splice(insertIdx, 0, dragTag);
|
||||
for (let i = 0; i < userTags.length; i++){
|
||||
if (userTags[i] === dragTag && i !== insertIdx){
|
||||
userTags.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.setState({userTags});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handleDragOver = e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
handleDragEnter = e => {
|
||||
e.preventDefault();
|
||||
}
|
||||
handleDragEnd = () => {
|
||||
this.restoreDropzones();
|
||||
}
|
||||
|
||||
findClosestTag = (clientX, clientY) => {
|
||||
let closestTag = null;
|
||||
let minDistX = Infinity, minDistY = Infinity;
|
||||
let rowTagY = null;
|
||||
const { userTags } = this.state;
|
||||
|
||||
// Find tags in closest row
|
||||
this.domTags.forEach((domTag, i) => {
|
||||
const b = domTag.getBoundingClientRect();
|
||||
const tagY = b.y + (b.height / 2);
|
||||
let dy = clientY - tagY,
|
||||
sqDistY = dy*dy;
|
||||
|
||||
if (sqDistY < minDistY){
|
||||
minDistY = sqDistY;
|
||||
rowTagY = tagY;
|
||||
}
|
||||
});
|
||||
|
||||
if (!rowTagY) return [null, ""];
|
||||
|
||||
// From row, find closest in X
|
||||
this.domTags.forEach((domTag, i) => {
|
||||
const b = domTag.getBoundingClientRect();
|
||||
const tagY = b.y + (b.height / 2);
|
||||
if (Math.abs(tagY - rowTagY) < 0.001){
|
||||
const tagX = b.x + b.width;
|
||||
let dx = clientX - tagX,
|
||||
sqDistX = dx*dx;
|
||||
if (sqDistX < minDistX){
|
||||
closestTag = userTags[i];
|
||||
minDistX = sqDistX;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let side = "right";
|
||||
if (closestTag){
|
||||
const b = this.domTags[this.state.userTags.indexOf(closestTag)].getBoundingClientRect();
|
||||
const centerX = b.x + b.width / 2.0;
|
||||
if (clientX < centerX) side = "left";
|
||||
}
|
||||
|
||||
return [closestTag, side];
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div
|
||||
ref={domNode => this.domNode = domNode}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
onClick={this.focus}
|
||||
onDrop={this.handleDrop}
|
||||
onDragOver={this.handleDragOver}
|
||||
onDragEnter={this.handleDragEnter}
|
||||
className="form-control tags-field">{this.state.userTags.map((tag, i) =>
|
||||
<div draggable="true" className="tag-badge" key={i} ref={domNode => this.domTags[i] = domNode}
|
||||
onClick={this.stop}
|
||||
onDragStart={this.handleDragStart(tag)}
|
||||
onDragEnd={this.handleDragEnd}>{tag} <a href="javascript:void(0)" onClick={this.handleRemoveTag(i)}>×</a> </div>
|
||||
)}
|
||||
<div className="inputText" contentEditable="true" ref={(domNode) => this.inputText = domNode}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.addTag}></div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default TagsField;
|
|
@ -3,6 +3,7 @@ import '../css/TaskList.scss';
|
|||
import TaskListItem from './TaskListItem';
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
import HistoryNav from '../classes/HistoryNav';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
|
||||
class TaskList extends React.Component {
|
||||
|
@ -11,16 +12,22 @@ class TaskList extends React.Component {
|
|||
source: PropTypes.string.isRequired, // URL where to load task list
|
||||
onDelete: PropTypes.func,
|
||||
onTaskMoved: PropTypes.func,
|
||||
hasPermission: PropTypes.func.isRequired
|
||||
hasPermission: PropTypes.func.isRequired,
|
||||
onTagsChanged: PropTypes.func,
|
||||
onTagClicked: PropTypes.func
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.historyNav = new HistoryNav(props.history);
|
||||
|
||||
this.state = {
|
||||
tasks: [],
|
||||
error: "",
|
||||
loading: true
|
||||
loading: true,
|
||||
filterText: "",
|
||||
filterTags: []
|
||||
};
|
||||
|
||||
this.refresh = this.refresh.bind(this);
|
||||
|
@ -41,12 +48,23 @@ class TaskList extends React.Component {
|
|||
this.refresh();
|
||||
}
|
||||
|
||||
applyFilter(text, tags){
|
||||
this.setState({filterText: text, filterTags: tags});
|
||||
}
|
||||
|
||||
loadTaskList(){
|
||||
this.setState({loading: true});
|
||||
|
||||
this.taskListRequest =
|
||||
$.getJSON(this.props.source, json => {
|
||||
if (json.length === 1){
|
||||
this.historyNav.addToQSList("project_task_expanded", json[0].id);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
tasks: json
|
||||
});
|
||||
setTimeout(() => this.notifyTagsChanged(), 0);
|
||||
})
|
||||
.fail((jqXHR, textStatus, errorThrown) => {
|
||||
this.setState({
|
||||
|
@ -56,7 +74,7 @@ class TaskList extends React.Component {
|
|||
.always(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -76,6 +94,49 @@ class TaskList extends React.Component {
|
|||
if (this.props.onTaskMoved) this.props.onTaskMoved(task);
|
||||
}
|
||||
|
||||
notifyTagsChanged = () => {
|
||||
const { tasks } = this.state;
|
||||
const tags = [];
|
||||
if (tasks){
|
||||
tasks.forEach(t => {
|
||||
if (t.tags){
|
||||
t.tags.forEach(x => {
|
||||
if (tags.indexOf(x) === -1) tags.push(x);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
tags.sort();
|
||||
|
||||
if (this.props.onTagsChanged) this.props.onTagsChanged(tags);
|
||||
}
|
||||
|
||||
taskEdited = (task) => {
|
||||
// Update
|
||||
const { tasks } = this.state;
|
||||
for (let i = 0; i < tasks.length; i++){
|
||||
if (tasks[i].id === task.id){
|
||||
tasks[i] = task;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.setState({tasks});
|
||||
|
||||
// Tags might have changed
|
||||
setTimeout(() => this.notifyTagsChanged(), 0);
|
||||
}
|
||||
|
||||
arrayContainsAll = (a, b) => {
|
||||
let miss = false;
|
||||
for (let i = 0; i < b.length; i++){
|
||||
if (a.indexOf(b[i]) === -1){
|
||||
miss = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return !miss;
|
||||
}
|
||||
|
||||
render() {
|
||||
let message = "";
|
||||
if (this.state.loading){
|
||||
|
@ -88,9 +149,11 @@ class TaskList extends React.Component {
|
|||
|
||||
return (
|
||||
<div className="task-list">
|
||||
{message}
|
||||
|
||||
{this.state.tasks.map(task => (
|
||||
{this.state.tasks.filter(t => {
|
||||
const name = t.name !== null ? t.name : interpolate(_("Task #%(number)s"), { number: t.id });
|
||||
return name.toLocaleLowerCase().indexOf(this.state.filterText.toLocaleLowerCase()) !== -1 &&
|
||||
this.arrayContainsAll(t.tags, this.state.filterTags);
|
||||
}).map(task => (
|
||||
<TaskListItem
|
||||
data={task}
|
||||
key={task.id}
|
||||
|
@ -98,9 +161,13 @@ class TaskList extends React.Component {
|
|||
onDelete={this.deleteTask}
|
||||
onMove={this.moveTask}
|
||||
onDuplicate={this.refresh}
|
||||
onEdited={this.taskEdited}
|
||||
onTagClicked={this.props.onTagClicked}
|
||||
hasPermission={this.props.hasPermission}
|
||||
history={this.props.history} />
|
||||
))}
|
||||
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@ import TaskPluginActionButtons from './TaskPluginActionButtons';
|
|||
import MoveTaskDialog from './MoveTaskDialog';
|
||||
import PipelineSteps from '../classes/PipelineSteps';
|
||||
import Css from '../classes/Css';
|
||||
import Tags from '../classes/Tags';
|
||||
import Trans from './Trans';
|
||||
import Utils from '../classes/Utils';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
|
||||
class TaskListItem extends React.Component {
|
||||
|
@ -23,7 +25,9 @@ class TaskListItem extends React.Component {
|
|||
onDelete: PropTypes.func,
|
||||
onMove: PropTypes.func,
|
||||
onDuplicate: PropTypes.func,
|
||||
hasPermission: PropTypes.func
|
||||
hasPermission: PropTypes.func,
|
||||
onEdited: PropTypes.func,
|
||||
onTagClicked: PropTypes.func
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
|
@ -262,7 +266,7 @@ class TaskListItem extends React.Component {
|
|||
<li>${_("Not enough overlap between images")}</li>
|
||||
<li>${_("Images might be too blurry (common with phone cameras)")}</li>
|
||||
<li>${_("The min-num-features task option is set too low, try increasing it by 25%")}</li>
|
||||
</ul>`, link: `<a href='https://support.dronedeploy.com/v1.0/docs/making-successful-maps' target='_blank'>${_("here")}</a>`})});
|
||||
</ul>`, link: `<a href='https://docs.webodm.net/references/create-successful-maps' target='_blank'>${_("here")}</a>`})});
|
||||
}else if (line.indexOf("Illegal instruction") !== -1 ||
|
||||
line.indexOf("Child returned 132") !== -1){
|
||||
this.setState({friendlyTaskError: interpolate(_("It looks like this computer might be too old. WebODM requires a computer with a 64-bit CPU supporting MMX, SSE, SSE2, SSE3 and SSSE3 instruction set support or higher. You can still run WebODM if you compile your own docker images. See %(link)s for more information."), { link: `<a href='https://github.com/OpenDroneMap/WebODM#common-troubleshooting'>${_("this page")}</a>` } )});
|
||||
|
@ -278,6 +282,7 @@ class TaskListItem extends React.Component {
|
|||
|
||||
handleEditTaskSave(task){
|
||||
this.setState({task, editing: false});
|
||||
if (this.props.onEdited) this.props.onEdited(task);
|
||||
this.setAutoRefresh();
|
||||
}
|
||||
|
||||
|
@ -401,6 +406,12 @@ class TaskListItem extends React.Component {
|
|||
}else return false;
|
||||
}
|
||||
|
||||
handleTagClick = t => {
|
||||
return () => {
|
||||
if (this.props.onTagClicked) this.props.onTagClicked(t);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const task = this.state.task;
|
||||
const name = task.name !== null ? task.name : interpolate(_("Task #%(number)s"), { number: task.id });
|
||||
|
@ -562,6 +573,15 @@ class TaskListItem extends React.Component {
|
|||
<td><strong>{_("Reconstructed Points:")}</strong></td>
|
||||
<td>{stats.pointcloud.points.toLocaleString()}</td>
|
||||
</tr>}
|
||||
{task.size > 0 &&
|
||||
<tr>
|
||||
<td><strong>{_("Disk Usage:")}</strong></td>
|
||||
<td>{Utils.bytesToSize(task.size * 1024 * 1024)}</td>
|
||||
</tr>}
|
||||
<tr>
|
||||
<td><strong>{_("Task ID:")}</strong></td>
|
||||
<td>{task.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{_("Task Output:")}</strong></td>
|
||||
<td><div className="btn-group btn-toggle">
|
||||
|
@ -586,17 +606,17 @@ class TaskListItem extends React.Component {
|
|||
/> : ""}
|
||||
|
||||
{showOrthophotoMissingWarning ?
|
||||
<div className="task-warning"><i className="fa fa-warning"></i> <span>{_("An orthophoto could not be generated. To generate one, make sure GPS information is embedded in the EXIF tags of your images, or use a Ground Control Points (GCP) file.")}</span></div> : ""}
|
||||
<div className="task-warning"><i className="fa fa-exclamation-triangle"></i> <span>{_("An orthophoto could not be generated. To generate one, make sure GPS information is embedded in the EXIF tags of your images, or use a Ground Control Points (GCP) file.")}</span></div> : ""}
|
||||
|
||||
{showMemoryErrorWarning ?
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <Trans params={{ memlink: `<a href="${memoryErrorLink}" target='_blank'>${_("enough RAM allocated")}</a>`, cloudlink: `<a href='https://www.opendronemap.org/webodm/lightning/' target='_blank'>${_("cloud processing node")}</a>` }}>{_("It looks like your processing node ran out of memory. If you are using docker, make sure that your docker environment has %(memlink)s. Alternatively, make sure you have enough physical RAM, reduce the number of images, make your images smaller, or reduce the max-concurrency parameter from the task's options. You can also try to use a %(cloudlink)s.")}</Trans></div> : ""}
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <Trans params={{ memlink: `<a href="${memoryErrorLink}" target='_blank'>${_("enough RAM allocated")}</a>`, cloudlink: `<a href='https://webodm.net' target='_blank'>${_("cloud processing node")}</a>` }}>{_("It looks like your processing node ran out of memory. If you are using docker, make sure that your docker environment has %(memlink)s. Alternatively, make sure you have enough physical RAM, reduce the number of images, make your images smaller, or reduce the max-concurrency parameter from the task's options. You can also try to use a %(cloudlink)s.")}</Trans></div> : ""}
|
||||
|
||||
{showTaskWarning ?
|
||||
<div className="task-warning"><i className="fa fa-support"></i> <span dangerouslySetInnerHTML={{__html: this.state.friendlyTaskError}} /></div> : ""}
|
||||
|
||||
{showExitedWithCodeOneHints ?
|
||||
<div className="task-warning"><i className="fa fa-info-circle"></i> <div className="inline">
|
||||
<Trans params={{link1: `<a href="https://www.dronedb.app/" target="_blank">DroneDB</a>`, link2: `<a href="https://drive.google.com/drive/u/0/" target="_blank">Google Drive</a>`, open_a_topic: `<a href="http://community.opendronemap.org/c/webodm" target="_blank">${_("open a topic")}</a>`, }}>{_("\"Process exited with code 1\" means that part of the processing failed. Sometimes it's a problem with the dataset, sometimes it can be solved by tweaking the Task Options and sometimes it might be a bug! If you need help, upload your images somewhere like %(link1)s or %(link2)s and %(open_a_topic)s on our community forum, making sure to include a copy of your task's output. Our awesome contributors will try to help you!")}</Trans> <i className="far fa-smile"></i>
|
||||
<Trans params={{link: `<a href="${window.__taskOptionsDocsLink}" target="_blank">${window.__taskOptionsDocsLink.replace("https://", "")}</a>` }}>{_("\"Process exited with code 1\" means that part of the processing failed. Sometimes it's a problem with the dataset, sometimes it can be solved by tweaking the Task Options. Check the documentation at %(link)s")}</Trans>
|
||||
</div>
|
||||
</div>
|
||||
: ""}
|
||||
|
@ -706,6 +726,7 @@ class TaskListItem extends React.Component {
|
|||
|
||||
let taskActionsIcon = "fa-ellipsis-h";
|
||||
if (actionLoading) taskActionsIcon = "fa-circle-notch fa-spin fa-fw";
|
||||
const userTags = Tags.userTags(task.tags);
|
||||
|
||||
return (
|
||||
<div className="task-list-item">
|
||||
|
@ -719,7 +740,10 @@ class TaskListItem extends React.Component {
|
|||
: ""}
|
||||
<div className="row">
|
||||
<div className="col-sm-5 col-xs-12 name">
|
||||
<i onClick={this.toggleExpanded} className={"clickable far " + (this.state.expanded ? "fa-minus-square" : " fa-plus-square")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded}>{name}</a>
|
||||
<i onClick={this.toggleExpanded} className={"clickable far " + (this.state.expanded ? "fa-minus-square" : " fa-plus-square")}></i> <a href="javascript:void(0);" onClick={this.toggleExpanded} className="name-link">{name}</a>
|
||||
{userTags.length > 0 ?
|
||||
userTags.map((t, i) => <div key={i} className="tag-badge small-badge" onClick={this.handleTagClick(t)}>{t}</div>)
|
||||
: ""}
|
||||
</div>
|
||||
<div className="col-sm-1 col-xs-5 details">
|
||||
<i className="far fa-image"></i> {task.images_count}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { systems, getUnitSystem, setUnitSystem } from '../classes/Units';
|
||||
import '../css/UnitSelector.scss';
|
||||
|
||||
class UnitSelector extends React.Component {
|
||||
static propTypes = {
|
||||
}
|
||||
|
||||
constructor(props){
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
system: getUnitSystem()
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = e => {
|
||||
this.setState({system: e.target.value});
|
||||
setUnitSystem(e.target.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<select className="unit-selector" value={this.state.system} onChange={this.handleChange}>
|
||||
{Object.keys(systems).map(k =>
|
||||
<option value={k} key={k}>{systems[k].getName()}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UnitSelector;
|
|
@ -2,6 +2,7 @@ import '../css/UploadProgressBar.scss';
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _, interpolate } from '../classes/gettext';
|
||||
import Utils from '../classes/Utils';
|
||||
|
||||
class UploadProgressBar extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -11,22 +12,12 @@ class UploadProgressBar extends React.Component {
|
|||
totalCount: PropTypes.number // number of files
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
|
||||
bytesToSize(bytes, decimals = 2){
|
||||
if(bytes == 0) return '0 byte';
|
||||
var k = 1000; // or 1024 for binary
|
||||
var dm = decimals || 3;
|
||||
var sizes = ['bytes', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
render() {
|
||||
let percentage = (this.props.progress !== undefined ?
|
||||
this.props.progress :
|
||||
0).toFixed(2);
|
||||
let bytes = this.props.totalBytesSent !== undefined && this.props.totalBytes !== undefined ?
|
||||
' ' + interpolate(_("remaining to upload: %(bytes)s"), { bytes: this.bytesToSize(this.props.totalBytes - this.props.totalBytesSent)}) :
|
||||
' ' + interpolate(_("remaining to upload: %(bytes)s"), { bytes: Utils.bytesToSize(this.props.totalBytes - this.props.totalBytesSent)}) :
|
||||
"";
|
||||
|
||||
let active = percentage < 100 ? "active" : "";
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import SortPanel from '../SortPanel';
|
||||
|
||||
var sortItems = [{
|
||||
key: "created_at",
|
||||
label: "Created on"
|
||||
}];
|
||||
|
||||
describe('<SortPanel />', () => {
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = shallow(<SortPanel items={sortItems} selected="created_at" />);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
})
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import TagsField from '../TagsField';
|
||||
|
||||
describe('<TagsField />', () => {
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = shallow(<TagsField tags={["abc"]} />);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
})
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import UnitSelector from '../UnitSelector';
|
||||
|
||||
describe('<UnitSelector />', () => {
|
||||
it('renders without exploding', () => {
|
||||
const wrapper = shallow(<UnitSelector />);
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
})
|
||||
});
|
|
@ -0,0 +1,122 @@
|
|||
import { systems, toMetric } from '../../classes/Units';
|
||||
|
||||
describe('Metric system', () => {
|
||||
it('it should display units properly', () => {
|
||||
|
||||
const { metric } = systems;
|
||||
|
||||
const lengths = [
|
||||
[1, "1 m"],
|
||||
[0.01, "1 cm"],
|
||||
[0.0154, "1.5 cm"],
|
||||
[0.99, "99 cm"],
|
||||
[0.995555, "99.6 cm"],
|
||||
[1.01, "1.01 m"],
|
||||
[999, "999 m"],
|
||||
[1000, "1 km"],
|
||||
[1001, "1.001 km"],
|
||||
[1000010, "1,000.01 km"],
|
||||
[1000012.349, "1,000.01235 km"],
|
||||
];
|
||||
|
||||
lengths.forEach(l => {
|
||||
expect(metric.length(l[0]).toString()).toBe(l[1]);
|
||||
});
|
||||
|
||||
const areas = [
|
||||
[1, "1 m²"],
|
||||
[9999, "9,999 m²"],
|
||||
[10000, "1 ha"],
|
||||
[11005, "1.1005 ha"],
|
||||
[11005, "1.1005 ha"],
|
||||
[999999, "99.9999 ha"],
|
||||
[1000000, "1 km²"],
|
||||
[1000000000, "1,000 km²"],
|
||||
[1000255558, "1,000.25556 km²"]
|
||||
];
|
||||
|
||||
areas.forEach(a => {
|
||||
expect(metric.area(a[0]).toString()).toBe(a[1]);
|
||||
});
|
||||
|
||||
const volumes = [
|
||||
[1, "1 m³"],
|
||||
[9000, "9,000 m³"],
|
||||
[9000.25559, "9,000.2556 m³"],
|
||||
];
|
||||
|
||||
volumes.forEach(v => {
|
||||
expect(metric.volume(v[0]).toString()).toBe(v[1]);
|
||||
});
|
||||
|
||||
expect(metric.area(11005.09, { fixedUnit: true }).toString({precision: 1})).toBe("11,005.1 m²");
|
||||
})
|
||||
});
|
||||
|
||||
describe('Imperial systems', () => {
|
||||
it('it should display units properly', () => {
|
||||
|
||||
const { imperial, imperialUS } = systems;
|
||||
|
||||
const lengths = [
|
||||
[1, "3.2808 ft", "3.2808 ft (US)"],
|
||||
[0.01, "0.0328 ft", "0.0328 ft (US)"],
|
||||
[0.0154, "0.0505 ft", "0.0505 ft (US)"],
|
||||
[1609, "5,278.8714 ft", "5,278.8608 ft (US)"],
|
||||
[1609.344, "1 mi", "5,279.9894 ft (US)"],
|
||||
[1609.3472187, "1 mi", "1 mi (US)"],
|
||||
[3218.69, "2 mi", "2 mi (US)"]
|
||||
];
|
||||
|
||||
lengths.forEach(l => {
|
||||
expect(imperial.length(l[0]).toString()).toBe(l[1]);
|
||||
expect(imperialUS.length(l[0]).toString()).toBe(l[2]);
|
||||
});
|
||||
|
||||
const areas = [
|
||||
[1, "10.76 ft²", "10.76 ft² (US)"],
|
||||
[9999, "2.47081 ac", "2.4708 ac (US)"],
|
||||
[4046.86, "1 ac", "43,559.86 ft² (US)"],
|
||||
[4046.87261, "1 ac", "1 ac (US)"],
|
||||
[2587398.1, "639.35999 ac", "639.35744 ac (US)"],
|
||||
[2.59e+6, "1 mi²", "1 mi² (US)"]
|
||||
];
|
||||
|
||||
areas.forEach(a => {
|
||||
expect(imperial.area(a[0]).toString()).toBe(a[1]);
|
||||
expect(imperialUS.area(a[0]).toString()).toBe(a[2]);
|
||||
});
|
||||
|
||||
const volumes = [
|
||||
[1, "1.308 yd³", "1.3079 yd³ (US)"],
|
||||
[1000, "1,307.9506 yd³", "1,307.9428 yd³ (US)"]
|
||||
];
|
||||
|
||||
volumes.forEach(v => {
|
||||
expect(imperial.volume(v[0]).toString()).toBe(v[1]);
|
||||
expect(imperialUS.volume(v[0]).toString()).toBe(v[2]);
|
||||
});
|
||||
|
||||
expect(imperial.area(9999, { fixedUnit: true }).toString({precision: 1})).toBe("107,628.3 ft²");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metric conversion', () => {
|
||||
it('it should convert units properly', () => {
|
||||
const { metric, imperial } = systems;
|
||||
|
||||
const km = metric.length(2000);
|
||||
const mi = imperial.length(3220);
|
||||
|
||||
expect(km.unit.abbr).toBe("km");
|
||||
expect(km.value).toBe(2);
|
||||
expect(mi.unit.abbr).toBe("mi");
|
||||
expect(Math.round(mi.value)).toBe(2)
|
||||
|
||||
expect(toMetric(km).toString()).toBe("2,000 m");
|
||||
expect(toMetric(mi).toString()).toBe("3,220 m");
|
||||
|
||||
expect(toMetric(km).value).toBe(2000);
|
||||
expect(toMetric(mi).value).toBe(3220);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
.edit-project-dialog{
|
||||
.name-fields{
|
||||
display: flex;
|
||||
.btn.toggle-tags{
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
input[type="text"]{
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,8 +28,22 @@
|
|||
|
||||
.name-loading{
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
right: 60px;
|
||||
top: 15px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.name-fields{
|
||||
display: flex;
|
||||
.btn.toggle-tags{
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
input[type="text"]{
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,11 @@
|
|||
margin-left: -100px;
|
||||
z-index: 999;
|
||||
padding-bottom: 6px;
|
||||
.opacity-slider-label{
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-layers-toggle, .leaflet-control-layers-toggle{
|
||||
|
|
|
@ -41,4 +41,9 @@
|
|||
opacity: 0.8;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
button.redo{
|
||||
margin-top: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
.paginator{
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.toolbar{
|
||||
i{
|
||||
opacity: 0.8;
|
||||
}
|
||||
margin-right: 8px;
|
||||
&.no-margin{
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.btn-group.open > .dropdown-menu{
|
||||
top: 22px;
|
||||
a{
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search{
|
||||
height: 25px;
|
||||
margin-left: 7px;
|
||||
margin-right: 4px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
border-width: 1px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
.search-popup{
|
||||
min-width: 256px;
|
||||
|
||||
li{
|
||||
display: flex;
|
||||
button{
|
||||
width: 27px;
|
||||
height: 25px;
|
||||
i{
|
||||
position: relative;
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search{
|
||||
margin-top: 1px;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
.query{
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,4 +24,13 @@
|
|||
.tooltip .tooltip-inner{
|
||||
padding: 3px 16px;
|
||||
}
|
||||
|
||||
.alert{
|
||||
padding: 2px 4px 2px 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.help-button:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.project-description{
|
||||
min-height: 12px;
|
||||
}
|
||||
|
||||
.drag-drop-icon{
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
@ -97,4 +101,71 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-filters{
|
||||
float: right;
|
||||
}
|
||||
|
||||
.tag-badge.small-badge {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
margin-left: 4px;
|
||||
margin-top: -2px;
|
||||
border-radius: 6px;
|
||||
font-size: 90%;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
&:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown{
|
||||
max-width: 320px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.filter-text{
|
||||
height: 25px;
|
||||
margin-left: 7px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
border-width: 1px;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-text-container,.tag-selection{
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filter-checkbox{
|
||||
margin-left: 8px;
|
||||
}
|
||||
.filter-checkbox-label{
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clear-container{
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.quick-clear-filter{
|
||||
margin-right: 6px !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
.sort-items{
|
||||
.sort-order-label{
|
||||
opacity: 0.7;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
a{
|
||||
margin-right: 0 !important;
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
a:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fa-check{
|
||||
font-size: 80%;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
.tags-field{
|
||||
height: auto;
|
||||
padding-bottom: 2px;
|
||||
|
||||
&:hover{
|
||||
cursor: text;
|
||||
}
|
||||
.tag-badge{
|
||||
&:hover{
|
||||
cursor: grab;
|
||||
}
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
padding-left: 6px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
margin-top: -2px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
a{
|
||||
margin-top: 2px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
a:hover, a:focus, a:active{
|
||||
cursor: pointer;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
.inputText{
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
border: none;
|
||||
margin-bottom: 10px;
|
||||
min-width: 1px;
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue