igs-slm 0.1.0b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (447) hide show
  1. igs_slm-0.1.0b0.dist-info/LICENSE +21 -0
  2. igs_slm-0.1.0b0.dist-info/METADATA +151 -0
  3. igs_slm-0.1.0b0.dist-info/RECORD +447 -0
  4. igs_slm-0.1.0b0.dist-info/WHEEL +4 -0
  5. igs_slm-0.1.0b0.dist-info/entry_points.txt +3 -0
  6. igs_tools/__init__.py +0 -0
  7. igs_tools/connection.py +88 -0
  8. igs_tools/defines/__init__.py +8 -0
  9. igs_tools/defines/constellation.py +21 -0
  10. igs_tools/defines/data_center.py +75 -0
  11. igs_tools/defines/rinex.py +49 -0
  12. igs_tools/directory.py +247 -0
  13. igs_tools/utils.py +66 -0
  14. slm/__init__.py +21 -0
  15. slm/admin.py +674 -0
  16. slm/api/edit/__init__.py +0 -0
  17. slm/api/edit/serializers.py +316 -0
  18. slm/api/edit/views.py +1632 -0
  19. slm/api/fields.py +89 -0
  20. slm/api/filter.py +504 -0
  21. slm/api/pagination.py +55 -0
  22. slm/api/permissions.py +65 -0
  23. slm/api/public/__init__.py +0 -0
  24. slm/api/public/serializers.py +249 -0
  25. slm/api/public/views.py +606 -0
  26. slm/api/serializers.py +132 -0
  27. slm/api/views.py +148 -0
  28. slm/apps.py +323 -0
  29. slm/authentication.py +198 -0
  30. slm/bin/__init__.py +0 -0
  31. slm/bin/startproject.py +262 -0
  32. slm/bin/templates/{{ project_dir }}/pyproject.toml +35 -0
  33. slm/bin/templates/{{ project_dir }}/sites/__init__.py +0 -0
  34. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/__init__.py +0 -0
  35. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/base.py +15 -0
  36. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/develop/__init__.py +56 -0
  37. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/develop/local.py +4 -0
  38. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/develop/wsgi.py +16 -0
  39. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/manage.py +34 -0
  40. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/production/__init__.py +61 -0
  41. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/production/wsgi.py +16 -0
  42. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/urls.py +7 -0
  43. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/validation.py +11 -0
  44. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/__init__.py +0 -0
  45. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/admin.py +5 -0
  46. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/apps.py +14 -0
  47. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/management/__init__.py +0 -0
  48. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/management/commands/__init__.py +0 -0
  49. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/management/commands/import_archive.py +64 -0
  50. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/migrations/__init__.py +0 -0
  51. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/models.py +6 -0
  52. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/templates/slm/base.html +8 -0
  53. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/urls.py +10 -0
  54. slm/bin/templates/{{ project_dir }}/{{ extension_app }}/views.py +5 -0
  55. slm/defines/AlertLevel.py +24 -0
  56. slm/defines/AntennaCalibration.py +25 -0
  57. slm/defines/AntennaFeatures.py +27 -0
  58. slm/defines/AntennaReferencePoint.py +22 -0
  59. slm/defines/Aspiration.py +13 -0
  60. slm/defines/CardinalDirection.py +19 -0
  61. slm/defines/CollocationStatus.py +12 -0
  62. slm/defines/EquipmentState.py +22 -0
  63. slm/defines/FlagSeverity.py +14 -0
  64. slm/defines/FractureSpacing.py +15 -0
  65. slm/defines/FrequencyStandardType.py +15 -0
  66. slm/defines/GeodesyMLVersion.py +48 -0
  67. slm/defines/ISOCountry.py +1194 -0
  68. slm/defines/Instrumentation.py +19 -0
  69. slm/defines/LogEntryType.py +30 -0
  70. slm/defines/SLMFileType.py +18 -0
  71. slm/defines/SiteFileUploadStatus.py +61 -0
  72. slm/defines/SiteLogFormat.py +49 -0
  73. slm/defines/SiteLogStatus.py +78 -0
  74. slm/defines/TectonicPlates.py +28 -0
  75. slm/defines/__init__.py +46 -0
  76. slm/forms.py +1126 -0
  77. slm/jinja2/slm/sitelog/ascii_9char.log +346 -0
  78. slm/jinja2/slm/sitelog/legacy.log +346 -0
  79. slm/jinja2/slm/sitelog/xsd/0.4/collocationInformation.xml +12 -0
  80. slm/jinja2/slm/sitelog/xsd/0.4/condition.xml +12 -0
  81. slm/jinja2/slm/sitelog/xsd/0.4/contact.xml +52 -0
  82. slm/jinja2/slm/sitelog/xsd/0.4/formInformation.xml +5 -0
  83. slm/jinja2/slm/sitelog/xsd/0.4/frequencyStandard.xml +12 -0
  84. slm/jinja2/slm/sitelog/xsd/0.4/gnssAntenna.xml +16 -0
  85. slm/jinja2/slm/sitelog/xsd/0.4/gnssReceiver.xml +11 -0
  86. slm/jinja2/slm/sitelog/xsd/0.4/humiditySensor.xml +13 -0
  87. slm/jinja2/slm/sitelog/xsd/0.4/localEpisodicEffect.xml +10 -0
  88. slm/jinja2/slm/sitelog/xsd/0.4/moreInformation.xml +22 -0
  89. slm/jinja2/slm/sitelog/xsd/0.4/multipathSource.xml +10 -0
  90. slm/jinja2/slm/sitelog/xsd/0.4/otherInstrumentation.xml +5 -0
  91. slm/jinja2/slm/sitelog/xsd/0.4/pressureSensor.xml +12 -0
  92. slm/jinja2/slm/sitelog/xsd/0.4/radioInterference.xml +11 -0
  93. slm/jinja2/slm/sitelog/xsd/0.4/sensor.xml +16 -0
  94. slm/jinja2/slm/sitelog/xsd/0.4/signalObstruction.xml +10 -0
  95. slm/jinja2/slm/sitelog/xsd/0.4/siteIdentification.xml +22 -0
  96. slm/jinja2/slm/sitelog/xsd/0.4/siteLocation.xml +21 -0
  97. slm/jinja2/slm/sitelog/xsd/0.4/surveyedLocalTie.xml +20 -0
  98. slm/jinja2/slm/sitelog/xsd/0.4/temperatureSensor.xml +13 -0
  99. slm/jinja2/slm/sitelog/xsd/0.4/waterVaporSensor.xml +11 -0
  100. slm/jinja2/slm/sitelog/xsd/0.5/document.xml +10 -0
  101. slm/jinja2/slm/sitelog/xsd/geodesyml_0.4.xml +99 -0
  102. slm/jinja2/slm/sitelog/xsd/geodesyml_0.5.xml +112 -0
  103. slm/management/__init__.py +0 -0
  104. slm/management/commands/__init__.py +53 -0
  105. slm/management/commands/build_index.py +96 -0
  106. slm/management/commands/generate_sinex.py +675 -0
  107. slm/management/commands/head_from_index.py +541 -0
  108. slm/management/commands/import_archive.py +908 -0
  109. slm/management/commands/import_equipment.py +351 -0
  110. slm/management/commands/set_site.py +56 -0
  111. slm/management/commands/sitelog.py +144 -0
  112. slm/management/commands/synchronize.py +60 -0
  113. slm/management/commands/update_data_availability.py +167 -0
  114. slm/management/commands/validate_db.py +186 -0
  115. slm/management/commands/validate_gml.py +73 -0
  116. slm/map/__init__.py +1 -0
  117. slm/map/admin.py +5 -0
  118. slm/map/api/__init__.py +0 -0
  119. slm/map/api/edit/__init__.py +0 -0
  120. slm/map/api/edit/serializers.py +28 -0
  121. slm/map/api/edit/views.py +46 -0
  122. slm/map/api/public/__init__.py +0 -0
  123. slm/map/api/public/serializers.py +29 -0
  124. slm/map/api/public/views.py +64 -0
  125. slm/map/apps.py +7 -0
  126. slm/map/defines.py +53 -0
  127. slm/map/migrations/0001_initial.py +115 -0
  128. slm/map/migrations/__init__.py +0 -0
  129. slm/map/models.py +63 -0
  130. slm/map/static/slm/css/map.css +86 -0
  131. slm/map/static/slm/js/map.js +159 -0
  132. slm/map/templates/slm/map.html +374 -0
  133. slm/map/templates/slm/station/base.html +11 -0
  134. slm/map/templates/slm/station/edit.html +10 -0
  135. slm/map/templates/slm/top_nav.html +17 -0
  136. slm/map/templatetags/__init__.py +0 -0
  137. slm/map/templatetags/slm_map.py +18 -0
  138. slm/map/urls.py +25 -0
  139. slm/map/views.py +36 -0
  140. slm/middleware.py +29 -0
  141. slm/migrations/0001_alter_siteantenna_marker_enu_alter_sitelocation_llh_and_more.py +47 -0
  142. slm/migrations/0001_initial.py +4826 -0
  143. slm/migrations/0002_alter_dataavailability_site.py +22 -0
  144. slm/migrations/0003_remove_logentry_slm_logentr_site_lo_7a2af7_idx_and_more.py +80 -0
  145. slm/migrations/0004_alter_logentry_timestamp_and_more.py +25 -0
  146. slm/migrations/0005_alter_logentry_options_alter_logentry_section_and_more.py +46 -0
  147. slm/migrations/0006_alter_logentry_options_alter_logentry_index_together.py +24 -0
  148. slm/migrations/0007_alter_dataavailability_rate.py +23 -0
  149. slm/migrations/0008_alter_archiveindex_options_and_more.py +64 -0
  150. slm/migrations/0009_alter_archiveindex_end.py +21 -0
  151. slm/migrations/0010_alter_dataavailability_rinex_version_and_more.py +844 -0
  152. slm/migrations/0011_alter_siteidentification_fracture_spacing.py +33 -0
  153. slm/migrations/0012_alter_logentry_type.py +36 -0
  154. slm/migrations/0013_unpublishedfilesalert.py +48 -0
  155. slm/migrations/0014_sitelogpublished.py +48 -0
  156. slm/migrations/0015_alter_siteantenna_options_and_more.py +181 -0
  157. slm/migrations/0016_alter_antenna_description_alter_radome_description_and_more.py +42 -0
  158. slm/migrations/0017_alter_logentry_unique_together_and_more.py +54 -0
  159. slm/migrations/0018_afix_deleted.py +34 -0
  160. slm/migrations/0018_alter_siteantenna_options_and_more.py +244 -0
  161. slm/migrations/0019_remove_siteantenna_marker_enu_siteantenna_marker_une_and_more.py +101 -0
  162. slm/migrations/0020_alter_manufacturer_options.py +16 -0
  163. slm/migrations/0021_alter_siteform_report_type.py +23 -0
  164. slm/migrations/0022_rename_antcal_antenna_radome_slm_antcal_antenna_20827a_idx_and_more.py +297 -0
  165. slm/migrations/0023_archivedsitelog_gml_version_and_more.py +55 -0
  166. slm/migrations/0024_alter_agency_name_alter_agency_shortname.py +24 -0
  167. slm/migrations/0025_alter_archivedsitelog_log_format_and_more.py +61 -0
  168. slm/migrations/0026_alter_archivedsitelog_log_format_and_more.py +61 -0
  169. slm/migrations/0027_importalert_file_contents_importalert_findings_and_more.py +41 -0
  170. slm/migrations/0028_antenna_replaced_manufacturer_url_radome_replaced_and_more.py +46 -0
  171. slm/migrations/0029_manufacturer_full_name.py +17 -0
  172. slm/migrations/0030_alter_antenna_state_alter_radome_state_and_more.py +43 -0
  173. slm/migrations/__init__.py +0 -0
  174. slm/migrations/load_satellitesystems.py +27 -0
  175. slm/models/__init__.py +118 -0
  176. slm/models/about.py +14 -0
  177. slm/models/alerts.py +1204 -0
  178. slm/models/data.py +58 -0
  179. slm/models/equipment.py +229 -0
  180. slm/models/help.py +14 -0
  181. slm/models/index.py +428 -0
  182. slm/models/sitelog.py +4279 -0
  183. slm/models/system.py +723 -0
  184. slm/models/user.py +304 -0
  185. slm/parsing/__init__.py +786 -0
  186. slm/parsing/legacy/__init__.py +4 -0
  187. slm/parsing/legacy/binding.py +817 -0
  188. slm/parsing/legacy/parser.py +377 -0
  189. slm/parsing/xsd/__init__.py +34 -0
  190. slm/parsing/xsd/binding.py +86 -0
  191. slm/parsing/xsd/geodesyml/0.4/commonTypes.xsd +133 -0
  192. slm/parsing/xsd/geodesyml/0.4/contact.xsd +29 -0
  193. slm/parsing/xsd/geodesyml/0.4/dataStreams.xsd +129 -0
  194. slm/parsing/xsd/geodesyml/0.4/document.xsd +64 -0
  195. slm/parsing/xsd/geodesyml/0.4/equipment.xsd +427 -0
  196. slm/parsing/xsd/geodesyml/0.4/fieldMeasurement.xsd +170 -0
  197. slm/parsing/xsd/geodesyml/0.4/geodesyML.xsd +71 -0
  198. slm/parsing/xsd/geodesyml/0.4/geodeticEquipment.xsd +343 -0
  199. slm/parsing/xsd/geodesyml/0.4/geodeticMonument.xsd +147 -0
  200. slm/parsing/xsd/geodesyml/0.4/lineage.xsd +614 -0
  201. slm/parsing/xsd/geodesyml/0.4/localInterferences.xsd +131 -0
  202. slm/parsing/xsd/geodesyml/0.4/measurement.xsd +473 -0
  203. slm/parsing/xsd/geodesyml/0.4/monumentInfo.xsd +251 -0
  204. slm/parsing/xsd/geodesyml/0.4/observationSystem.xsd +429 -0
  205. slm/parsing/xsd/geodesyml/0.4/project.xsd +38 -0
  206. slm/parsing/xsd/geodesyml/0.4/quality.xsd +176 -0
  207. slm/parsing/xsd/geodesyml/0.4/referenceFrame.xsd +194 -0
  208. slm/parsing/xsd/geodesyml/0.4/siteLog.xsd +71 -0
  209. slm/parsing/xsd/geodesyml/0.5/commonTypes.xsd +133 -0
  210. slm/parsing/xsd/geodesyml/0.5/contact.xsd +29 -0
  211. slm/parsing/xsd/geodesyml/0.5/dataStreams.xsd +129 -0
  212. slm/parsing/xsd/geodesyml/0.5/document.xsd +64 -0
  213. slm/parsing/xsd/geodesyml/0.5/equipment.xsd +427 -0
  214. slm/parsing/xsd/geodesyml/0.5/fieldMeasurement.xsd +170 -0
  215. slm/parsing/xsd/geodesyml/0.5/geodesyML.xsd +71 -0
  216. slm/parsing/xsd/geodesyml/0.5/geodeticEquipment.xsd +343 -0
  217. slm/parsing/xsd/geodesyml/0.5/geodeticMonument.xsd +147 -0
  218. slm/parsing/xsd/geodesyml/0.5/lineage.xsd +614 -0
  219. slm/parsing/xsd/geodesyml/0.5/localInterferences.xsd +131 -0
  220. slm/parsing/xsd/geodesyml/0.5/measurement.xsd +473 -0
  221. slm/parsing/xsd/geodesyml/0.5/monumentInfo.xsd +306 -0
  222. slm/parsing/xsd/geodesyml/0.5/observationSystem.xsd +429 -0
  223. slm/parsing/xsd/geodesyml/0.5/project.xsd +38 -0
  224. slm/parsing/xsd/geodesyml/0.5/quality.xsd +176 -0
  225. slm/parsing/xsd/geodesyml/0.5/referenceFrame.xsd +194 -0
  226. slm/parsing/xsd/geodesyml/0.5/siteLog.xsd +73 -0
  227. slm/parsing/xsd/parser.py +116 -0
  228. slm/parsing/xsd/resolver.py +28 -0
  229. slm/receivers/__init__.py +11 -0
  230. slm/receivers/alerts.py +87 -0
  231. slm/receivers/cleanup.py +41 -0
  232. slm/receivers/event_loggers.py +175 -0
  233. slm/receivers/index.py +67 -0
  234. slm/settings/__init__.py +55 -0
  235. slm/settings/auth.py +15 -0
  236. slm/settings/ckeditor.py +14 -0
  237. slm/settings/debug.py +47 -0
  238. slm/settings/internationalization.py +12 -0
  239. slm/settings/logging.py +113 -0
  240. slm/settings/platform/__init__.py +0 -0
  241. slm/settings/platform/darwin.py +10 -0
  242. slm/settings/rest.py +21 -0
  243. slm/settings/root.py +152 -0
  244. slm/settings/routines.py +43 -0
  245. slm/settings/secrets.py +37 -0
  246. slm/settings/security.py +5 -0
  247. slm/settings/slm.py +188 -0
  248. slm/settings/static_templates.py +53 -0
  249. slm/settings/templates.py +29 -0
  250. slm/settings/uploads.py +8 -0
  251. slm/settings/urls.py +126 -0
  252. slm/settings/validation.py +196 -0
  253. slm/signals.py +250 -0
  254. slm/singleton.py +49 -0
  255. slm/static/rest_framework/css/bootstrap-tweaks.css +204 -0
  256. slm/static/rest_framework/css/bootstrap.min.css +7 -0
  257. slm/static/rest_framework/css/bootstrap.min.css.map +1 -0
  258. slm/static/rest_framework/css/default.css +82 -0
  259. slm/static/rest_framework/css/prettify.css +30 -0
  260. slm/static/rest_framework/docs/css/base.css +344 -0
  261. slm/static/rest_framework/docs/css/highlight.css +125 -0
  262. slm/static/rest_framework/docs/css/jquery.json-view.min.css +11 -0
  263. slm/static/rest_framework/docs/img/favicon.ico +0 -0
  264. slm/static/rest_framework/docs/img/grid.png +0 -0
  265. slm/static/rest_framework/docs/js/api.js +321 -0
  266. slm/static/rest_framework/docs/js/highlight.pack.js +2 -0
  267. slm/static/rest_framework/docs/js/jquery.json-view.min.js +7 -0
  268. slm/static/rest_framework/img/grid.png +0 -0
  269. slm/static/rest_framework/js/ajax-form.js +127 -0
  270. slm/static/rest_framework/js/bootstrap.bundle.min.js +7 -0
  271. slm/static/rest_framework/js/bootstrap.bundle.min.js.map +1 -0
  272. slm/static/rest_framework/js/bootstrap.min.js.map +1 -0
  273. slm/static/rest_framework/js/coreapi-0.1.1.js +2043 -0
  274. slm/static/rest_framework/js/csrf.js +52 -0
  275. slm/static/rest_framework/js/default.js +47 -0
  276. slm/static/rest_framework/js/jquery-3.5.1.min.js +2 -0
  277. slm/static/rest_framework/js/prettify-min.js +28 -0
  278. slm/static/slm/css/admin.css +3 -0
  279. slm/static/slm/css/defines.css +82 -0
  280. slm/static/slm/css/forms.css +1 -0
  281. slm/static/slm/css/style.css +1004 -0
  282. slm/static/slm/img/email-branding.png +0 -0
  283. slm/static/slm/img/favicon.ico +0 -0
  284. slm/static/slm/img/login-bg.jpg +0 -0
  285. slm/static/slm/img/slm-logo.svg +4 -0
  286. slm/static/slm/js/autocomplete.js +341 -0
  287. slm/static/slm/js/enums.js +322 -0
  288. slm/static/slm/js/fileIcons.js +30 -0
  289. slm/static/slm/js/form.js +404 -0
  290. slm/static/slm/js/formWidget.js +23 -0
  291. slm/static/slm/js/persistable.js +33 -0
  292. slm/static/slm/js/slm.js +1028 -0
  293. slm/static/slm/js/time24.js +212 -0
  294. slm/static_templates/slm/css/defines.css +26 -0
  295. slm/static_templates/slm/js/enums.js +28 -0
  296. slm/static_templates/slm/js/fileIcons.js +16 -0
  297. slm/static_templates/slm/js/urls.js +5 -0
  298. slm/templates/account/base.html +20 -0
  299. slm/templates/account/email/base.html +43 -0
  300. slm/templates/account/email/base_message.txt +7 -0
  301. slm/templates/account/email/email_confirmation_message.html +16 -0
  302. slm/templates/account/email/email_confirmation_message.txt +7 -0
  303. slm/templates/account/email/email_confirmation_signup_message.html +1 -0
  304. slm/templates/account/email/email_confirmation_signup_message.txt +1 -0
  305. slm/templates/account/email/email_confirmation_signup_subject.txt +1 -0
  306. slm/templates/account/email/email_confirmation_subject.txt +4 -0
  307. slm/templates/account/email/password_reset_key_message.html +28 -0
  308. slm/templates/account/email/password_reset_key_message.txt +9 -0
  309. slm/templates/account/email/password_reset_key_subject.txt +4 -0
  310. slm/templates/account/email/unknown_account_message.html +25 -0
  311. slm/templates/account/email/unknown_account_message.txt +12 -0
  312. slm/templates/account/email/unknown_account_subject.txt +4 -0
  313. slm/templates/account/login.html +67 -0
  314. slm/templates/account/logout.html +38 -0
  315. slm/templates/account/password_change.html +48 -0
  316. slm/templates/account/password_reset.html +51 -0
  317. slm/templates/account/password_reset_done.html +20 -0
  318. slm/templates/account/password_reset_from_key.html +52 -0
  319. slm/templates/account/password_reset_from_key_done.html +17 -0
  320. slm/templates/admin/base.html +7 -0
  321. slm/templates/messages.html +8 -0
  322. slm/templates/rest_framework/README +16 -0
  323. slm/templates/rest_framework/admin/detail.html +10 -0
  324. slm/templates/rest_framework/admin/dict_value.html +11 -0
  325. slm/templates/rest_framework/admin/list.html +32 -0
  326. slm/templates/rest_framework/admin/list_value.html +11 -0
  327. slm/templates/rest_framework/admin/simple_list_value.html +2 -0
  328. slm/templates/rest_framework/admin.html +282 -0
  329. slm/templates/rest_framework/api.html +3 -0
  330. slm/templates/rest_framework/base.html +334 -0
  331. slm/templates/rest_framework/docs/auth/basic.html +42 -0
  332. slm/templates/rest_framework/docs/auth/session.html +40 -0
  333. slm/templates/rest_framework/docs/auth/token.html +41 -0
  334. slm/templates/rest_framework/docs/document.html +37 -0
  335. slm/templates/rest_framework/docs/error.html +71 -0
  336. slm/templates/rest_framework/docs/index.html +55 -0
  337. slm/templates/rest_framework/docs/interact.html +57 -0
  338. slm/templates/rest_framework/docs/langs/javascript-intro.html +5 -0
  339. slm/templates/rest_framework/docs/langs/javascript.html +15 -0
  340. slm/templates/rest_framework/docs/langs/python-intro.html +3 -0
  341. slm/templates/rest_framework/docs/langs/python.html +13 -0
  342. slm/templates/rest_framework/docs/langs/shell-intro.html +3 -0
  343. slm/templates/rest_framework/docs/langs/shell.html +6 -0
  344. slm/templates/rest_framework/docs/link.html +113 -0
  345. slm/templates/rest_framework/docs/sidebar.html +78 -0
  346. slm/templates/rest_framework/filters/base.html +16 -0
  347. slm/templates/rest_framework/filters/ordering.html +17 -0
  348. slm/templates/rest_framework/filters/search.html +13 -0
  349. slm/templates/rest_framework/horizontal/checkbox.html +23 -0
  350. slm/templates/rest_framework/horizontal/checkbox_multiple.html +32 -0
  351. slm/templates/rest_framework/horizontal/dict_field.html +11 -0
  352. slm/templates/rest_framework/horizontal/fieldset.html +16 -0
  353. slm/templates/rest_framework/horizontal/form.html +6 -0
  354. slm/templates/rest_framework/horizontal/input.html +21 -0
  355. slm/templates/rest_framework/horizontal/list_field.html +11 -0
  356. slm/templates/rest_framework/horizontal/list_fieldset.html +13 -0
  357. slm/templates/rest_framework/horizontal/radio.html +42 -0
  358. slm/templates/rest_framework/horizontal/select.html +36 -0
  359. slm/templates/rest_framework/horizontal/select_multiple.html +38 -0
  360. slm/templates/rest_framework/horizontal/textarea.html +21 -0
  361. slm/templates/rest_framework/inline/checkbox.html +8 -0
  362. slm/templates/rest_framework/inline/checkbox_multiple.html +14 -0
  363. slm/templates/rest_framework/inline/dict_field.html +9 -0
  364. slm/templates/rest_framework/inline/fieldset.html +6 -0
  365. slm/templates/rest_framework/inline/form.html +8 -0
  366. slm/templates/rest_framework/inline/input.html +9 -0
  367. slm/templates/rest_framework/inline/list_field.html +9 -0
  368. slm/templates/rest_framework/inline/list_fieldset.html +3 -0
  369. slm/templates/rest_framework/inline/radio.html +25 -0
  370. slm/templates/rest_framework/inline/select.html +24 -0
  371. slm/templates/rest_framework/inline/select_multiple.html +25 -0
  372. slm/templates/rest_framework/inline/textarea.html +9 -0
  373. slm/templates/rest_framework/login.html +3 -0
  374. slm/templates/rest_framework/login_base.html +65 -0
  375. slm/templates/rest_framework/pagination/numbers.html +47 -0
  376. slm/templates/rest_framework/pagination/previous_and_next.html +21 -0
  377. slm/templates/rest_framework/raw_data_form.html +11 -0
  378. slm/templates/rest_framework/schema.js +3 -0
  379. slm/templates/rest_framework/vertical/checkbox.html +16 -0
  380. slm/templates/rest_framework/vertical/checkbox_multiple.html +30 -0
  381. slm/templates/rest_framework/vertical/dict_field.html +7 -0
  382. slm/templates/rest_framework/vertical/fieldset.html +13 -0
  383. slm/templates/rest_framework/vertical/form.html +6 -0
  384. slm/templates/rest_framework/vertical/input.html +17 -0
  385. slm/templates/rest_framework/vertical/list_field.html +7 -0
  386. slm/templates/rest_framework/vertical/list_fieldset.html +7 -0
  387. slm/templates/rest_framework/vertical/radio.html +40 -0
  388. slm/templates/rest_framework/vertical/select.html +34 -0
  389. slm/templates/rest_framework/vertical/select_multiple.html +31 -0
  390. slm/templates/rest_framework/vertical/textarea.html +17 -0
  391. slm/templates/slm/about.html +21 -0
  392. slm/templates/slm/alerts/alert.html +15 -0
  393. slm/templates/slm/alerts/geodesymlinvalid.html +8 -0
  394. slm/templates/slm/alerts/importalert.html +10 -0
  395. slm/templates/slm/alerts.html +18 -0
  396. slm/templates/slm/auth_menu.html +41 -0
  397. slm/templates/slm/base.html +195 -0
  398. slm/templates/slm/emails/alert_issued.html +31 -0
  399. slm/templates/slm/emails/alert_issued.txt +9 -0
  400. slm/templates/slm/emails/base.html +6 -0
  401. slm/templates/slm/emails/changes_rejected.txt +7 -0
  402. slm/templates/slm/emails/review_requested.txt +7 -0
  403. slm/templates/slm/forms/widgets/auto_complete.html +21 -0
  404. slm/templates/slm/forms/widgets/auto_complete_multiple.html +18 -0
  405. slm/templates/slm/forms/widgets/checkbox_multiple.html +6 -0
  406. slm/templates/slm/forms/widgets/inline_multi.html +1 -0
  407. slm/templates/slm/forms/widgets/splitdatetime.html +14 -0
  408. slm/templates/slm/forms/widgets/time24.html +37 -0
  409. slm/templates/slm/help.html +54 -0
  410. slm/templates/slm/messages.html +13 -0
  411. slm/templates/slm/new_site.html +88 -0
  412. slm/templates/slm/profile.html +57 -0
  413. slm/templates/slm/register.html +40 -0
  414. slm/templates/slm/reports/file_log.html +43 -0
  415. slm/templates/slm/reports/head_log.html +23 -0
  416. slm/templates/slm/reports/head_report.html +55 -0
  417. slm/templates/slm/reports/index_log.html +23 -0
  418. slm/templates/slm/reports/index_report.html +71 -0
  419. slm/templates/slm/station/alert.html +8 -0
  420. slm/templates/slm/station/alerts.html +19 -0
  421. slm/templates/slm/station/base.html +104 -0
  422. slm/templates/slm/station/download.html +87 -0
  423. slm/templates/slm/station/edit.html +283 -0
  424. slm/templates/slm/station/form.html +110 -0
  425. slm/templates/slm/station/log.html +18 -0
  426. slm/templates/slm/station/review.html +461 -0
  427. slm/templates/slm/station/upload.html +295 -0
  428. slm/templates/slm/station/uploads/attachment.html +20 -0
  429. slm/templates/slm/station/uploads/geodesyml.html +1 -0
  430. slm/templates/slm/station/uploads/image.html +27 -0
  431. slm/templates/slm/station/uploads/json.html +0 -0
  432. slm/templates/slm/station/uploads/legacy.html +77 -0
  433. slm/templates/slm/top_nav.html +14 -0
  434. slm/templates/slm/user_activity.html +16 -0
  435. slm/templates/slm/widgets/alert_scroll.html +135 -0
  436. slm/templates/slm/widgets/filelist.html +258 -0
  437. slm/templates/slm/widgets/legend.html +12 -0
  438. slm/templates/slm/widgets/log_scroll.html +88 -0
  439. slm/templates/slm/widgets/stationlist.html +233 -0
  440. slm/templatetags/__init__.py +0 -0
  441. slm/templatetags/jinja2.py +9 -0
  442. slm/templatetags/slm.py +459 -0
  443. slm/urls.py +148 -0
  444. slm/utils.py +299 -0
  445. slm/validators.py +297 -0
  446. slm/views.py +654 -0
  447. slm/widgets.py +134 -0
slm/api/edit/views.py ADDED
@@ -0,0 +1,1632 @@
1
+ import json
2
+ from datetime import datetime
3
+ from io import BytesIO
4
+ from logging import getLogger
5
+
6
+ import django_filters
7
+ from chardet import detect
8
+ from crispy_forms.helper import FormHelper
9
+ from crispy_forms.layout import Div, Field, Fieldset, Layout
10
+ from django.contrib.auth import get_user_model
11
+ from django.contrib.gis.db import models as gis_models
12
+ from django.contrib.gis.geos import Point
13
+ from django.core.exceptions import PermissionDenied
14
+ from django.core.exceptions import ValidationError as DjangoValidationError
15
+ from django.db import models, transaction
16
+ from django.db.models import Q
17
+ from django.http import Http404
18
+ from django.http.response import (
19
+ FileResponse,
20
+ HttpResponseForbidden,
21
+ HttpResponseNotFound,
22
+ )
23
+ from django.utils.timezone import now
24
+ from django.utils.translation import gettext as _
25
+ from django_enum.drf import EnumField
26
+ from django_enum.fields import (
27
+ EnumBigIntegerField,
28
+ EnumCharField,
29
+ EnumIntegerField,
30
+ EnumPositiveBigIntegerField,
31
+ EnumPositiveIntegerField,
32
+ EnumPositiveSmallIntegerField,
33
+ EnumSmallIntegerField,
34
+ )
35
+ from django_filters.rest_framework import DjangoFilterBackend, FilterSet
36
+ from rest_framework import mixins, renderers, serializers, status, viewsets
37
+ from rest_framework.exceptions import ValidationError as DRFValidationError
38
+ from rest_framework.filters import OrderingFilter
39
+ from rest_framework.parsers import (
40
+ FileUploadParser,
41
+ FormParser,
42
+ JSONParser,
43
+ MultiPartParser,
44
+ )
45
+ from rest_framework.permissions import IsAuthenticated
46
+ from rest_framework.response import Response
47
+ from rest_framework.serializers import ModelSerializer, SlugRelatedField
48
+
49
+ from slm import signals as slm_signals
50
+ from slm.api.edit.serializers import (
51
+ AlertSerializer,
52
+ LogEntrySerializer,
53
+ ReviewRequestSerializer,
54
+ SiteFileUploadSerializer,
55
+ StationSerializer,
56
+ UserSerializer,
57
+ )
58
+ from slm.api.fields import SLMDateTimeField, SLMPointField
59
+ from slm.api.filter import (
60
+ AcceptListArguments,
61
+ BaseStationFilter,
62
+ CrispyFormCompat,
63
+ SLMBooleanFilter,
64
+ )
65
+ from slm.api.pagination import DataTablesPagination
66
+ from slm.api.permissions import (
67
+ CanDeleteAlert,
68
+ CanEditSite,
69
+ CanRejectReview,
70
+ IsUserOrAdmin,
71
+ UpdateAdminOnly,
72
+ )
73
+ from slm.api.public.views import AgencyViewSet as PublicAgencyViewSet
74
+ from slm.api.public.views import NetworkViewSet as PublicNetworkViewSet
75
+ from slm.api.serializers import SiteLogSerializer
76
+ from slm.api.views import BaseSiteLogDownloadViewSet
77
+ from slm.defines import (
78
+ CardinalDirection,
79
+ SiteFileUploadStatus,
80
+ SiteLogFormat,
81
+ SiteLogStatus,
82
+ SLMFileType,
83
+ )
84
+ from slm.forms import StationFilterForm as BaseStationFilterForm
85
+ from slm.models import (
86
+ Agency,
87
+ Alert,
88
+ LogEntry,
89
+ Network,
90
+ Site,
91
+ SiteAntenna,
92
+ SiteCollocation,
93
+ SiteFileUpload,
94
+ SiteForm,
95
+ SiteFrequencyStandard,
96
+ SiteHumiditySensor,
97
+ SiteIdentification,
98
+ SiteLocalEpisodicEffects,
99
+ SiteLocation,
100
+ SiteMoreInformation,
101
+ SiteMultiPathSources,
102
+ SiteOperationalContact,
103
+ SiteOtherInstrumentation,
104
+ SitePressureSensor,
105
+ SiteRadioInterferences,
106
+ SiteReceiver,
107
+ SiteResponsibleAgency,
108
+ SiteSection,
109
+ SiteSignalObstructions,
110
+ SiteSubSection,
111
+ SiteSurveyedLocalTies,
112
+ SiteTemperatureSensor,
113
+ SiteWaterVaporRadiometer,
114
+ )
115
+ from slm.parsing.legacy.parser import Error, Warn
116
+
117
+
118
+ class StationFilterForm(BaseStationFilterForm):
119
+ @property
120
+ def helper(self):
121
+ """
122
+ Todo - how to render help_text as alt or titles?
123
+ """
124
+ helper = FormHelper()
125
+ helper.form_id = "slm-station-filter"
126
+ helper.disable_csrf = True
127
+ helper.layout = Layout(
128
+ Div(
129
+ Div(
130
+ Field("status", css_class="slm-status"),
131
+ "alert",
132
+ Field("alert_level", css_class="slm-alert-level"),
133
+ css_class="col-3",
134
+ ),
135
+ Div(
136
+ Fieldset(
137
+ _("Equipment Filters"),
138
+ Field(
139
+ "current",
140
+ css_class="form-check-input",
141
+ wrapper_class="form-check form-switch",
142
+ ),
143
+ "receiver",
144
+ "antenna",
145
+ "radome",
146
+ css_class="slm-form-group",
147
+ ),
148
+ css_class="col-4",
149
+ ),
150
+ Div(
151
+ "agency",
152
+ "network",
153
+ Field("country", css_class="slm-country search-input"),
154
+ css_class="col-5",
155
+ ),
156
+ css_class="row",
157
+ )
158
+ )
159
+ helper.attrs = {
160
+ "data_slm_initial": json.dumps(
161
+ {
162
+ field.name: field.field.initial
163
+ for field in self
164
+ if field.field.initial
165
+ }
166
+ )
167
+ }
168
+ return helper
169
+
170
+
171
+ class StationFilter(BaseStationFilter):
172
+ """
173
+ Edit API station filter extensions.
174
+ """
175
+
176
+ def get_form_class(self):
177
+ return StationFilterForm
178
+
179
+ @property
180
+ def current_equipment(self):
181
+ return self.form.cleaned_data.get("current", None)
182
+
183
+
184
+ class PassThroughRenderer(renderers.BaseRenderer):
185
+ """
186
+ Return data as-is. View should supply a Response.
187
+ """
188
+
189
+ media_type = ""
190
+ format = "legacy"
191
+
192
+ def render(self, data, accepted_media_type=None, renderer_context=None):
193
+ return data
194
+
195
+
196
+ class DataTablesListMixin(mixins.ListModelMixin):
197
+ """
198
+ A mixin for adapting list views to work with the datatables library.
199
+ """
200
+
201
+ pagination_class = DataTablesPagination
202
+
203
+
204
+ class AgencyViewSet(PublicAgencyViewSet):
205
+ def get_queryset(self):
206
+ return Agency.objects.membership(self.request.user)
207
+
208
+
209
+ class NetworkViewSet(PublicNetworkViewSet):
210
+ def get_queryset(self):
211
+ return Network.objects.visible_to(self.request.user)
212
+
213
+
214
+ class ReviewRequestView(
215
+ DataTablesListMixin,
216
+ mixins.RetrieveModelMixin,
217
+ mixins.UpdateModelMixin,
218
+ mixins.ListModelMixin,
219
+ viewsets.GenericViewSet,
220
+ ):
221
+ serializer_class = ReviewRequestSerializer
222
+ permission_classes = (IsAuthenticated, CanEditSite)
223
+
224
+ def perform_update(self, serializer):
225
+ slm_signals.review_requested.send(
226
+ sender=self,
227
+ site=serializer.instance,
228
+ detail=serializer.validated_data.get("detail", None),
229
+ request=self.request,
230
+ )
231
+ serializer.instance.refresh_from_db()
232
+
233
+ def get_queryset(self):
234
+ return (
235
+ Site.objects.editable_by(self.request.user)
236
+ .filter(review_requested__isnull=True)
237
+ .filter(status__in=SiteLogStatus.unpublished_states())
238
+ )
239
+
240
+
241
+ class RejectUpdatesView(
242
+ DataTablesListMixin,
243
+ mixins.RetrieveModelMixin,
244
+ mixins.UpdateModelMixin,
245
+ mixins.ListModelMixin,
246
+ viewsets.GenericViewSet,
247
+ ):
248
+ serializer_class = ReviewRequestSerializer
249
+ permission_classes = (IsAuthenticated, CanRejectReview)
250
+
251
+ def perform_update(self, serializer):
252
+ slm_signals.updates_rejected.send(
253
+ sender=self,
254
+ site=serializer.instance,
255
+ detail=serializer.validated_data.get("detail", None),
256
+ request=self.request,
257
+ )
258
+ serializer.instance.refresh_from_db()
259
+
260
+ def get_queryset(self):
261
+ return Site.objects.moderated(self.request.user).filter(
262
+ review_requested__isnull=False
263
+ )
264
+
265
+
266
+ class StationListViewSet(
267
+ DataTablesListMixin,
268
+ mixins.CreateModelMixin,
269
+ mixins.RetrieveModelMixin,
270
+ mixins.UpdateModelMixin,
271
+ viewsets.GenericViewSet,
272
+ ):
273
+ serializer_class = StationSerializer
274
+ permission_classes = (
275
+ IsAuthenticated,
276
+ CanEditSite,
277
+ )
278
+
279
+ filter_backends = (DjangoFilterBackend, OrderingFilter)
280
+ filterset_class = StationFilter
281
+ ordering_fields = ["name", "num_flags", "created", "last_update", "last_publish"]
282
+ ordering = ("name",)
283
+
284
+ def get_queryset(self):
285
+ return (
286
+ Site.objects.editable_by(self.request.user)
287
+ .prefetch_related(
288
+ "agencies", "networks", "owner__agencies", "last_user__agencies"
289
+ )
290
+ .select_related(
291
+ "owner",
292
+ "last_user",
293
+ "review_requested",
294
+ "updates_rejected",
295
+ "import_alert",
296
+ )
297
+ )
298
+
299
+
300
+ class LogEntryViewSet(DataTablesListMixin, viewsets.GenericViewSet):
301
+ serializer_class = LogEntrySerializer
302
+ permission_classes = (IsAuthenticated,)
303
+
304
+ class LogEntryFilter(CrispyFormCompat, FilterSet):
305
+ sites = None
306
+
307
+ def __init__(self, data=None, queryset=None, *, request=None, **kwargs):
308
+ super().__init__(data, queryset=queryset, request=request, **kwargs)
309
+ # we chain this filter so when someone filters the station list
310
+ # we can show the log entries corresponding to the filtered
311
+ # stations
312
+ self.sites = StationFilter(
313
+ data=data, queryset=Site.objects.all(), request=request, **kwargs
314
+ )
315
+
316
+ def filter_queryset(self, queryset):
317
+ return super().filter_queryset(queryset).filter(site__in=self.sites.qs)
318
+
319
+ site = django_filters.CharFilter(field_name="site__name", lookup_expr="iexact")
320
+ user = django_filters.CharFilter(field_name="user__email", lookup_expr="iexact")
321
+ before = django_filters.CharFilter(field_name="timestamp", lookup_expr="lt")
322
+ after = django_filters.CharFilter(field_name="timestamp", lookup_expr="gte")
323
+ ip = django_filters.CharFilter(field_name="ip", lookup_expr="iexact")
324
+
325
+ class Meta:
326
+ model = LogEntry
327
+ fields = ("site", "user", "before", "after", "ip")
328
+
329
+ filter_backends = (DjangoFilterBackend,)
330
+ filterset_class = LogEntryFilter
331
+
332
+ def get_queryset(self):
333
+ return LogEntry.objects.for_user(self.request.user).select_related(
334
+ "section", "site", "file", "user"
335
+ )
336
+
337
+
338
+ class AlertViewSet(
339
+ DataTablesListMixin,
340
+ mixins.RetrieveModelMixin,
341
+ mixins.DestroyModelMixin,
342
+ viewsets.GenericViewSet,
343
+ ):
344
+ serializer_class = AlertSerializer
345
+ permission_classes = (IsAuthenticated, CanDeleteAlert)
346
+
347
+ class AlertFilter(CrispyFormCompat, FilterSet):
348
+ sites = None
349
+
350
+ def __init__(self, data=None, queryset=None, *, request=None, **kwargs):
351
+ super().__init__(data, queryset=queryset, request=request, **kwargs)
352
+ self.sites = StationFilter(
353
+ data=data, queryset=Site.objects.all(), request=request, **kwargs
354
+ )
355
+
356
+ site = django_filters.CharFilter(method="for_site", distinct=True)
357
+ user = django_filters.CharFilter(method="for_user", distinct=True)
358
+
359
+ def filter_queryset(self, queryset):
360
+ return (
361
+ super()
362
+ .filter_queryset(queryset)
363
+ .concerning_sites(self.sites.qs)
364
+ .distinct()
365
+ )
366
+
367
+ def for_site(self, queryset, name, value):
368
+ return queryset.for_site(
369
+ Site.objects.filter(name__iexact=value).first()
370
+ ).distinct()
371
+
372
+ def for_user(self, queryset, name, value):
373
+ return queryset.for_user(
374
+ get_user_model().filter(email__iexact=value)
375
+ ).distinct()
376
+
377
+ class Meta:
378
+ model = Alert
379
+ fields = (
380
+ "site",
381
+ "user",
382
+ )
383
+ distinct = True
384
+
385
+ filter_backends = (DjangoFilterBackend,)
386
+ filterset_class = AlertFilter
387
+
388
+ def get_queryset(self):
389
+ Alert.objects.delete_expired()
390
+ return Alert.objects.visible_to(self.request.user)
391
+
392
+
393
+ class UserProfileViewSet(
394
+ mixins.UpdateModelMixin,
395
+ mixins.RetrieveModelMixin,
396
+ mixins.ListModelMixin,
397
+ viewsets.GenericViewSet,
398
+ ):
399
+ serializer_class = UserSerializer
400
+ permission_classes = (
401
+ IsAuthenticated,
402
+ IsUserOrAdmin,
403
+ )
404
+ parser_classes = (FormParser, JSONParser)
405
+
406
+ def get_queryset(self):
407
+ return (
408
+ get_user_model()
409
+ .objects.filter(id=self.request.user.id)
410
+ .select_related("profile")
411
+ .prefetch_related("agencies")
412
+ )
413
+
414
+ def list(self, request, **kwargs):
415
+ resp = super(UserProfileViewSet, self).list(request, **kwargs)
416
+ resp.data = resp.data[0]
417
+ return resp
418
+
419
+ def update(self, request, *args, **kwargs):
420
+ from django.contrib import messages
421
+
422
+ resp = super().update(request, *args, **kwargs)
423
+ if resp.status_code < 300:
424
+ messages.add_message(
425
+ request, messages.SUCCESS, _("User profile updated successfully.")
426
+ )
427
+ return resp
428
+
429
+
430
+ class SiteLogDownloadViewSet(BaseSiteLogDownloadViewSet):
431
+ class ArchiveIndexFilter(BaseSiteLogDownloadViewSet.ArchiveIndexFilter):
432
+ unpublished = SLMBooleanFilter(method="noop")
433
+ unpublished.help = _(
434
+ "If true, download the published version of the log. If false,"
435
+ "the HEAD version of the log "
436
+ )
437
+
438
+ class Meta(BaseSiteLogDownloadViewSet.ArchiveIndexFilter.Meta):
439
+ fields = (
440
+ "unpublished",
441
+ *BaseSiteLogDownloadViewSet.ArchiveIndexFilter.Meta.fields,
442
+ )
443
+
444
+ filterset_class = ArchiveIndexFilter
445
+
446
+ def retrieve(self, request, *args, **kwargs):
447
+ if request.GET.get("unpublished", False):
448
+ try:
449
+ site = Site.objects.get(name__iexact=kwargs.get("site"))
450
+ return FileResponse(
451
+ BytesIO(
452
+ SiteLogSerializer(instance=site, published=None)
453
+ .format(request.accepted_renderer.format)
454
+ .encode()
455
+ ),
456
+ filename=site.get_filename(
457
+ log_format=request.accepted_renderer.format,
458
+ epoch=datetime.now(),
459
+ name_len=request.GET.get("name_len", None),
460
+ lower_case=request.GET.get("lower_case", False),
461
+ ),
462
+ )
463
+ except Site.DoesNotExist:
464
+ raise Http404()
465
+ return super().retrieve(request, *args, **kwargs)
466
+
467
+
468
+ class SectionViewSet(type):
469
+ """
470
+ POST, PUT and PATCH all behave the same way for the section edit API.
471
+
472
+ Each runs through the following steps:
473
+
474
+ 1) Permission check - only moderators are permitted to submit _flags or
475
+ publish=True
476
+ """
477
+
478
+ def __new__(metacls, name, bases, namespace, **kwargs):
479
+ ModelClass = kwargs.pop("model")
480
+ can_delete = issubclass(ModelClass, SiteSubSection)
481
+ parents = [
482
+ *bases,
483
+ mixins.RetrieveModelMixin,
484
+ mixins.ListModelMixin,
485
+ mixins.UpdateModelMixin,
486
+ mixins.CreateModelMixin,
487
+ ]
488
+ if can_delete:
489
+ parents.append(mixins.DestroyModelMixin)
490
+
491
+ parents.append(viewsets.GenericViewSet)
492
+ obj = super().__new__(metacls, name, tuple(parents), namespace)
493
+
494
+ class ViewSetFilter(CrispyFormCompat, FilterSet):
495
+ site = django_filters.CharFilter(
496
+ field_name="site__name", lookup_expr="iexact"
497
+ )
498
+
499
+ class Meta:
500
+ model = ModelClass
501
+ fields = ["site", "id"] + (
502
+ ["subsection"] if issubclass(ModelClass, SiteSubSection) else []
503
+ )
504
+
505
+ class ViewSetSerializer(ModelSerializer):
506
+ def build_standard_field(self, field_name, model_field):
507
+ """
508
+ Force EnumFields into django_enum's enum field type.
509
+ """
510
+ if model_field.__class__ in {
511
+ EnumIntegerField,
512
+ EnumPositiveIntegerField,
513
+ EnumBigIntegerField,
514
+ EnumPositiveSmallIntegerField,
515
+ EnumPositiveBigIntegerField,
516
+ EnumSmallIntegerField,
517
+ EnumCharField,
518
+ }:
519
+ return EnumField, {
520
+ **super().build_standard_field(field_name, model_field)[1],
521
+ "enum": model_field.enum,
522
+ "strict": model_field.strict,
523
+ }
524
+ return super().build_standard_field(field_name, model_field)
525
+
526
+ serializer_field_mapping = {
527
+ **ModelSerializer.serializer_field_mapping,
528
+ models.DateTimeField: SLMDateTimeField,
529
+ gis_models.PointField: SLMPointField,
530
+ }
531
+
532
+ _diff = serializers.SerializerMethodField(read_only=True)
533
+ _flags = serializers.JSONField(
534
+ read_only=False,
535
+ required=False,
536
+ encoder=SiteSection._meta.get_field("_flags").encoder,
537
+ decoder=SiteSection._meta.get_field("_flags").decoder,
538
+ )
539
+
540
+ can_publish = serializers.SerializerMethodField(read_only=True)
541
+
542
+ publish = serializers.BooleanField(write_only=True, required=False)
543
+ revert = serializers.BooleanField(write_only=True, required=False)
544
+
545
+ def to_internal_value(self, data):
546
+ """
547
+ Swap empty string for None in post data for any field that
548
+ allows null. (You'd think this wouldn't be necessary or that
549
+ DRF would have some kind of switch?)
550
+
551
+ :param data:
552
+ :return:
553
+ """
554
+ data = data.copy()
555
+ for field, value in data.items():
556
+ if value == "" and getattr(
557
+ self.fields.get(field, None), "allow_null", False
558
+ ):
559
+ data[field] = None
560
+
561
+ # if these are set to None - validation rejects them
562
+ # todo anything else like this?
563
+ if data.get("id", False) in {None, ""}:
564
+ del data["id"]
565
+
566
+ if data.get("subsection", False) in {None, ""}:
567
+ del data["subsection"]
568
+
569
+ return super().to_internal_value(data)
570
+
571
+ def get_can_publish(self, obj):
572
+ if "request" in self.context:
573
+ return self.context["request"].user.is_moderator()
574
+ return None
575
+
576
+ def get__diff(self, obj):
577
+ return obj.published_diff()
578
+
579
+ def perform_section_update(self, validated_data, instance=None):
580
+ """
581
+ We perform the same update routines on either a PUT, PATCH or
582
+ POST for uniformity and convenience. This could include:
583
+
584
+ 1. Field Update
585
+ * If current instance is published and this contains
586
+ edits create a new db row
587
+ * If current instance is not published update instance
588
+ * Issue section edited event.
589
+ 2. New Section
590
+ * Issue section added event.
591
+ 3. Flag Update
592
+ * Issue fields_flagged or flags_cleared event.
593
+ 4. Publish
594
+ * Delete the previously published instance and set
595
+ instance (old or added) to published and issue
596
+ site_published event.
597
+ 5. Any combination of 1, 2, 3 and 4
598
+
599
+ :param validated_data: The validated POST data
600
+ :param instance: The instance if this was a PUT or PATCH. POST
601
+ behaves the same way - so we also resolve the instance from
602
+ POST data if the update url was not used.
603
+ :return:
604
+ """
605
+ with transaction.atomic():
606
+ # permission check - we do this here because operating
607
+ # directly on POST data in a Permission object is more
608
+ # difficult
609
+ site = validated_data.get("site") or instance.site
610
+ is_moderator = site.is_moderator(self.context["request"].user)
611
+ do_publish = validated_data.pop("publish", False)
612
+ do_revert = validated_data.pop("revert", False)
613
+ update_status = None
614
+
615
+ if not is_moderator:
616
+ # non-moderators are not allowed to publish!
617
+ if do_publish:
618
+ raise PermissionDenied(
619
+ _(
620
+ "You must have moderator privileges to "
621
+ "publish site log edits."
622
+ )
623
+ )
624
+
625
+ # we don't disallow an accompanying edit with _flags -
626
+ # we just strip them out of the user doesnt have
627
+ # permission to add them
628
+ validated_data.pop("_flags", None)
629
+
630
+ section_id = validated_data.pop("id", None)
631
+ subsection = validated_data.get("subsection", None)
632
+
633
+ line_filter = Q(site=site)
634
+ if subsection is not None:
635
+ line_filter &= Q(subsection=subsection)
636
+
637
+ # get the previous section instance - if it exists
638
+ if instance is None:
639
+ # this is either a section where only one can exist or
640
+ # a subsection where multiple can exist
641
+ if not self.allow_multiple():
642
+ instance = (
643
+ ModelClass.objects.filter(site=site)
644
+ .order_by("-edited")
645
+ .select_for_update()
646
+ .first()
647
+ )
648
+ # this is a subsection - if the subsection IDs are not
649
+ # present it is
650
+ elif subsection is not None:
651
+ instance = (
652
+ ModelClass.objects.filter(line_filter)
653
+ .order_by("-edited")
654
+ .select_for_update()
655
+ .first()
656
+ )
657
+ else:
658
+ instance = (
659
+ ModelClass.objects.filter(pk=instance.pk)
660
+ .select_for_update()
661
+ .first()
662
+ )
663
+
664
+ if do_revert and instance:
665
+ if instance.revert():
666
+ reverted_to = ModelClass.objects.filter(
667
+ line_filter & Q(published=True)
668
+ ).first()
669
+ if reverted_to:
670
+ return reverted_to
671
+ self.context[
672
+ "request"
673
+ ]._response_status = status.HTTP_204_NO_CONTENT
674
+ return instance
675
+
676
+ try:
677
+ # this is a new section
678
+ if instance is None:
679
+ new_section = super().create(validated_data)
680
+ new_section.full_clean()
681
+ new_section.save()
682
+ update_status = new_section.edited
683
+ if not isinstance(new_section, SiteForm):
684
+ form = new_section.site.siteform_set.head()
685
+ if form is None:
686
+ form = SiteForm.objects.create(
687
+ site=site, published=False, report_type="NEW"
688
+ )
689
+ elif form.published:
690
+ form.pk = None
691
+ form.published = False
692
+ if self.context["request"].user.full_name:
693
+ form.prepared_by = self.context[
694
+ "request"
695
+ ].user.full_name
696
+ form.save()
697
+ slm_signals.section_added.send(
698
+ sender=self,
699
+ site=site,
700
+ user=self.context["request"].user,
701
+ request=self.context["request"],
702
+ timestamp=update_status,
703
+ section=new_section,
704
+ )
705
+ instance = new_section
706
+ else:
707
+ # if an object id was present and it is not at or past
708
+ # the last section ID - we have a concurrent edit race
709
+ # condition between one or more users.
710
+ if section_id is not None and section_id < instance.id:
711
+ raise DRFValidationError(
712
+ _(
713
+ "Edits must be made on HEAD. Someone else "
714
+ "may be editing the log concurrently. "
715
+ "Refresh and try again."
716
+ )
717
+ )
718
+
719
+ # if not new - does this section have edits?
720
+ update = False
721
+ flags = validated_data.get("_flags", instance._flags)
722
+ edited_fields = []
723
+
724
+ # todo this diffing code is getting a bit messy because
725
+ # of all the special type cases - consider a refactor
726
+ # also needs to be DRYed w/ published_diff function
727
+ for field in ModelClass.site_log_fields():
728
+ if field in validated_data:
729
+ is_many = isinstance(
730
+ instance._meta.get_field(field),
731
+ models.ManyToManyField,
732
+ )
733
+ new_value = validated_data.get(field)
734
+ old_value = getattr(instance, field)
735
+ if (
736
+ not is_many
737
+ and (
738
+ getattr(new_value, "coords", None)
739
+ != getattr(old_value, "coords", None)
740
+ if isinstance(new_value, Point)
741
+ else new_value != old_value
742
+ )
743
+ ) or (
744
+ is_many
745
+ and set(new_value)
746
+ != set(getattr(instance, field).all())
747
+ ):
748
+ update = True
749
+ if not instance.published:
750
+ edited_fields.append(field)
751
+ if is_many:
752
+ if new_value:
753
+ getattr(instance, field).set(new_value)
754
+ else:
755
+ getattr(instance, field).clear()
756
+ else:
757
+ setattr(instance, field, new_value)
758
+
759
+ if field in flags:
760
+ del flags[field]
761
+ if update:
762
+ if instance.published:
763
+ validated_data["_flags"] = flags
764
+ instance.pk = None # copy the instance
765
+ instance.published = False
766
+ instance.save()
767
+ for param, value in validated_data.items():
768
+ if isinstance(
769
+ instance._meta.get_field(param),
770
+ models.ManyToManyField,
771
+ ):
772
+ if value:
773
+ getattr(instance, param).set(value)
774
+ else:
775
+ getattr(instance, param).clear()
776
+ else:
777
+ setattr(instance, param, value)
778
+ instance.full_clean()
779
+ instance.save()
780
+ else:
781
+ instance._flags = flags
782
+ instance.full_clean()
783
+ instance.save()
784
+
785
+ # make sure we use edit timestamp if publish and
786
+ # edit are simultaneous
787
+ update_status = instance.edited
788
+ if not isinstance(instance, SiteForm):
789
+ form = instance.site.siteform_set.head()
790
+ if form.published:
791
+ form.pk = None
792
+ form.published = False
793
+ if self.context["request"].user.full_name:
794
+ form.prepared_by = self.context[
795
+ "request"
796
+ ].user.full_name
797
+ form.save()
798
+ slm_signals.section_edited.send(
799
+ sender=self,
800
+ site=site,
801
+ user=self.context["request"].user,
802
+ request=self.context["request"],
803
+ timestamp=update_status,
804
+ section=instance,
805
+ fields=edited_fields,
806
+ )
807
+ elif "_flags" in validated_data:
808
+ # this is just a flag update
809
+ added = len(flags) - (
810
+ len(instance._flags) if instance._flags else 0
811
+ )
812
+ site.num_flags += added
813
+ if site.num_flags < 0:
814
+ site.num_flags = 0
815
+
816
+ site.save()
817
+ instance._flags = flags
818
+ instance.save()
819
+
820
+ if do_publish:
821
+ update_status = update_status or now()
822
+ if not isinstance(instance, SiteForm):
823
+ form = instance.site.siteform_set.head()
824
+ if form.published:
825
+ form.pk = None
826
+ form.published = False
827
+ if self.context["request"].user.full_name:
828
+ form.prepared_by = self.context[
829
+ "request"
830
+ ].user.full_name
831
+ form.save(modified_section=instance.dot_index)
832
+ form.publish(
833
+ request=self.context.get("request", None),
834
+ silent=True,
835
+ timestamp=update_status,
836
+ update_site=False,
837
+ )
838
+ instance.publish(
839
+ request=self.context.get("request", None),
840
+ timestamp=update_status,
841
+ update_site=False, # this is done below
842
+ )
843
+ try:
844
+ instance.refresh_from_db()
845
+ except instance.DoesNotExist:
846
+ # hacky but it works, not really another way
847
+ # to pass information up and down the chain
848
+ # from serializer to view - probably means a
849
+ # lot of this logic belongs in the view
850
+ self.context[
851
+ "request"
852
+ ]._response_status = status.HTTP_204_NO_CONTENT
853
+
854
+ if update_status:
855
+ site.update_status(
856
+ save=True,
857
+ user=self.context["request"].user,
858
+ timestamp=update_status,
859
+ )
860
+ return instance
861
+ except DjangoValidationError as ve:
862
+ raise DRFValidationError(ve.message_dict)
863
+
864
+ def update(self, instance, validated_data):
865
+ return self.perform_section_update(
866
+ validated_data=validated_data, instance=instance
867
+ )
868
+
869
+ def create(self, validated_data):
870
+ return self.perform_section_update(validated_data=validated_data)
871
+
872
+ @classmethod
873
+ def allow_multiple(cls):
874
+ """
875
+ Does this serializer allow multiple sections per site?
876
+ :return: True if multiple sections are allowed - False
877
+ otherwise
878
+ """
879
+ return issubclass(ModelClass, SiteSubSection)
880
+
881
+ def build_relational_field(self, field_name, relation_info):
882
+ """
883
+ By default DRF will use PrimaryKeyRelatedFields to represent
884
+ ForeignKey relations - for certain fields in the API we'd
885
+ rather tie them based on a string field on the related model.
886
+ If API_RELATED_FIELD is set to that field on a related model
887
+ we use a SlugRelatedField instead so instead of passing PKs
888
+ in the API, users can pass human readable names instead and do
889
+ not have to do the work to figure out what the primary key is
890
+ under the covers - as this is SLM database instance specific.
891
+
892
+ This is also critical for our autocomplete fields.
893
+ """
894
+ related = getattr(
895
+ relation_info.related_model, "API_RELATED_FIELD", None
896
+ )
897
+ _, defaults = super().build_relational_field(field_name, relation_info)
898
+ if related:
899
+ return (SlugRelatedField, {**defaults, "slug_field": related})
900
+ return _, defaults
901
+
902
+ class Meta:
903
+ model = ModelClass
904
+
905
+ # prevent UniqueTogetherValidator from being attached
906
+ # subsection and section ids are attached after the data
907
+ # validation process and uniqueness qualities are enforced
908
+ # by the code, it should be impossible for a user to use the
909
+ # api in a way that would trigger the database to violate
910
+ # this constraint
911
+ validators = []
912
+
913
+ fields = [
914
+ "site",
915
+ "id",
916
+ "publish",
917
+ "published",
918
+ "revert",
919
+ "can_publish",
920
+ "_flags",
921
+ "_diff",
922
+ *ModelClass.site_log_fields(),
923
+ ] + (
924
+ ["subsection", "heading", "effective", "is_deleted"]
925
+ if issubclass(ModelClass, SiteSubSection)
926
+ else []
927
+ )
928
+
929
+ extra_kwargs = {
930
+ "id": {"required": False, "read_only": False},
931
+ "site": {"required": True},
932
+ **(
933
+ {
934
+ "heading": {"required": False, "read_only": True},
935
+ "effective": {"required": False, "read_only": True},
936
+ "is_deleted": {"required": False, "read_only": True},
937
+ "subsection": {"required": False},
938
+ "four_character_id": { # special case
939
+ "required": False,
940
+ "read_only": True,
941
+ },
942
+ "nine_character_id": { # special case
943
+ "required": False,
944
+ "read_only": True,
945
+ },
946
+ "custom_graphic": {"trim_whitespace": False},
947
+ }
948
+ if issubclass(ModelClass, SiteSubSection)
949
+ else {}
950
+ ),
951
+ }
952
+
953
+ obj.serializer_class = ViewSetSerializer
954
+ obj.filterset_class = ViewSetFilter
955
+ obj.permission_classes = (IsAuthenticated, CanEditSite, UpdateAdminOnly)
956
+ obj.pagination_class = DataTablesPagination
957
+ obj.filter_backends = (DjangoFilterBackend,)
958
+
959
+ def get_queryset(self):
960
+ return ModelClass.objects.editable_by(self.request.user).select_related(
961
+ "site"
962
+ )
963
+
964
+ def create(self, request, *args, **kwargs):
965
+ serializer = self.get_serializer(data=request.data)
966
+ serializer.is_valid(raise_exception=True)
967
+ try:
968
+ self.perform_create(serializer)
969
+ except DjangoValidationError as ve:
970
+ raise DRFValidationError(ve.message_dict)
971
+
972
+ headers = self.get_success_headers(serializer.data)
973
+ return Response(
974
+ serializer.data,
975
+ status=getattr(request, "_response_status", status.HTTP_201_CREATED),
976
+ headers=headers,
977
+ )
978
+
979
+ def update(self, request, *args, **kwargs):
980
+ response = mixins.UpdateModelMixin.update(self, request, *args, **kwargs)
981
+ response.status_code = getattr(
982
+ request, "_response_status", response.status_code
983
+ )
984
+ return response
985
+
986
+ def destroy(self, request, *args, **kwargs):
987
+ instance = self.get_object()
988
+ instance = self.perform_destroy(instance)
989
+ if instance:
990
+ serializer = self.get_serializer(instance=instance)
991
+ headers = self.get_success_headers(serializer.data)
992
+ return Response(
993
+ serializer.data,
994
+ status=getattr(request, "_response_status", status.HTTP_200_OK),
995
+ headers=headers,
996
+ )
997
+ return Response(
998
+ status=getattr(request, "_response_status", status.HTTP_204_NO_CONTENT)
999
+ )
1000
+
1001
+ def perform_destroy(self, instance):
1002
+ """
1003
+ Deletes must happen on head:
1004
+
1005
+ 1) If head is published, the delete flag will be marked
1006
+ 2) If head is unpublished, but no previously published section
1007
+ exists the record will be deleted.
1008
+ 3) If head is unpublished, but previous published sections exist,
1009
+ the unpublished record will be deleted and the published record
1010
+ will have its is_deleted flag set.
1011
+
1012
+ Sections are fully deleted when a published record with the
1013
+ is_deleted flag set is published.
1014
+
1015
+ :param self: view class instance
1016
+ :param instance: The section or subsection instance
1017
+ """
1018
+ with transaction.atomic():
1019
+ section = ModelClass.objects.select_for_update().get(pk=instance.pk)
1020
+ site = section.site
1021
+ published = ModelClass.objects.filter(
1022
+ site=section.site, subsection=section.subsection, published=True
1023
+ ).first()
1024
+
1025
+ if not section.published:
1026
+ # we delete it if this section or subsection has never
1027
+ # been published before
1028
+ section.delete()
1029
+
1030
+ if published:
1031
+ published.is_deleted = True
1032
+ published.save()
1033
+ form = section.site.siteform_set.head()
1034
+ if form.published:
1035
+ form.pk = None
1036
+ form.published = False
1037
+ if self.request.user.full_name:
1038
+ form.prepared_by = self.request.user.full_name
1039
+ form.save()
1040
+ to_return = published
1041
+ else:
1042
+ to_return = None
1043
+
1044
+ slm_signals.section_deleted.send(
1045
+ sender=self,
1046
+ site=site,
1047
+ user=self.request.user,
1048
+ request=self.request,
1049
+ timestamp=now(),
1050
+ section=section,
1051
+ )
1052
+
1053
+ site.update_status(save=True, user=self.request.user, timestamp=now())
1054
+ return to_return
1055
+
1056
+ obj.get_queryset = get_queryset
1057
+ if can_delete:
1058
+ obj.perform_destroy = perform_destroy
1059
+ obj.destroy = destroy
1060
+ obj.create = create
1061
+ obj.update = update
1062
+ return obj
1063
+
1064
+
1065
+ # TODO all these can be constructed dynamically from the models
1066
+ class SiteFormViewSet(metaclass=SectionViewSet, model=SiteForm):
1067
+ pass
1068
+
1069
+
1070
+ class SiteIdentificationViewSet(metaclass=SectionViewSet, model=SiteIdentification):
1071
+ pass
1072
+
1073
+
1074
+ class SiteLocationViewSet(metaclass=SectionViewSet, model=SiteLocation):
1075
+ pass
1076
+
1077
+
1078
+ class SiteReceiverViewSet(metaclass=SectionViewSet, model=SiteReceiver):
1079
+ pass
1080
+
1081
+
1082
+ class SiteAntennaViewSet(metaclass=SectionViewSet, model=SiteAntenna):
1083
+ pass
1084
+
1085
+
1086
+ class SiteSurveyedLocalTiesViewSet(
1087
+ metaclass=SectionViewSet, model=SiteSurveyedLocalTies
1088
+ ):
1089
+ pass
1090
+
1091
+
1092
+ class SiteFrequencyStandardViewSet(
1093
+ metaclass=SectionViewSet, model=SiteFrequencyStandard
1094
+ ):
1095
+ pass
1096
+
1097
+
1098
+ class SiteCollocationViewSet(metaclass=SectionViewSet, model=SiteCollocation):
1099
+ pass
1100
+
1101
+
1102
+ class SiteHumiditySensorViewSet(metaclass=SectionViewSet, model=SiteHumiditySensor):
1103
+ pass
1104
+
1105
+
1106
+ class SitePressureSensorViewSet(metaclass=SectionViewSet, model=SitePressureSensor):
1107
+ pass
1108
+
1109
+
1110
+ class SiteTemperatureSensorViewSet(
1111
+ metaclass=SectionViewSet, model=SiteTemperatureSensor
1112
+ ):
1113
+ pass
1114
+
1115
+
1116
+ class SiteWaterVaporRadiometerViewSet(
1117
+ metaclass=SectionViewSet, model=SiteWaterVaporRadiometer
1118
+ ):
1119
+ pass
1120
+
1121
+
1122
+ class SiteOtherInstrumentationViewSet(
1123
+ metaclass=SectionViewSet, model=SiteOtherInstrumentation
1124
+ ):
1125
+ pass
1126
+
1127
+
1128
+ class SiteRadioInterferencesViewSet(
1129
+ metaclass=SectionViewSet, model=SiteRadioInterferences
1130
+ ):
1131
+ pass
1132
+
1133
+
1134
+ class SiteMultiPathSourcesViewSet(metaclass=SectionViewSet, model=SiteMultiPathSources):
1135
+ pass
1136
+
1137
+
1138
+ class SiteSignalObstructionsViewSet(
1139
+ metaclass=SectionViewSet, model=SiteSignalObstructions
1140
+ ):
1141
+ pass
1142
+
1143
+
1144
+ class SiteLocalEpisodicEffectsViewSet(
1145
+ metaclass=SectionViewSet, model=SiteLocalEpisodicEffects
1146
+ ):
1147
+ pass
1148
+
1149
+
1150
+ class SiteOperationalContactViewSet(
1151
+ metaclass=SectionViewSet, model=SiteOperationalContact
1152
+ ):
1153
+ pass
1154
+
1155
+
1156
+ class SiteResponsibleAgencyViewSet(
1157
+ metaclass=SectionViewSet, model=SiteResponsibleAgency
1158
+ ):
1159
+ pass
1160
+
1161
+
1162
+ class SiteMoreInformationViewSet(metaclass=SectionViewSet, model=SiteMoreInformation):
1163
+ pass
1164
+
1165
+
1166
+ class SiteFileUploadViewSet(
1167
+ DataTablesListMixin,
1168
+ mixins.CreateModelMixin,
1169
+ mixins.RetrieveModelMixin,
1170
+ mixins.UpdateModelMixin,
1171
+ mixins.DestroyModelMixin,
1172
+ viewsets.GenericViewSet,
1173
+ ):
1174
+ logger = getLogger("slm.api.edit.views.SiteFileUploadViewSet")
1175
+
1176
+ serializer_class = SiteFileUploadSerializer
1177
+ permission_classes = (
1178
+ IsAuthenticated,
1179
+ CanEditSite,
1180
+ )
1181
+ parser_classes = (MultiPartParser, FormParser, JSONParser, FileUploadParser)
1182
+
1183
+ site = None
1184
+
1185
+ SECTION_VIEWS = {
1186
+ 0: SiteFormViewSet,
1187
+ 1: SiteIdentificationViewSet,
1188
+ 2: SiteLocationViewSet,
1189
+ 3: SiteReceiverViewSet,
1190
+ 4: SiteAntennaViewSet,
1191
+ 5: SiteSurveyedLocalTiesViewSet,
1192
+ 6: SiteFrequencyStandardViewSet,
1193
+ 7: SiteCollocationViewSet,
1194
+ (8, 1): SiteHumiditySensorViewSet,
1195
+ (8, 2): SitePressureSensorViewSet,
1196
+ (8, 3): SiteTemperatureSensorViewSet,
1197
+ (8, 4): SiteWaterVaporRadiometerViewSet,
1198
+ (8, 5): SiteOtherInstrumentationViewSet,
1199
+ (9, 1): SiteRadioInterferencesViewSet,
1200
+ (9, 2): SiteMultiPathSourcesViewSet,
1201
+ (9, 3): SiteSignalObstructionsViewSet,
1202
+ 10: SiteLocalEpisodicEffectsViewSet,
1203
+ 11: SiteOperationalContactViewSet,
1204
+ 12: SiteResponsibleAgencyViewSet,
1205
+ 13: SiteMoreInformationViewSet,
1206
+ }
1207
+
1208
+ @staticmethod
1209
+ def get_subsection_id(section):
1210
+ if section.section_number in {3, 4, 5, 6, 7, 8, 9, 10}:
1211
+ return section.ordering_id
1212
+ return None
1213
+
1214
+ class FileFilter(CrispyFormCompat, AcceptListArguments, FilterSet):
1215
+ name = django_filters.CharFilter(field_name="name", lookup_expr="istartswith")
1216
+
1217
+ file_type = django_filters.MultipleChoiceFilter(choices=SLMFileType.choices)
1218
+
1219
+ log_format = django_filters.MultipleChoiceFilter(choices=SiteLogFormat.choices)
1220
+
1221
+ status = django_filters.MultipleChoiceFilter(
1222
+ choices=SiteFileUploadStatus.choices
1223
+ )
1224
+
1225
+ class Meta:
1226
+ model = SiteFileUpload
1227
+ fields = ("name", "status", "file_type", "log_format")
1228
+
1229
+ filter_backends = (DjangoFilterBackend, OrderingFilter)
1230
+ filterset_class = FileFilter
1231
+ ordering_fields = ["-timestamp", "name"]
1232
+
1233
+ original_request = None
1234
+
1235
+ def dispatch(self, request, *args, **kwargs):
1236
+ self.site = kwargs.pop("site", None)
1237
+ self.original_request = request
1238
+ try:
1239
+ self.site = Site.objects.get(name__iexact=self.site)
1240
+ except Site.DoesNotExist:
1241
+ return HttpResponseNotFound(f"{self.site} does not exist.")
1242
+
1243
+ if not self.site.can_edit(request.user):
1244
+ return HttpResponseForbidden(
1245
+ f"{request.user} cannot edit site {self.site.name}"
1246
+ )
1247
+
1248
+ return super().dispatch(request, *args, **kwargs)
1249
+
1250
+ def get_queryset(self):
1251
+ return SiteFileUpload.objects.available_to(self.request.user).filter(
1252
+ site=self.site
1253
+ )
1254
+
1255
+ def perform_destroy(self, instance):
1256
+ super().perform_destroy(instance)
1257
+ if instance.file_type is not SLMFileType.SITE_LOG:
1258
+ slm_signals.site_file_deleted.send(
1259
+ sender=self,
1260
+ site=self.site,
1261
+ user=self.original_request.user,
1262
+ timestamp=now(),
1263
+ request=self.original_request,
1264
+ upload=instance,
1265
+ )
1266
+
1267
+ def create(self, request, *args, **kwargs):
1268
+ with transaction.atomic():
1269
+ if "file" not in request.FILES:
1270
+ return Response("Expected a file upload with name 'file'.", status=400)
1271
+
1272
+ upload = SiteFileUpload(
1273
+ site=self.site,
1274
+ file=request.FILES["file"],
1275
+ name=request.FILES["file"].name[:255],
1276
+ mimetype=request.FILES["file"].content_type,
1277
+ user=request.user,
1278
+ )
1279
+ upload.save()
1280
+ slm_signals.site_file_uploaded.send(
1281
+ sender=self,
1282
+ site=self.site,
1283
+ user=request.user,
1284
+ timestamp=upload.timestamp,
1285
+ request=request,
1286
+ upload=upload,
1287
+ )
1288
+
1289
+ if upload.file_type is SLMFileType.SITE_LOG:
1290
+ from slm.parsing.legacy import SiteLogBinder, SiteLogParser
1291
+
1292
+ if upload.log_format in [
1293
+ SiteLogFormat.LEGACY,
1294
+ SiteLogFormat.ASCII_9CHAR,
1295
+ ]:
1296
+ with upload.file.open() as uplf:
1297
+ content = uplf.read()
1298
+ encoding = detect(content).get("encoding", "utf-8")
1299
+ try:
1300
+ bound_log = SiteLogBinder(
1301
+ SiteLogParser(
1302
+ content.decode(encoding), site_name=self.site.name
1303
+ )
1304
+ ).parsed
1305
+ except (UnicodeDecodeError, LookupError):
1306
+ upload.status = SiteFileUploadStatus.INVALID
1307
+ upload.save()
1308
+ return Response(
1309
+ {
1310
+ "file": upload.id,
1311
+ "error": _(
1312
+ "Unable to decode this text file - please "
1313
+ "ensure the file is encoded in UTF-8 and "
1314
+ "try again."
1315
+ ),
1316
+ },
1317
+ status=400,
1318
+ )
1319
+ if not bound_log.errors:
1320
+ self.update_from_legacy(request, bound_log)
1321
+
1322
+ upload.context = bound_log.context
1323
+ if bound_log.errors:
1324
+ upload.status = SiteFileUploadStatus.INVALID
1325
+ upload.save()
1326
+ return Response(
1327
+ {
1328
+ "file": upload.id,
1329
+ "error": _(
1330
+ "There were errors parsing the site " "log."
1331
+ ),
1332
+ },
1333
+ status=400,
1334
+ )
1335
+ upload.status = (
1336
+ SiteFileUploadStatus.WARNINGS
1337
+ if bound_log.warnings
1338
+ else SiteFileUploadStatus.VALID
1339
+ )
1340
+ upload.save()
1341
+
1342
+ elif upload.log_format is SiteLogFormat.GEODESY_ML:
1343
+ from slm.parsing.xsd import SiteLogBinder, SiteLogParser
1344
+
1345
+ with upload.file.open() as uplf:
1346
+ content = uplf.read()
1347
+ encoding = detect(content).get("encoding", "utf-8")
1348
+ try:
1349
+ parsed = SiteLogParser(
1350
+ content.decode(encoding), site_name=self.site.name
1351
+ )
1352
+ except (UnicodeDecodeError, LookupError):
1353
+ upload.status = SiteFileUploadStatus.INVALID
1354
+ upload.save()
1355
+ return Response(
1356
+ {
1357
+ "file": upload.id,
1358
+ "error": _(
1359
+ "Unable to decode this xml file - please "
1360
+ "ensure the file is encoded in UTF-8 and "
1361
+ "try again."
1362
+ ),
1363
+ },
1364
+ status=400,
1365
+ )
1366
+
1367
+ upload.context = parsed.context
1368
+ if parsed.errors:
1369
+ upload.status = SiteFileUploadStatus.INVALID
1370
+ upload.save()
1371
+ return Response(
1372
+ {
1373
+ "file": upload.id,
1374
+ "error": _(
1375
+ "There were errors parsing the site " "log."
1376
+ ),
1377
+ },
1378
+ status=400,
1379
+ )
1380
+ upload.save()
1381
+ return Response(
1382
+ "GeodesyML uploads are not yet supported.", status=400
1383
+ )
1384
+
1385
+ elif upload.log_format is SiteLogFormat.JSON:
1386
+ return Response("JSON uploads are not yet supported.", status=400)
1387
+ else:
1388
+ return Response("Unsupported site log upload format.", status=400)
1389
+ elif upload.file_type is SLMFileType.SITE_IMAGE:
1390
+ # automagically set the view direction if its specified on
1391
+ # the filename
1392
+ if upload.direction is None:
1393
+ lower_name = upload.name.lower()
1394
+ if "north" in lower_name:
1395
+ upload.direction = CardinalDirection.NORTH
1396
+ if "east" in lower_name:
1397
+ upload.direction = CardinalDirection.NORTH_EAST
1398
+ elif "west" in lower_name:
1399
+ upload.direction = CardinalDirection.NORTH_WEST
1400
+ upload.save()
1401
+ elif "south" in lower_name:
1402
+ upload.direction = CardinalDirection.SOUTH
1403
+ if "east" in lower_name:
1404
+ upload.direction = CardinalDirection.SOUTH_EAST
1405
+ elif "west" in lower_name:
1406
+ upload.direction = CardinalDirection.SOUTH_WEST
1407
+ upload.save()
1408
+ elif "east" in lower_name:
1409
+ upload.direction = CardinalDirection.EAST
1410
+ upload.save()
1411
+ elif "west" in lower_name:
1412
+ upload.direction = CardinalDirection.WEST
1413
+ upload.save()
1414
+
1415
+ upload.site.synchronize()
1416
+ return Response(self.get_serializer(instance=upload).data, status=200)
1417
+
1418
+ def perform_update(self, serializer):
1419
+ # do permissions check on publish/unpublish action - also only allow
1420
+ # a subset of status changes
1421
+ if (
1422
+ serializer.validated_data.get("status", None) is not None
1423
+ and serializer.validated_data["status"] != serializer.instance.status
1424
+ and not self.site.is_moderator(self.request.user)
1425
+ ):
1426
+ raise PermissionDenied("Must be a moderator to publish site files.")
1427
+ if serializer.validated_data.get("status", None) and (
1428
+ SiteFileUploadStatus(serializer.validated_data["status"])
1429
+ not in {SiteFileUploadStatus.PUBLISHED, SiteFileUploadStatus.UNPUBLISHED}
1430
+ ):
1431
+ raise PermissionDenied("Files may only be published or unpublished.")
1432
+ status = serializer.instance.status
1433
+ super().perform_update(serializer)
1434
+
1435
+ if (status, serializer.instance.status) == (
1436
+ SiteFileUploadStatus.UNPUBLISHED,
1437
+ SiteFileUploadStatus.PUBLISHED,
1438
+ ):
1439
+ slm_signals.site_file_published.send(
1440
+ sender=self,
1441
+ site=self.site,
1442
+ user=self.original_request.user,
1443
+ timestamp=now(),
1444
+ request=self.original_request,
1445
+ upload=serializer.instance,
1446
+ )
1447
+ elif (status, serializer.instance.status) == (
1448
+ SiteFileUploadStatus.PUBLISHED,
1449
+ SiteFileUploadStatus.UNPUBLISHED,
1450
+ ):
1451
+ slm_signals.site_file_unpublished.send(
1452
+ sender=self,
1453
+ site=self.site,
1454
+ user=self.original_request.user,
1455
+ timestamp=now(),
1456
+ request=self.original_request,
1457
+ upload=serializer.instance,
1458
+ )
1459
+ self.site.synchronize()
1460
+
1461
+ def update_from_legacy(self, request, parsed):
1462
+ errors = {}
1463
+
1464
+ existing_sections = {}
1465
+ posted_subsections = {
1466
+ 3: set(),
1467
+ 4: set(),
1468
+ 5: set(),
1469
+ 6: set(),
1470
+ 7: set(),
1471
+ (8, 1): set(),
1472
+ (8, 2): set(),
1473
+ (8, 3): set(),
1474
+ (8, 4): set(),
1475
+ (8, 5): set(),
1476
+ (9, 1): set(),
1477
+ (9, 2): set(),
1478
+ (9, 3): set(),
1479
+ 10: set(),
1480
+ }
1481
+ with transaction.atomic():
1482
+ # we reverse this so we process the form section last which will
1483
+ # ensure that the prepared by field will be set to what was given
1484
+ # in the upload log
1485
+ for index, section in reversed(parsed.sections.items()):
1486
+ if section.example or not section.contains_values:
1487
+ continue
1488
+
1489
+ section_view = self.SECTION_VIEWS.get(section.heading_index, None)
1490
+ if section_view:
1491
+ data = {**section.binding, "site": self.site.id}
1492
+ subsection_number = self.get_subsection_id(section)
1493
+ if subsection_number is not None:
1494
+ # we have to find the right subsection identifiers
1495
+ # (which don't necessarily equal the existing ids)
1496
+ if section.heading_index not in existing_sections:
1497
+ existing_sections[section.heading_index] = list(
1498
+ section_view.serializer_class.Meta.model.objects.filter(
1499
+ site=self.site, is_deleted=False
1500
+ )
1501
+ .head()
1502
+ .sort()
1503
+ )
1504
+ if (
1505
+ len(existing_sections[section.heading_index])
1506
+ > subsection_number - 1
1507
+ ):
1508
+ instance = existing_sections[section.heading_index][
1509
+ subsection_number - 1
1510
+ ]
1511
+ data["subsection"] = instance.subsection
1512
+ posted_subsections[section.heading_index].add(instance)
1513
+
1514
+ serializer = section_view.serializer_class(
1515
+ data=data, context={"request": request}
1516
+ )
1517
+ if not serializer.is_valid(raise_exception=False):
1518
+ errors[index] = {
1519
+ param: "\n".join([str(detail) for detail in details])
1520
+ for param, details in serializer.errors.items()
1521
+ }
1522
+ else:
1523
+ try:
1524
+ serializer.save()
1525
+ for param, flag in serializer.instance._flags.items():
1526
+ params = section.get_params(param)
1527
+ for parsed_param in params:
1528
+ for line_no in range(
1529
+ parsed_param.line_no, parsed_param.line_end + 1
1530
+ ):
1531
+ parsed.add_finding(
1532
+ Warn(
1533
+ line_no,
1534
+ parsed,
1535
+ str(flag),
1536
+ section=section,
1537
+ )
1538
+ )
1539
+ except (DjangoValidationError, DRFValidationError) as dve:
1540
+ for param, error_list in getattr(
1541
+ dve, "message_dict", getattr(dve, "detail")
1542
+ ).items():
1543
+ errors.setdefault(index, {})[param] = "\n".join(
1544
+ [str(msg) for msg in error_list]
1545
+ )
1546
+
1547
+ if errors:
1548
+ # if any section fails - rollback all sections
1549
+ transaction.set_rollback(True)
1550
+ for section_index, section_errors in errors.items():
1551
+ for param, error in section_errors.items():
1552
+ section = parsed.sections[section_index]
1553
+ params = section.get_params(param)
1554
+ if params:
1555
+ for parsed_param in params:
1556
+ for line_no in range(
1557
+ parsed_param.line_no, parsed_param.line_end + 1
1558
+ ):
1559
+ parsed.add_finding(
1560
+ Error(
1561
+ line_no, parsed, str(error), section=section
1562
+ )
1563
+ )
1564
+ else:
1565
+ parsed.add_finding(
1566
+ Error(
1567
+ section.line_no, parsed, str(error), section=section
1568
+ )
1569
+ )
1570
+ return errors
1571
+
1572
+ # delete any subsections that are current but not present in the
1573
+ # uploaded sitelog
1574
+ for heading_index, existing in existing_sections.items():
1575
+ section_view = self.SECTION_VIEWS.get(heading_index, None)
1576
+ if section_view:
1577
+ for instance in set(existing).difference(
1578
+ posted_subsections.get(heading_index, set())
1579
+ ):
1580
+ view = section_view()
1581
+ view.request = request
1582
+ try:
1583
+ view.perform_destroy(instance)
1584
+ except Exception:
1585
+ # catch everything here - if this does not happen
1586
+ # its not the end of the world - the exception log
1587
+ # will notify the relevant parties that there's some
1588
+ # kind of issue
1589
+ self.logger.exception(
1590
+ "Error deleting subsection %d of section %s",
1591
+ instance.subsection,
1592
+ heading_index,
1593
+ )
1594
+ return errors
1595
+
1596
+ def retrieve(self, request, *args, **kwargs):
1597
+ """
1598
+ By default the edit api GET will return a json structure with
1599
+ information about the file. Adding ?download to the url will download
1600
+ the file itself.
1601
+ """
1602
+ if request.GET.get("download", None) is None:
1603
+ return super().retrieve(request, *args, **kwargs)
1604
+
1605
+ file = self.get_object()
1606
+ if request.GET.get("thumbnail", None):
1607
+ file = file.thumbnail
1608
+ else:
1609
+ file = file.file
1610
+ return FileResponse(
1611
+ file.open("rb"),
1612
+ filename=file.name,
1613
+ # note this might not match the name on disk
1614
+ as_attachment=True,
1615
+ )
1616
+
1617
+
1618
+ class ImageOperationsViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
1619
+ permission_classes = (IsAuthenticated, CanEditSite)
1620
+
1621
+ def get_queryset(self):
1622
+ return SiteFileUpload.objects.filter(file_type=SLMFileType.SITE_IMAGE)
1623
+
1624
+ def retrieve(self, request, *args, **kwargs):
1625
+ rotate = request.GET.get("rotate", None)
1626
+ try:
1627
+ if rotate:
1628
+ file = self.get_object()
1629
+ file.rotate(int(rotate))
1630
+ except ValueError:
1631
+ return Response({"rotate": "rotate must be an integer"}, status=400)
1632
+ return Response(status=204)