From 2001c53541d2c3d977244bc97aea5ceb1f1faeba Mon Sep 17 00:00:00 2001 From: Michael Herman Date: Wed, 29 Nov 2023 14:05:11 -0600 Subject: [PATCH] updates --- .dockerignore | 7 + .gitignore | 2 + Dockerfile | 78 + core/settings.py | 67 +- core/urls.py | 1 + cypress.config.js | 11 + cypress/e2e/e2e.cy.js | 98 + cypress/fixtures/example.json | 5 + cypress/fixtures/gift.json | 5 + cypress/fixtures/party.json | 6 + cypress/fixtures/updated_gift.json | 5 + cypress/fixtures/updated_party.json | 6 + cypress/support/commands.js | 17 + cypress/support/e2e.js | 20 + docker-compose.yml | 23 + heroku.yml | 8 + party/forms.py | 63 + party/static/party/js/remove-me.js | 27 + party/templates/party/base.html | 36 +- .../general/page_party_organizer_login.html | 28 + .../gift_registry/page_gift_registry.html | 37 + .../gift_registry/partial_gift_detail.html | 28 + .../party/gift_registry/partial_gift_new.html | 24 + .../gift_registry/partial_gift_removed.html | 9 + .../gift_registry/partial_gift_update.html | 25 + .../party/guest_list/page_guest_list.html | 51 + .../guest_list/partial_guest_filter.html | 29 + .../partial_guest_filter_and_list.html | 3 + .../party/guest_list/partial_guest_list.html | 19 + .../party/new_party/page_new_party.html | 14 + .../party/party_detail/page_party_detail.html | 35 + .../party_detail/partial_party_detail.html | 20 + .../party_detail/partial_party_edit_form.html | 26 + .../party/party_list/page_parties_list.html | 19 +- .../party_list/partial_parties_list.html | 39 + party/tests/test_gift_registry.py | 118 + party/tests/test_guest_list.py | 103 + party/tests/test_new_party.py | 79 + party/tests/test_party_detail.py | 54 + party/tests/test_party_list.py | 32 + party/urls.py | 39 +- party/views/__init__.py | 24 +- party/views/general_views.py | 5 + party/views/gift_registry_views.py | 95 + party/views/guest_list_views.py | 91 + party/views/new_party_views.py | 35 + party/views/party_details_views.py | 34 + party/views/party_list_views.py | 13 +- requirements.txt | 7 + staticfiles/admin/css/autocomplete.css | 275 + staticfiles/admin/css/autocomplete.css.gz | Bin 0 -> 1147 bytes staticfiles/admin/css/base.css | 1138 ++ staticfiles/admin/css/base.css.gz | Bin 0 -> 4737 bytes staticfiles/admin/css/changelists.css | 328 + staticfiles/admin/css/changelists.css.gz | Bin 0 -> 1564 bytes staticfiles/admin/css/dark_mode.css | 137 + staticfiles/admin/css/dark_mode.css.gz | Bin 0 -> 849 bytes staticfiles/admin/css/dashboard.css | 29 + staticfiles/admin/css/dashboard.css.gz | Bin 0 -> 267 bytes staticfiles/admin/css/forms.css | 530 + staticfiles/admin/css/forms.css.gz | Bin 0 -> 2199 bytes staticfiles/admin/css/login.css | 61 + staticfiles/admin/css/login.css.gz | Bin 0 -> 417 bytes staticfiles/admin/css/nav_sidebar.css | 144 + staticfiles/admin/css/nav_sidebar.css.gz | Bin 0 -> 779 bytes staticfiles/admin/css/responsive.css | 998 ++ staticfiles/admin/css/responsive.css.gz | Bin 0 -> 3432 bytes staticfiles/admin/css/responsive_rtl.css | 81 + staticfiles/admin/css/responsive_rtl.css.gz | Bin 0 -> 527 bytes staticfiles/admin/css/rtl.css | 288 + staticfiles/admin/css/rtl.css.gz | Bin 0 -> 1229 bytes .../css/vendor/select2/LICENSE-SELECT2.md | 21 + .../css/vendor/select2/LICENSE-SELECT2.md.gz | Bin 0 -> 685 bytes .../admin/css/vendor/select2/select2.css | 481 + .../admin/css/vendor/select2/select2.css.gz | Bin 0 -> 2232 bytes .../admin/css/vendor/select2/select2.min.css | 1 + .../css/vendor/select2/select2.min.css.gz | Bin 0 -> 1978 bytes staticfiles/admin/css/widgets.css | 603 + staticfiles/admin/css/widgets.css.gz | Bin 0 -> 2468 bytes staticfiles/admin/img/LICENSE | 20 + staticfiles/admin/img/LICENSE.gz | Bin 0 -> 656 bytes staticfiles/admin/img/README.txt | 7 + staticfiles/admin/img/README.txt.gz | Bin 0 -> 214 bytes staticfiles/admin/img/calendar-icons.svg | 14 + staticfiles/admin/img/calendar-icons.svg.gz | Bin 0 -> 385 bytes staticfiles/admin/img/gis/move_vertex_off.svg | 1 + .../admin/img/gis/move_vertex_off.svg.gz | Bin 0 -> 470 bytes staticfiles/admin/img/gis/move_vertex_on.svg | 1 + .../admin/img/gis/move_vertex_on.svg.gz | Bin 0 -> 472 bytes staticfiles/admin/img/icon-addlink.svg | 3 + staticfiles/admin/img/icon-addlink.svg.gz | Bin 0 -> 206 bytes staticfiles/admin/img/icon-alert.svg | 3 + staticfiles/admin/img/icon-alert.svg.gz | Bin 0 -> 329 bytes staticfiles/admin/img/icon-calendar.svg | 9 + staticfiles/admin/img/icon-calendar.svg.gz | Bin 0 -> 438 bytes staticfiles/admin/img/icon-changelink.svg | 3 + staticfiles/admin/img/icon-changelink.svg.gz | Bin 0 -> 269 bytes staticfiles/admin/img/icon-clock.svg | 9 + staticfiles/admin/img/icon-clock.svg.gz | Bin 0 -> 357 bytes staticfiles/admin/img/icon-deletelink.svg | 3 + staticfiles/admin/img/icon-deletelink.svg.gz | Bin 0 -> 221 bytes staticfiles/admin/img/icon-no.svg | 3 + staticfiles/admin/img/icon-no.svg.gz | Bin 0 -> 297 bytes staticfiles/admin/img/icon-unknown-alt.svg | 3 + staticfiles/admin/img/icon-unknown-alt.svg.gz | Bin 0 -> 377 bytes staticfiles/admin/img/icon-unknown.svg | 3 + staticfiles/admin/img/icon-unknown.svg.gz | Bin 0 -> 377 bytes staticfiles/admin/img/icon-viewlink.svg | 3 + staticfiles/admin/img/icon-viewlink.svg.gz | Bin 0 -> 346 bytes staticfiles/admin/img/icon-yes.svg | 3 + staticfiles/admin/img/icon-yes.svg.gz | Bin 0 -> 266 bytes staticfiles/admin/img/inline-delete.svg | 3 + staticfiles/admin/img/inline-delete.svg.gz | Bin 0 -> 293 bytes staticfiles/admin/img/search.svg | 3 + staticfiles/admin/img/search.svg.gz | Bin 0 -> 264 bytes staticfiles/admin/img/selector-icons.svg | 34 + staticfiles/admin/img/selector-icons.svg.gz | Bin 0 -> 770 bytes staticfiles/admin/img/sorting-icons.svg | 19 + staticfiles/admin/img/sorting-icons.svg.gz | Bin 0 -> 366 bytes staticfiles/admin/img/tooltag-add.svg | 3 + staticfiles/admin/img/tooltag-add.svg.gz | Bin 0 -> 203 bytes staticfiles/admin/img/tooltag-arrowright.svg | 3 + .../admin/img/tooltag-arrowright.svg.gz | Bin 0 -> 194 bytes staticfiles/admin/js/SelectBox.js | 116 + staticfiles/admin/js/SelectBox.js.gz | Bin 0 -> 1025 bytes staticfiles/admin/js/SelectFilter2.js | 283 + staticfiles/admin/js/SelectFilter2.js.gz | Bin 0 -> 2914 bytes staticfiles/admin/js/actions.js | 201 + staticfiles/admin/js/actions.js.gz | Bin 0 -> 1874 bytes .../admin/js/admin/DateTimeShortcuts.js | 408 + .../admin/js/admin/DateTimeShortcuts.js.gz | Bin 0 -> 3645 bytes .../admin/js/admin/RelatedObjectLookups.js | 238 + .../admin/js/admin/RelatedObjectLookups.js.gz | Bin 0 -> 2301 bytes staticfiles/admin/js/autocomplete.js | 33 + staticfiles/admin/js/autocomplete.js.gz | Bin 0 -> 425 bytes staticfiles/admin/js/calendar.js | 221 + staticfiles/admin/js/calendar.js.gz | Bin 0 -> 2193 bytes staticfiles/admin/js/cancel.js | 29 + staticfiles/admin/js/cancel.js.gz | Bin 0 -> 430 bytes staticfiles/admin/js/change_form.js | 16 + staticfiles/admin/js/change_form.js.gz | Bin 0 -> 322 bytes staticfiles/admin/js/collapse.js | 43 + staticfiles/admin/js/collapse.js.gz | Bin 0 -> 614 bytes staticfiles/admin/js/core.js | 170 + staticfiles/admin/js/core.js.gz | Bin 0 -> 1505 bytes staticfiles/admin/js/filters.js | 30 + staticfiles/admin/js/filters.js.gz | Bin 0 -> 502 bytes staticfiles/admin/js/inlines.js | 359 + staticfiles/admin/js/inlines.js.gz | Bin 0 -> 3744 bytes staticfiles/admin/js/jquery.init.js | 8 + staticfiles/admin/js/jquery.init.js.gz | Bin 0 -> 236 bytes staticfiles/admin/js/nav_sidebar.js | 79 + staticfiles/admin/js/nav_sidebar.js.gz | Bin 0 -> 845 bytes staticfiles/admin/js/popup_response.js | 16 + staticfiles/admin/js/popup_response.js.gz | Bin 0 -> 270 bytes staticfiles/admin/js/prepopulate.js | 43 + staticfiles/admin/js/prepopulate.js.gz | Bin 0 -> 536 bytes staticfiles/admin/js/prepopulate_init.js | 15 + staticfiles/admin/js/prepopulate_init.js.gz | Bin 0 -> 277 bytes staticfiles/admin/js/theme.js | 56 + staticfiles/admin/js/theme.js.gz | Bin 0 -> 605 bytes staticfiles/admin/js/urlify.js | 169 + staticfiles/admin/js/urlify.js.gz | Bin 0 -> 2578 bytes .../admin/js/vendor/jquery/LICENSE.txt | 20 + .../admin/js/vendor/jquery/LICENSE.txt.gz | Bin 0 -> 656 bytes staticfiles/admin/js/vendor/jquery/jquery.js | 10965 ++++++++++++++++ .../admin/js/vendor/jquery/jquery.js.gz | Bin 0 -> 86002 bytes .../admin/js/vendor/jquery/jquery.min.js | 2 + .../admin/js/vendor/jquery/jquery.min.js.gz | Bin 0 -> 31011 bytes .../admin/js/vendor/select2/LICENSE.md | 21 + .../admin/js/vendor/select2/LICENSE.md.gz | Bin 0 -> 685 bytes .../admin/js/vendor/select2/i18n/af.js | 3 + .../admin/js/vendor/select2/i18n/af.js.gz | Bin 0 -> 460 bytes .../admin/js/vendor/select2/i18n/ar.js | 3 + .../admin/js/vendor/select2/i18n/ar.js.gz | Bin 0 -> 498 bytes .../admin/js/vendor/select2/i18n/az.js | 3 + .../admin/js/vendor/select2/i18n/az.js.gz | Bin 0 -> 413 bytes .../admin/js/vendor/select2/i18n/bg.js | 3 + .../admin/js/vendor/select2/i18n/bg.js.gz | Bin 0 -> 541 bytes .../admin/js/vendor/select2/i18n/bn.js | 3 + .../admin/js/vendor/select2/i18n/bn.js.gz | Bin 0 -> 553 bytes .../admin/js/vendor/select2/i18n/bs.js | 3 + .../admin/js/vendor/select2/i18n/bs.js.gz | Bin 0 -> 523 bytes .../admin/js/vendor/select2/i18n/ca.js | 3 + .../admin/js/vendor/select2/i18n/ca.js.gz | Bin 0 -> 470 bytes .../admin/js/vendor/select2/i18n/cs.js | 3 + .../admin/js/vendor/select2/i18n/cs.js.gz | Bin 0 -> 623 bytes .../admin/js/vendor/select2/i18n/da.js | 3 + .../admin/js/vendor/select2/i18n/da.js.gz | Bin 0 -> 441 bytes .../admin/js/vendor/select2/i18n/de.js | 3 + .../admin/js/vendor/select2/i18n/de.js.gz | Bin 0 -> 467 bytes .../admin/js/vendor/select2/i18n/dsb.js | 3 + .../admin/js/vendor/select2/i18n/dsb.js.gz | Bin 0 -> 551 bytes .../admin/js/vendor/select2/i18n/el.js | 3 + .../admin/js/vendor/select2/i18n/el.js.gz | Bin 0 -> 644 bytes .../admin/js/vendor/select2/i18n/en.js | 3 + .../admin/js/vendor/select2/i18n/en.js.gz | Bin 0 -> 447 bytes .../admin/js/vendor/select2/i18n/es.js | 3 + .../admin/js/vendor/select2/i18n/es.js.gz | Bin 0 -> 474 bytes .../admin/js/vendor/select2/i18n/et.js | 3 + .../admin/js/vendor/select2/i18n/et.js.gz | Bin 0 -> 432 bytes .../admin/js/vendor/select2/i18n/eu.js | 3 + .../admin/js/vendor/select2/i18n/eu.js.gz | Bin 0 -> 450 bytes .../admin/js/vendor/select2/i18n/fa.js | 3 + .../admin/js/vendor/select2/i18n/fa.js.gz | Bin 0 -> 538 bytes .../admin/js/vendor/select2/i18n/fi.js | 3 + .../admin/js/vendor/select2/i18n/fi.js.gz | Bin 0 -> 429 bytes .../admin/js/vendor/select2/i18n/fr.js | 3 + .../admin/js/vendor/select2/i18n/fr.js.gz | Bin 0 -> 484 bytes .../admin/js/vendor/select2/i18n/gl.js | 3 + .../admin/js/vendor/select2/i18n/gl.js.gz | Bin 0 -> 465 bytes .../admin/js/vendor/select2/i18n/he.js | 3 + .../admin/js/vendor/select2/i18n/he.js.gz | Bin 0 -> 518 bytes .../admin/js/vendor/select2/i18n/hi.js | 3 + .../admin/js/vendor/select2/i18n/hi.js.gz | Bin 0 -> 572 bytes .../admin/js/vendor/select2/i18n/hr.js | 3 + .../admin/js/vendor/select2/i18n/hr.js.gz | Bin 0 -> 477 bytes .../admin/js/vendor/select2/i18n/hsb.js | 3 + .../admin/js/vendor/select2/i18n/hsb.js.gz | Bin 0 -> 556 bytes .../admin/js/vendor/select2/i18n/hu.js | 3 + .../admin/js/vendor/select2/i18n/hu.js.gz | Bin 0 -> 467 bytes .../admin/js/vendor/select2/i18n/hy.js | 3 + .../admin/js/vendor/select2/i18n/hy.js.gz | Bin 0 -> 530 bytes .../admin/js/vendor/select2/i18n/id.js | 3 + .../admin/js/vendor/select2/i18n/id.js.gz | Bin 0 -> 416 bytes .../admin/js/vendor/select2/i18n/is.js | 3 + .../admin/js/vendor/select2/i18n/is.js.gz | Bin 0 -> 465 bytes .../admin/js/vendor/select2/i18n/it.js | 3 + .../admin/js/vendor/select2/i18n/it.js.gz | Bin 0 -> 488 bytes .../admin/js/vendor/select2/i18n/ja.js | 3 + .../admin/js/vendor/select2/i18n/ja.js.gz | Bin 0 -> 511 bytes .../admin/js/vendor/select2/i18n/ka.js | 3 + .../admin/js/vendor/select2/i18n/ka.js.gz | Bin 0 -> 533 bytes .../admin/js/vendor/select2/i18n/km.js | 3 + .../admin/js/vendor/select2/i18n/km.js.gz | Bin 0 -> 540 bytes .../admin/js/vendor/select2/i18n/ko.js | 3 + .../admin/js/vendor/select2/i18n/ko.js.gz | Bin 0 -> 506 bytes .../admin/js/vendor/select2/i18n/lt.js | 3 + .../admin/js/vendor/select2/i18n/lt.js.gz | Bin 0 -> 521 bytes .../admin/js/vendor/select2/i18n/lv.js | 3 + .../admin/js/vendor/select2/i18n/lv.js.gz | Bin 0 -> 505 bytes .../admin/js/vendor/select2/i18n/mk.js | 3 + .../admin/js/vendor/select2/i18n/mk.js.gz | Bin 0 -> 557 bytes .../admin/js/vendor/select2/i18n/ms.js | 3 + .../admin/js/vendor/select2/i18n/ms.js.gz | Bin 0 -> 436 bytes .../admin/js/vendor/select2/i18n/nb.js | 3 + .../admin/js/vendor/select2/i18n/nb.js.gz | Bin 0 -> 413 bytes .../admin/js/vendor/select2/i18n/ne.js | 3 + .../admin/js/vendor/select2/i18n/ne.js.gz | Bin 0 -> 591 bytes .../admin/js/vendor/select2/i18n/nl.js | 3 + .../admin/js/vendor/select2/i18n/nl.js.gz | Bin 0 -> 469 bytes .../admin/js/vendor/select2/i18n/pl.js | 3 + .../admin/js/vendor/select2/i18n/pl.js.gz | Bin 0 -> 524 bytes .../admin/js/vendor/select2/i18n/ps.js | 3 + .../admin/js/vendor/select2/i18n/ps.js.gz | Bin 0 -> 587 bytes .../admin/js/vendor/select2/i18n/pt-BR.js | 3 + .../admin/js/vendor/select2/i18n/pt-BR.js.gz | Bin 0 -> 486 bytes .../admin/js/vendor/select2/i18n/pt.js | 3 + .../admin/js/vendor/select2/i18n/pt.js.gz | Bin 0 -> 470 bytes .../admin/js/vendor/select2/i18n/ro.js | 3 + .../admin/js/vendor/select2/i18n/ro.js.gz | Bin 0 -> 511 bytes .../admin/js/vendor/select2/i18n/ru.js | 3 + .../admin/js/vendor/select2/i18n/ru.js.gz | Bin 0 -> 632 bytes .../admin/js/vendor/select2/i18n/sk.js | 3 + .../admin/js/vendor/select2/i18n/sk.js.gz | Bin 0 -> 617 bytes .../admin/js/vendor/select2/i18n/sl.js | 3 + .../admin/js/vendor/select2/i18n/sl.js.gz | Bin 0 -> 487 bytes .../admin/js/vendor/select2/i18n/sq.js | 3 + .../admin/js/vendor/select2/i18n/sq.js.gz | Bin 0 -> 490 bytes .../admin/js/vendor/select2/i18n/sr-Cyrl.js | 3 + .../js/vendor/select2/i18n/sr-Cyrl.js.gz | Bin 0 -> 608 bytes .../admin/js/vendor/select2/i18n/sr.js | 3 + .../admin/js/vendor/select2/i18n/sr.js.gz | Bin 0 -> 552 bytes .../admin/js/vendor/select2/i18n/sv.js | 3 + .../admin/js/vendor/select2/i18n/sv.js.gz | Bin 0 -> 429 bytes .../admin/js/vendor/select2/i18n/th.js | 3 + .../admin/js/vendor/select2/i18n/th.js.gz | Bin 0 -> 515 bytes .../admin/js/vendor/select2/i18n/tk.js | 3 + .../admin/js/vendor/select2/i18n/tk.js.gz | Bin 0 -> 434 bytes .../admin/js/vendor/select2/i18n/tr.js | 3 + .../admin/js/vendor/select2/i18n/tr.js.gz | Bin 0 -> 423 bytes .../admin/js/vendor/select2/i18n/uk.js | 3 + .../admin/js/vendor/select2/i18n/uk.js.gz | Bin 0 -> 626 bytes .../admin/js/vendor/select2/i18n/vi.js | 3 + .../admin/js/vendor/select2/i18n/vi.js.gz | Bin 0 -> 479 bytes .../admin/js/vendor/select2/i18n/zh-CN.js | 3 + .../admin/js/vendor/select2/i18n/zh-CN.js.gz | Bin 0 -> 468 bytes .../admin/js/vendor/select2/i18n/zh-TW.js | 3 + .../admin/js/vendor/select2/i18n/zh-TW.js.gz | Bin 0 -> 451 bytes .../admin/js/vendor/select2/select2.full.js | 6820 ++++++++++ .../js/vendor/select2/select2.full.js.gz | Bin 0 -> 37925 bytes .../js/vendor/select2/select2.full.min.js | 2 + .../js/vendor/select2/select2.full.min.js.gz | Bin 0 -> 21986 bytes .../admin/js/vendor/xregexp/LICENSE.txt | 21 + .../admin/js/vendor/xregexp/LICENSE.txt.gz | Bin 0 -> 679 bytes .../admin/js/vendor/xregexp/xregexp.js | 4652 +++++++ .../admin/js/vendor/xregexp/xregexp.js.gz | Bin 0 -> 60899 bytes .../admin/js/vendor/xregexp/xregexp.min.js | 160 + .../admin/js/vendor/xregexp/xregexp.min.js.gz | Bin 0 -> 37609 bytes .../django-browser-reload/reload-listener.js | 28 + .../reload-listener.js.gz | Bin 0 -> 324 bytes .../django-browser-reload/reload-worker.js | 112 + .../django-browser-reload/reload-worker.js.gz | Bin 0 -> 864 bytes staticfiles/facebook/js/fbconnect.js | 125 + staticfiles/facebook/js/fbconnect.js.gz | Bin 0 -> 1133 bytes staticfiles/party/js/alpine.min.js | 5 + staticfiles/party/js/alpine.min.js.gz | Bin 0 -> 15541 bytes staticfiles/party/js/htmx.min.js | 1 + staticfiles/party/js/htmx.min.js.gz | Bin 0 -> 15224 bytes staticfiles/party/js/remove-me.js | 27 + staticfiles/party/js/remove-me.js.gz | Bin 0 -> 376 bytes theme/static_src/tailwind.config.js | 3 + 312 files changed, 32515 insertions(+), 47 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cypress.config.js create mode 100644 cypress/e2e/e2e.cy.js create mode 100644 cypress/fixtures/example.json create mode 100644 cypress/fixtures/gift.json create mode 100644 cypress/fixtures/party.json create mode 100644 cypress/fixtures/updated_gift.json create mode 100644 cypress/fixtures/updated_party.json create mode 100644 cypress/support/commands.js create mode 100644 cypress/support/e2e.js create mode 100644 docker-compose.yml create mode 100644 heroku.yml create mode 100644 party/forms.py create mode 100644 party/static/party/js/remove-me.js create mode 100644 party/templates/party/general/page_party_organizer_login.html create mode 100644 party/templates/party/gift_registry/page_gift_registry.html create mode 100644 party/templates/party/gift_registry/partial_gift_detail.html create mode 100644 party/templates/party/gift_registry/partial_gift_new.html create mode 100644 party/templates/party/gift_registry/partial_gift_removed.html create mode 100644 party/templates/party/gift_registry/partial_gift_update.html create mode 100644 party/templates/party/guest_list/page_guest_list.html create mode 100644 party/templates/party/guest_list/partial_guest_filter.html create mode 100644 party/templates/party/guest_list/partial_guest_filter_and_list.html create mode 100644 party/templates/party/guest_list/partial_guest_list.html create mode 100644 party/templates/party/new_party/page_new_party.html create mode 100644 party/templates/party/party_detail/page_party_detail.html create mode 100644 party/templates/party/party_detail/partial_party_detail.html create mode 100644 party/templates/party/party_detail/partial_party_edit_form.html create mode 100644 party/templates/party/party_list/partial_parties_list.html create mode 100644 party/tests/test_gift_registry.py create mode 100644 party/tests/test_guest_list.py create mode 100644 party/tests/test_new_party.py create mode 100644 party/tests/test_party_detail.py create mode 100644 party/views/general_views.py create mode 100644 party/views/gift_registry_views.py create mode 100644 party/views/guest_list_views.py create mode 100644 party/views/new_party_views.py create mode 100644 party/views/party_details_views.py create mode 100644 staticfiles/admin/css/autocomplete.css create mode 100644 staticfiles/admin/css/autocomplete.css.gz create mode 100644 staticfiles/admin/css/base.css create mode 100644 staticfiles/admin/css/base.css.gz create mode 100644 staticfiles/admin/css/changelists.css create mode 100644 staticfiles/admin/css/changelists.css.gz create mode 100644 staticfiles/admin/css/dark_mode.css create mode 100644 staticfiles/admin/css/dark_mode.css.gz create mode 100644 staticfiles/admin/css/dashboard.css create mode 100644 staticfiles/admin/css/dashboard.css.gz create mode 100644 staticfiles/admin/css/forms.css create mode 100644 staticfiles/admin/css/forms.css.gz create mode 100644 staticfiles/admin/css/login.css create mode 100644 staticfiles/admin/css/login.css.gz create mode 100644 staticfiles/admin/css/nav_sidebar.css create mode 100644 staticfiles/admin/css/nav_sidebar.css.gz create mode 100644 staticfiles/admin/css/responsive.css create mode 100644 staticfiles/admin/css/responsive.css.gz create mode 100644 staticfiles/admin/css/responsive_rtl.css create mode 100644 staticfiles/admin/css/responsive_rtl.css.gz create mode 100644 staticfiles/admin/css/rtl.css create mode 100644 staticfiles/admin/css/rtl.css.gz create mode 100644 staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md create mode 100644 staticfiles/admin/css/vendor/select2/LICENSE-SELECT2.md.gz create mode 100644 staticfiles/admin/css/vendor/select2/select2.css create mode 100644 staticfiles/admin/css/vendor/select2/select2.css.gz create mode 100644 staticfiles/admin/css/vendor/select2/select2.min.css create mode 100644 staticfiles/admin/css/vendor/select2/select2.min.css.gz create mode 100644 staticfiles/admin/css/widgets.css create mode 100644 staticfiles/admin/css/widgets.css.gz create mode 100644 staticfiles/admin/img/LICENSE create mode 100644 staticfiles/admin/img/LICENSE.gz create mode 100644 staticfiles/admin/img/README.txt create mode 100644 staticfiles/admin/img/README.txt.gz create mode 100644 staticfiles/admin/img/calendar-icons.svg create mode 100644 staticfiles/admin/img/calendar-icons.svg.gz create mode 100644 staticfiles/admin/img/gis/move_vertex_off.svg create mode 100644 staticfiles/admin/img/gis/move_vertex_off.svg.gz create mode 100644 staticfiles/admin/img/gis/move_vertex_on.svg create mode 100644 staticfiles/admin/img/gis/move_vertex_on.svg.gz create mode 100644 staticfiles/admin/img/icon-addlink.svg create mode 100644 staticfiles/admin/img/icon-addlink.svg.gz create mode 100644 staticfiles/admin/img/icon-alert.svg create mode 100644 staticfiles/admin/img/icon-alert.svg.gz create mode 100644 staticfiles/admin/img/icon-calendar.svg create mode 100644 staticfiles/admin/img/icon-calendar.svg.gz create mode 100644 staticfiles/admin/img/icon-changelink.svg create mode 100644 staticfiles/admin/img/icon-changelink.svg.gz create mode 100644 staticfiles/admin/img/icon-clock.svg create mode 100644 staticfiles/admin/img/icon-clock.svg.gz create mode 100644 staticfiles/admin/img/icon-deletelink.svg create mode 100644 staticfiles/admin/img/icon-deletelink.svg.gz create mode 100644 staticfiles/admin/img/icon-no.svg create mode 100644 staticfiles/admin/img/icon-no.svg.gz create mode 100644 staticfiles/admin/img/icon-unknown-alt.svg create mode 100644 staticfiles/admin/img/icon-unknown-alt.svg.gz create mode 100644 staticfiles/admin/img/icon-unknown.svg create mode 100644 staticfiles/admin/img/icon-unknown.svg.gz create mode 100644 staticfiles/admin/img/icon-viewlink.svg create mode 100644 staticfiles/admin/img/icon-viewlink.svg.gz create mode 100644 staticfiles/admin/img/icon-yes.svg create mode 100644 staticfiles/admin/img/icon-yes.svg.gz create mode 100644 staticfiles/admin/img/inline-delete.svg create mode 100644 staticfiles/admin/img/inline-delete.svg.gz create mode 100644 staticfiles/admin/img/search.svg create mode 100644 staticfiles/admin/img/search.svg.gz create mode 100644 staticfiles/admin/img/selector-icons.svg create mode 100644 staticfiles/admin/img/selector-icons.svg.gz create mode 100644 staticfiles/admin/img/sorting-icons.svg create mode 100644 staticfiles/admin/img/sorting-icons.svg.gz create mode 100644 staticfiles/admin/img/tooltag-add.svg create mode 100644 staticfiles/admin/img/tooltag-add.svg.gz create mode 100644 staticfiles/admin/img/tooltag-arrowright.svg create mode 100644 staticfiles/admin/img/tooltag-arrowright.svg.gz create mode 100644 staticfiles/admin/js/SelectBox.js create mode 100644 staticfiles/admin/js/SelectBox.js.gz create mode 100644 staticfiles/admin/js/SelectFilter2.js create mode 100644 staticfiles/admin/js/SelectFilter2.js.gz create mode 100644 staticfiles/admin/js/actions.js create mode 100644 staticfiles/admin/js/actions.js.gz create mode 100644 staticfiles/admin/js/admin/DateTimeShortcuts.js create mode 100644 staticfiles/admin/js/admin/DateTimeShortcuts.js.gz create mode 100644 staticfiles/admin/js/admin/RelatedObjectLookups.js create mode 100644 staticfiles/admin/js/admin/RelatedObjectLookups.js.gz create mode 100644 staticfiles/admin/js/autocomplete.js create mode 100644 staticfiles/admin/js/autocomplete.js.gz create mode 100644 staticfiles/admin/js/calendar.js create mode 100644 staticfiles/admin/js/calendar.js.gz create mode 100644 staticfiles/admin/js/cancel.js create mode 100644 staticfiles/admin/js/cancel.js.gz create mode 100644 staticfiles/admin/js/change_form.js create mode 100644 staticfiles/admin/js/change_form.js.gz create mode 100644 staticfiles/admin/js/collapse.js create mode 100644 staticfiles/admin/js/collapse.js.gz create mode 100644 staticfiles/admin/js/core.js create mode 100644 staticfiles/admin/js/core.js.gz create mode 100644 staticfiles/admin/js/filters.js create mode 100644 staticfiles/admin/js/filters.js.gz create mode 100644 staticfiles/admin/js/inlines.js create mode 100644 staticfiles/admin/js/inlines.js.gz create mode 100644 staticfiles/admin/js/jquery.init.js create mode 100644 staticfiles/admin/js/jquery.init.js.gz create mode 100644 staticfiles/admin/js/nav_sidebar.js create mode 100644 staticfiles/admin/js/nav_sidebar.js.gz create mode 100644 staticfiles/admin/js/popup_response.js create mode 100644 staticfiles/admin/js/popup_response.js.gz create mode 100644 staticfiles/admin/js/prepopulate.js create mode 100644 staticfiles/admin/js/prepopulate.js.gz create mode 100644 staticfiles/admin/js/prepopulate_init.js create mode 100644 staticfiles/admin/js/prepopulate_init.js.gz create mode 100644 staticfiles/admin/js/theme.js create mode 100644 staticfiles/admin/js/theme.js.gz create mode 100644 staticfiles/admin/js/urlify.js create mode 100644 staticfiles/admin/js/urlify.js.gz create mode 100644 staticfiles/admin/js/vendor/jquery/LICENSE.txt create mode 100644 staticfiles/admin/js/vendor/jquery/LICENSE.txt.gz create mode 100644 staticfiles/admin/js/vendor/jquery/jquery.js create mode 100644 staticfiles/admin/js/vendor/jquery/jquery.js.gz create mode 100644 staticfiles/admin/js/vendor/jquery/jquery.min.js create mode 100644 staticfiles/admin/js/vendor/jquery/jquery.min.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/LICENSE.md create mode 100644 staticfiles/admin/js/vendor/select2/LICENSE.md.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/af.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/af.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ar.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ar.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/az.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/az.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bg.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bg.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bn.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bn.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bs.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/bs.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ca.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ca.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/cs.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/cs.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/da.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/da.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/de.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/de.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/dsb.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/dsb.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/el.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/el.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/en.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/en.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/es.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/es.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/et.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/et.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/eu.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/eu.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fa.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fa.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fi.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fi.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fr.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/fr.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/gl.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/gl.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/he.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/he.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hi.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hi.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hr.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hr.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hsb.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hsb.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hu.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hu.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hy.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/hy.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/id.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/id.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/is.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/is.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/it.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/it.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ja.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ja.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ka.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ka.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/km.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/km.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ko.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ko.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/lt.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/lt.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/lv.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/lv.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/mk.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/mk.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ms.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ms.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/nb.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/nb.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ne.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ne.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/nl.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/nl.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pl.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pl.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ps.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ps.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pt-BR.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pt-BR.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pt.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/pt.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ro.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ro.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ru.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/ru.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sk.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sk.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sl.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sl.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sq.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sq.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sr-Cyrl.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sr-Cyrl.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sr.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sr.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sv.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/sv.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/th.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/th.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/tk.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/tk.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/tr.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/tr.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/uk.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/uk.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/vi.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/vi.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/zh-CN.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/zh-CN.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/i18n/zh-TW.js create mode 100644 staticfiles/admin/js/vendor/select2/i18n/zh-TW.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/select2.full.js create mode 100644 staticfiles/admin/js/vendor/select2/select2.full.js.gz create mode 100644 staticfiles/admin/js/vendor/select2/select2.full.min.js create mode 100644 staticfiles/admin/js/vendor/select2/select2.full.min.js.gz create mode 100644 staticfiles/admin/js/vendor/xregexp/LICENSE.txt create mode 100644 staticfiles/admin/js/vendor/xregexp/LICENSE.txt.gz create mode 100644 staticfiles/admin/js/vendor/xregexp/xregexp.js create mode 100644 staticfiles/admin/js/vendor/xregexp/xregexp.js.gz create mode 100644 staticfiles/admin/js/vendor/xregexp/xregexp.min.js create mode 100644 staticfiles/admin/js/vendor/xregexp/xregexp.min.js.gz create mode 100644 staticfiles/django-browser-reload/reload-listener.js create mode 100644 staticfiles/django-browser-reload/reload-listener.js.gz create mode 100644 staticfiles/django-browser-reload/reload-worker.js create mode 100644 staticfiles/django-browser-reload/reload-worker.js.gz create mode 100644 staticfiles/facebook/js/fbconnect.js create mode 100644 staticfiles/facebook/js/fbconnect.js.gz create mode 100644 staticfiles/party/js/alpine.min.js create mode 100644 staticfiles/party/js/alpine.min.js.gz create mode 100644 staticfiles/party/js/htmx.min.js create mode 100644 staticfiles/party/js/htmx.min.js.gz create mode 100644 staticfiles/party/js/remove-me.js create mode 100644 staticfiles/party/js/remove-me.js.gz diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..85e1ca4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +venv +.dockerignore +Dockerfile +.git +.gitignore +.pytest_cache +.github diff --git a/.gitignore b/.gitignore index 68bc17f..e9891a3 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +cypress.env.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e09d7d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,78 @@ +########### +# BUILDER # +########### + +# pull official base image +FROM python:3.12-slim-bookworm as builder + +# install system dependencies +RUN apt-get update \ + && apt-get -y install g++ ca-certificates curl gnupg \ + && apt-get clean + +# install node +ENV NODE_MAJOR=20 +RUN mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + 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 update && apt-get install nodejs -y + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install python dependencies +COPY . . +RUN pip install --upgrade pip && pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels -r requirements.txt + +# Build Tailwind +RUN pip install -r requirements.txt && python manage.py tailwind install && python manage.py tailwind build && python manage.py collectstatic --noinput + +######### +# FINAL # +######### + +# pull official base image +FROM python:3.12-slim-bookworm + +# upgrade system packages +RUN apt-get update && apt-get upgrade -y && apt-get clean + +# create directory for the app user +RUN mkdir -p /home/app + +# create the app user +RUN addgroup --system app && adduser --system --group app + +# create the appropriate directories +ENV HOME=/home/app +ENV APP_HOME=/home/app/web +RUN mkdir $APP_HOME +WORKDIR $APP_HOME + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV ENVIRONMENT prod +ENV TESTING 0 +ENV PYTHONPATH $APP_HOME + +# install dependencies +COPY --from=builder /usr/src/app/wheels /wheels +COPY --from=builder /usr/src/app/requirements.txt . +COPY --from=builder /usr/src/app/staticfiles $APP_HOME/staticfiles +RUN pip install --upgrade pip +RUN pip install --no-cache /wheels/* + +# copy project +COPY . $APP_HOME + +# chown all the files to the app user +RUN chown -R app:app $HOME +# change to the app user +USER app +# serve the application +CMD gunicorn core.wsgi:application --bind 0.0.0.0:$PORT diff --git a/core/settings.py b/core/settings.py index b5f53f6..f9e9a65 100644 --- a/core/settings.py +++ b/core/settings.py @@ -10,8 +10,11 @@ https://docs.djangoproject.com/en/4.0/ref/settings/ """ +import os from pathlib import Path +import dj_database_url + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -19,14 +22,17 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-%hussx3!p=8@%iej1k_7vxd*!6acbv7ln93_+_2ia8b-becqc1' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +SECRET_KEY = os.environ.get("SECRET_KEY", default="hussx3!p=8@%iej1k_7vxd*!6acbv7ln93_+_2ia8kb-becqc1") +DEBUG = int(os.environ.get("DEBUG", default=0)) +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="127.0.0.1 localhost [::1]").split(" ") -ALLOWED_HOSTS = [] +CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", default="http://127.0.0.1:8000 http://localhost:8000").split(" ") +SESSION_COOKIE_SECURE = True # ensures cookie is only sent under an HTTPS connection +CSRF_COOKIE_SECURE = True # ensures CSRF cookie is only sent under an HTTPS connection +SECURE_HSTS_SECONDS = 604800 # determines how long browsers should remember that your site should only be accessed using HTTPS +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # signifies a request is secure despite using proxy +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" # django-allauth's default protocol for generating URLs # Application definition @@ -37,21 +43,31 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sites", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.google", + "allauth.socialaccount.providers.facebook", "tailwind", "theme", "django_browser_reload", + "crispy_forms", + "crispy_bootstrap4", "party", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_browser_reload.middleware.BrowserReloadMiddleware", + "allauth.account.middleware.AccountMiddleware", ] ROOT_URLCONF = 'core.urls' @@ -85,6 +101,8 @@ } } +DATABASES["default"] = dj_database_url.config(default="sqlite:///db.sqlite3") + # Password validation # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators @@ -121,6 +139,13 @@ # https://docs.djangoproject.com/en/4.0/howto/static-files/ STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / "staticfiles" + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", + }, +} # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field @@ -134,3 +159,19 @@ INTERNAL_IPS = [ "127.0.0.1", ] + +CRISPY_TEMPLATE_PACK = "bootstrap4" + +LOGIN_REDIRECT_URL = "page_party_list" # where to redirect after login +LOGIN_URL = 'party_login' # where to redirect when login is required to access a view + + +AUTHENTICATION_BACKENDS = ( + "allauth.account.auth_backends.AuthenticationBackend", +) + +SITE_ID = 1 # needs to match the Site ID in the admin +ACCOUNT_EMAIL_VERIFICATION = "none" # no email verification needed +SOCIALACCOUNT_LOGIN_ON_GET = True # skip additional confirm page, less secure +ACCOUNT_LOGOUT_ON_GET = True # skip the confirm logout page +ACCOUNT_UNIQUE_EMAIL = True diff --git a/core/urls.py b/core/urls.py index 577b605..04acd40 100644 --- a/core/urls.py +++ b/core/urls.py @@ -6,4 +6,5 @@ path("admin/", admin.site.urls), path("__reload__/", include("django_browser_reload.urls")), path("", include("party.urls")), + path("accounts/", include("allauth.urls")), ] diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 0000000..f6a42f6 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,11 @@ +const {defineConfig} = require('cypress'); + +module.exports = defineConfig({ + e2e: { + baseUrl: 'http://127.0.0.1:8000/', + env: { + 'username': '', + 'password': '' + } + }, +}); diff --git a/cypress/e2e/e2e.cy.js b/cypress/e2e/e2e.cy.js new file mode 100644 index 0000000..14faeb8 --- /dev/null +++ b/cypress/e2e/e2e.cy.js @@ -0,0 +1,98 @@ +function fillAndSubmitPartyForm(party) { + cy.get('input[name="party_date"]').clear().type(party.party_date); + cy.get('input[name="party_time"]').clear().type(party.party_time); + cy.get('input[name="venue"]').clear().type(party.venue); + cy.get('textarea[name="invitation"]').clear().type(party.invitation); + cy.get('form').submit(); +} + +function partyExistsOnPage(party) { + cy.get('#party-invitation').should('contain', party.venue); + cy.get('#party-invitation').should('contain', party.invitation); +} + +function fillAndSubmitGiftForm(gift) { + cy.get('input[name="gift"]').clear().type(gift.gift); + cy.get('input[name="price"]').clear().type(gift.price); + cy.get('input[name="link"]').clear().type(gift.gift_link); + cy.get('form').submit(); +} + +function giftExistsOnPage(gift) { + cy.get('#gift-registry').should('contain', gift.gift); + cy.get('#gift-registry').should('contain', gift.price); + cy.get('#gift-registry').should('contain', gift.gift_link); +} + +function giftNotExistsOnPage(gift) { + cy.get('#gift-registry').should('not.contain', gift.gift); + cy.get('#gift-registry').should('not.contain', gift.price); + cy.get('#gift-registry').should('not.contain', gift.gift_link); +} + + +function create_party() { + cy.visit('/'); + cy.get('[data-cy="new-party-link"]').click(); + + cy.fixture('party.json').then((party) => { + fillAndSubmitPartyForm(party); + cy.url().should('match', /\/party\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/$/); + partyExistsOnPage(party); + }); +} + +function edit_party() { + cy.get('[data-cy="edit-party-button"]').click(); + cy.fixture('updated_party.json').then((updated_party) => { + fillAndSubmitPartyForm(updated_party); + partyExistsOnPage(updated_party); + cy.get('form').should('not.exist'); + }); +} + +function add_gift() { + cy.visit('/'); + cy.get('[data-cy="gift-registry-link"]').first().click(); + + cy.get('[data-cy="add-gift-button"]').click(); + cy.fixture('gift.json').then((gift) => { + fillAndSubmitGiftForm(gift); + cy.get('form').should('not.exist'); + giftExistsOnPage(gift); + }); +} + +function edit_gift() { + cy.get('[data-cy="edit-gift-button"]').first().click(); + cy.fixture('updated_gift.json').then((updated_gift) => { + fillAndSubmitGiftForm(updated_gift); + + giftExistsOnPage(updated_gift); + cy.get('form').should('not.exist'); + }); +} + +function delete_gift() { + cy.get('[data-cy="delete-gift-button"]').first().click(); + cy.get('[data-cy="gift-removed-alert"]').should('be.visible'); + + cy.fixture('updated_gift.json').then((updated_gift) => { + giftNotExistsOnPage(updated_gift); + }); +} + + +describe('Logged in user creating and managing a party', () => { + before(() => { + cy.login(); + }); + + it('Performs a complete party and gift registry workflow', function () { + create_party(); + edit_party(); + add_gift(); + edit_gift(); + delete_gift(); + }); +}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 0000000..02e4254 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/fixtures/gift.json b/cypress/fixtures/gift.json new file mode 100644 index 0000000..1588684 --- /dev/null +++ b/cypress/fixtures/gift.json @@ -0,0 +1,5 @@ +{ + "gift": "Chocolates", + "price": "12.25", + "gift_link": "https://testlink.org/" +} diff --git a/cypress/fixtures/party.json b/cypress/fixtures/party.json new file mode 100644 index 0000000..10e5eae --- /dev/null +++ b/cypress/fixtures/party.json @@ -0,0 +1,6 @@ +{ + "party_date": "2028-08-26", + "party_time": "12:00", + "venue": "the party place", + "invitation": "Come to my awesome party!" +} diff --git a/cypress/fixtures/updated_gift.json b/cypress/fixtures/updated_gift.json new file mode 100644 index 0000000..b961e0a --- /dev/null +++ b/cypress/fixtures/updated_gift.json @@ -0,0 +1,5 @@ +{ + "gift": "Better chocolates", + "price": "20.25", + "gift_link": "https://updatedtestlink.org/" +} diff --git a/cypress/fixtures/updated_party.json b/cypress/fixtures/updated_party.json new file mode 100644 index 0000000..2fe6144 --- /dev/null +++ b/cypress/fixtures/updated_party.json @@ -0,0 +1,6 @@ +{ + "party_date": "2030-01-01", + "party_time": "20:00", + "venue": "Updated venue", + "invitation": "Updated invitation" +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000..f15f340 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,17 @@ +Cypress.Commands.add('login', () => { + cy.session("logged-in-user", () => { + const username = Cypress.env('username'); + const password = Cypress.env('password'); + + cy.visit('/login/'); + cy.get('input[name="username"]').type(username); + cy.get('input[name="password"]').type(password); + cy.get('form').submit(); + }, { + validate() { + cy.document() + .its('cookie') + .should('contain', 'csrftoken'); + } + }); +}); diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 0000000..0e7290a --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/e2e.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f68e6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + party_db: + image: postgres:16-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=party_user + - POSTGRES_PASSWORD=party_password + - POSTGRES_DB=party_db + party: + build: . + command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://party_user:party_password@party_db:5432/party_db + depends_on: + - party_db + +volumes: + postgres_data: diff --git a/heroku.yml b/heroku.yml new file mode 100644 index 0000000..bd7cdd3 --- /dev/null +++ b/heroku.yml @@ -0,0 +1,8 @@ +setup: + addons: + - plan: heroku-postgresql +build: + docker: + web: Dockerfile +run: + web: gunicorn core.wsgi:application --bind 0.0.0.0:$PORT diff --git a/party/forms.py b/party/forms.py new file mode 100644 index 0000000..63b651b --- /dev/null +++ b/party/forms.py @@ -0,0 +1,63 @@ +import datetime + +from crispy_forms.helper import FormHelper +from django import forms +from django.urls import reverse_lazy + +from .models import Party, Gift + + +class PartyForm(forms.ModelForm): + class Meta: + model = Party + fields = ("party_date", "party_time", "venue", "invitation") + widgets = { + "party_date": forms.DateInput( + attrs={ + "type": "date", + "hx-get": reverse_lazy("partial_check_party_date"), + "hx-trigger": "blur", + "hx-swap": "outerHTML", + "hx-target": "#div_id_party_date", + } + ), + "party_time": forms.TimeInput(attrs={"type": "time"}), + "invitation": forms.Textarea( + attrs={ + "rows": 10, + "cols": 30, + "hx-get": reverse_lazy("partial_check_invitation"), + "hx-trigger": "blur", + "hx-swap": "outerHTML", + "hx-target": "#div_id_invitation", + } + ), + } + + def clean_invitation(self): + invitation = self.cleaned_data["invitation"] + + if len(invitation) < 10: + raise forms.ValidationError("You really should write an invitation.") + + return invitation + + def clean_party_date(self): + party_date = self.cleaned_data["party_date"] + + if datetime.date.today() > party_date: + raise forms.ValidationError("You chose a date in the past.") + + return party_date + + +class GiftForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_show_labels = False + + class Meta: + model = Gift + fields = ("gift", "price", "link") diff --git a/party/static/party/js/remove-me.js b/party/static/party/js/remove-me.js new file mode 100644 index 0000000..42be993 --- /dev/null +++ b/party/static/party/js/remove-me.js @@ -0,0 +1,27 @@ +(function(){ + function maybeRemoveMe(elt) { + var timing = elt.getAttribute("remove-me") || elt.getAttribute("data-remove-me"); + if (timing) { + setTimeout(function () { + elt.parentElement.removeChild(elt); + }, htmx.parseInterval(timing)); + } + } + + htmx.defineExtension('remove-me', { + onEvent: function (name, evt) { + if (name === "htmx:afterProcessNode") { + var elt = evt.detail.elt; + if (elt.getAttribute) { + maybeRemoveMe(elt); + if (elt.querySelectorAll) { + var children = elt.querySelectorAll("[remove-me], [data-remove-me]"); + for (var i = 0; i < children.length; i++) { + maybeRemoveMe(children[i]); + } + } + } + } + } + }); +})(); diff --git a/party/templates/party/base.html b/party/templates/party/base.html index e8d0acc..4ebff68 100644 --- a/party/templates/party/base.html +++ b/party/templates/party/base.html @@ -6,18 +6,36 @@ Party! - + + {% tailwind_css %} - - + +
{% block content %} {% endblock %} diff --git a/party/templates/party/general/page_party_organizer_login.html b/party/templates/party/general/page_party_organizer_login.html new file mode 100644 index 0000000..c21127c --- /dev/null +++ b/party/templates/party/general/page_party_organizer_login.html @@ -0,0 +1,28 @@ +{% extends 'party/base.html' %} + +{% load crispy_forms_tags %} +{% load socialaccount %} + +{% block content %} +
+
+

You have to be logged in to use this app.

+
+ {% csrf_token %} + {{ form.username|as_crispy_field }} + {{ form.password|as_crispy_field }} + + {{ form.non_field_errors }} +
+ +
+
+{% endblock %} diff --git a/party/templates/party/gift_registry/page_gift_registry.html b/party/templates/party/gift_registry/page_gift_registry.html new file mode 100644 index 0000000..f2ac1ae --- /dev/null +++ b/party/templates/party/gift_registry/page_gift_registry.html @@ -0,0 +1,37 @@ +{% extends 'party/base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +

+ Gift registry for a party at {{ party.venue }} on {{ party.party_date }} +

+
+
+
+
Gift
+
App. price
+
Link
+ {% if user == party.organizer %} +
Edit
+
Delete
+ {% endif %} +
+
+ {% for gift in gifts %} + {% include 'party/gift_registry/partial_gift_detail.html' %} + {% endfor %} +
+
+
+ +
+
+{% endblock %} diff --git a/party/templates/party/gift_registry/partial_gift_detail.html b/party/templates/party/gift_registry/partial_gift_detail.html new file mode 100644 index 0000000..2a6488f --- /dev/null +++ b/party/templates/party/gift_registry/partial_gift_detail.html @@ -0,0 +1,28 @@ +
+
{{ gift.gift }}
+
{{ gift.price|default:"/" }}
+
{{ gift.link|default:"/"|urlizetrunc:30 }}
+ {% if user == party.organizer %} +
+ +
+
+ +
+ {% endif %} +
diff --git a/party/templates/party/gift_registry/partial_gift_new.html b/party/templates/party/gift_registry/partial_gift_new.html new file mode 100644 index 0000000..40243c2 --- /dev/null +++ b/party/templates/party/gift_registry/partial_gift_new.html @@ -0,0 +1,24 @@ +{% load crispy_forms_tags %} + +
+
{{ form.gift|as_crispy_field }}
+
{{ form.price|as_crispy_field }}
+
{{ form.link|as_crispy_field }}
+
+ +
+ +
+ +
+
diff --git a/party/templates/party/gift_registry/partial_gift_removed.html b/party/templates/party/gift_registry/partial_gift_removed.html new file mode 100644 index 0000000..2975e78 --- /dev/null +++ b/party/templates/party/gift_registry/partial_gift_removed.html @@ -0,0 +1,9 @@ +
+
+ Gift was removed. +
+
diff --git a/party/templates/party/gift_registry/partial_gift_update.html b/party/templates/party/gift_registry/partial_gift_update.html new file mode 100644 index 0000000..15e966d --- /dev/null +++ b/party/templates/party/gift_registry/partial_gift_update.html @@ -0,0 +1,25 @@ +{% load crispy_forms_tags %} + +
+
{{ form.gift|as_crispy_field }}
+
{{ form.price|as_crispy_field }}
+
{{ form.link|as_crispy_field }}
+
+ +
+
+ +
+
diff --git a/party/templates/party/guest_list/page_guest_list.html b/party/templates/party/guest_list/page_guest_list.html new file mode 100644 index 0000000..e2fdc91 --- /dev/null +++ b/party/templates/party/guest_list/page_guest_list.html @@ -0,0 +1,51 @@ +{% extends 'party/base.html' %} + +{% block content %} +
+ + + +
+ +
+
+ {% include 'party/guest_list/partial_guest_filter.html' %} + +
+
+ {% include 'party/guest_list/partial_guest_list.html' %} +
+
+ +
+ + +
+
+
+{% endblock %} diff --git a/party/templates/party/guest_list/partial_guest_filter.html b/party/templates/party/guest_list/partial_guest_filter.html new file mode 100644 index 0000000..a653eab --- /dev/null +++ b/party/templates/party/guest_list/partial_guest_filter.html @@ -0,0 +1,29 @@ +
+ +
+
+ +
+ +
+ + + + + + + + +
+
+
diff --git a/party/templates/party/guest_list/partial_guest_filter_and_list.html b/party/templates/party/guest_list/partial_guest_filter_and_list.html new file mode 100644 index 0000000..43d4a0d --- /dev/null +++ b/party/templates/party/guest_list/partial_guest_filter_and_list.html @@ -0,0 +1,3 @@ +{% include 'party/guest_list/partial_guest_filter.html' %} + +{% include 'party/guest_list/partial_guest_list.html' %} diff --git a/party/templates/party/guest_list/partial_guest_list.html b/party/templates/party/guest_list/partial_guest_list.html new file mode 100644 index 0000000..22c592d --- /dev/null +++ b/party/templates/party/guest_list/partial_guest_list.html @@ -0,0 +1,19 @@ +{% for guest in guests %} +
+
+ +
+
+ {{ guest.name }} + +
+
+ {% empty %} +
No guests match the requirements.
+{% endfor %} diff --git a/party/templates/party/new_party/page_new_party.html b/party/templates/party/new_party/page_new_party.html new file mode 100644 index 0000000..7be11fe --- /dev/null +++ b/party/templates/party/new_party/page_new_party.html @@ -0,0 +1,14 @@ +{% extends 'party/base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +
+
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+{% endblock %} diff --git a/party/templates/party/party_detail/page_party_detail.html b/party/templates/party/party_detail/page_party_detail.html new file mode 100644 index 0000000..d1c6cd0 --- /dev/null +++ b/party/templates/party/party_detail/page_party_detail.html @@ -0,0 +1,35 @@ +{% extends 'party/base.html' %} + +{% block content %} +
+
+ {% include 'party/party_detail/partial_party_detail.html' %} +
+ +
+ +
+ + Back to list of parties. +
+ + + ‹ + +
+
+{% endblock %} diff --git a/party/templates/party/party_detail/partial_party_detail.html b/party/templates/party/party_detail/partial_party_detail.html new file mode 100644 index 0000000..54de87e --- /dev/null +++ b/party/templates/party/party_detail/partial_party_detail.html @@ -0,0 +1,20 @@ +
+
+ Save the date! +

{{ party.party_date }} {{ party.party_time }}

+ at +

{{ party.venue }}

+
+ +
+ {{ party.invitation }} +
+ + +
diff --git a/party/templates/party/party_detail/partial_party_edit_form.html b/party/templates/party/party_detail/partial_party_edit_form.html new file mode 100644 index 0000000..f90814a --- /dev/null +++ b/party/templates/party/party_detail/partial_party_edit_form.html @@ -0,0 +1,26 @@ +{% load crispy_forms_tags %} + +
+
+
+
+ {{ form.party_date|as_crispy_field }} +
+
+ {{ form.party_time|as_crispy_field }} +
+
+ {{ form.venue|as_crispy_field }} +
+
+ {{ form.invitation|as_crispy_field }} +
+
+
+ +
+
+
diff --git a/party/templates/party/party_list/page_parties_list.html b/party/templates/party/party_list/page_parties_list.html index 9944578..c4eb570 100644 --- a/party/templates/party/party_list/page_parties_list.html +++ b/party/templates/party/party_list/page_parties_list.html @@ -2,23 +2,6 @@ {% block content %}
- {% for party in parties %} -
-

{{ party.party_date }}, {{ party.party_time }}, {{ party.venue }}

-
- {{ party.invitation|truncatewords:120 }} -
- -
- {% empty %} - - {% endfor %} + {% include 'party/party_list/partial_parties_list.html' %}
{% endblock %} diff --git a/party/templates/party/party_list/partial_parties_list.html b/party/templates/party/party_list/partial_parties_list.html new file mode 100644 index 0000000..cc605d9 --- /dev/null +++ b/party/templates/party/party_list/partial_parties_list.html @@ -0,0 +1,39 @@ +{% for party in parties %} + + {% if forloop.last and page_obj.has_next %} +
+ {% else %} +
+ {% endif %} + +

{{ party.party_date }}, {{ party.party_time }}, {{ party.venue }}

+
+ {{ party.invitation|truncatewords:120 }} +
+ +
+{% empty %} + +{% endfor %} diff --git a/party/tests/test_gift_registry.py b/party/tests/test_gift_registry.py new file mode 100644 index 0000000..9fc8777 --- /dev/null +++ b/party/tests/test_gift_registry.py @@ -0,0 +1,118 @@ +from urllib.parse import urlencode + +import pytest +from django.urls import reverse + +from party.models import Gift + + +@pytest.mark.django_db +def test_gift_registry_page_lists_gifts_for_party_by_id( + authenticated_client, create_user, create_party, create_gift +): + party = create_party(organizer=create_user, venue="Best venue") + gift_1 = create_gift(gift="Roses", party=party) + gift_2 = create_gift(gift="Chocolate", party=party) + + another_party = create_party(organizer=create_user, venue="Another venue") + create_gift(party=another_party) + + url = reverse("page_gift_registry", args=[party.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert list(response.context_data["gifts"]) == [gift_1, gift_2] + + +def test_gift_detail_partial_returns_gift_detail_including_party( + authenticated_client, create_user, django_user_model, create_party, create_gift +): + party = create_party(organizer=create_user) + gift = create_gift(party=party) + + url = reverse("partial_gift_detail", args=[gift.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert response.context_data["gift"] == gift + assert response.context_data["party"] == party + + +def test_partial_gift_update_returns_gift_update_form(authenticated_client, create_user, create_party, create_gift): + party = create_party(create_user) + gift = create_gift(party=party) + + url = reverse("partial_gift_update", args=[gift.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert "form" in response.context + assert response.context["form"].instance == gift + + +def test_partial_gift_update_updates_gift_and_returns_its_details_including_party_id(authenticated_client, create_user, create_party, create_gift): + party = create_party(create_user) + gift = create_gift(party=party) + + data = urlencode( + { + "gift": "Updated gift", + "price": "50", + "link": "https://updatedtestlink.com", + } + ) + + url = reverse("partial_gift_update", args=[gift.uuid]) + response = authenticated_client(create_user).put(url, content_type="application/json", data=data) + + assert Gift.objects.get(uuid=gift.uuid).gift == "Updated gift" + assert Gift.objects.get(uuid=gift.uuid).price == 50.0 + assert Gift.objects.get(uuid=gift.uuid).link == "https://updatedtestlink.com" + + assert response.status_code == 200 + assert response.context["gift"].gift == "Updated gift" + assert response.context["party"] == party + + +def test_partial_gift_delete_removes_gift(authenticated_client, create_user, create_party, create_gift): + party = create_party(organizer=create_user) + gift = create_gift(party=party) + + assert Gift.objects.count() == 1 + + url = reverse("partial_gift_delete", args=[gift.uuid]) + + authenticated_client(create_user).delete(url) + + assert Gift.objects.count() == 0 + + +def test_get_partial_new_gift_returns_create_gift_form_with_party(authenticated_client, create_user, create_party): + party = create_party(organizer=create_user) + + url = reverse("partial_new_gift", args=[party.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert "form" in response.context + assert not response.context["form"].is_bound + assert response.context["party_id"] == party.uuid + + +def test_put_partial_new_gift_saves_gift(authenticated_client, create_user, create_party): + party = create_party(organizer=create_user) + + data = { + "gift": "New gift", + "price": "50", + "link": "https://newtestlink.com", + } + + url = reverse("partial_new_gift", args=[party.uuid]) + + response = authenticated_client(create_user).post(url, data=data) + + assert response.status_code == 200 + assert Gift.objects.count() == 1 + assert response.context["gift"].gift == "New gift" + assert response.context["party"] == party diff --git a/party/tests/test_guest_list.py b/party/tests/test_guest_list.py new file mode 100644 index 0000000..354878b --- /dev/null +++ b/party/tests/test_guest_list.py @@ -0,0 +1,103 @@ +import pytest + +from django.urls import reverse + +from party.models import Guest + + +def test_page_guest_list_lists_guests_for_certain_party(authenticated_client, create_user, create_party, create_guest): + party = create_party(organizer=create_user, venue="Main venue") + guest_1 = create_guest(party=party, name="Anna Brown") + guest_2 = create_guest(party=party, name="Lia Keyes") + + another_party = create_party(organizer=create_user, venue="Another venue") + create_guest(party=another_party, name="Guest from another party") + + url = reverse("page_guest_list", args=[party.uuid]) + + response = authenticated_client(create_user).get(url) + response_guests_list = list(response.context["guests"]) + + assert response.status_code == 200 + assert response.context["party_id"] == party.uuid + assert response_guests_list == [guest_1, guest_2] + assert len(response_guests_list) == 2 + + +def test_mark_guest_attending(authenticated_client, create_user, create_party, create_guest): + party = create_party(organizer=create_user) + guest_1 = create_guest(party=party, attending=False) + guest_2 = create_guest(party=party, attending=False) + + url = reverse("partial_mark_attending", args=[party.uuid]) + + data = f"guest_ids={guest_1.uuid}" + response = authenticated_client(create_user).put(url, data=data, content_type="application/x-www-form-urlencoded") + + assert Guest.objects.get(uuid=guest_1.uuid).attending is True + assert Guest.objects.get(uuid=guest_2.uuid).attending is False + + assert response.status_code == 200 + assert len(list(response.context["guests"])) == 2 + assert response.context["party_id"] == party.uuid + + +def test_mark_guest_not_attending(authenticated_client, create_user, create_party, create_guest): + party = create_party(organizer=create_user) + guest_1 = create_guest(party=party, attending=True) + guest_2 = create_guest(party=party, attending=True) + + url = reverse("partial_mark_not_attending", args=[party.uuid]) + + data = f"guest_ids={guest_1.uuid}" + response = authenticated_client(create_user).put(url, data=data, content_type="application/x-www-form-urlencoded") + + assert Guest.objects.get(uuid=guest_1.uuid).attending is False + assert Guest.objects.get(uuid=guest_2.uuid).attending is True + + assert response.status_code == 200 + assert len(list(response.context["guests"])) == 2 + assert response.context["party_id"] == party.uuid + + +@pytest.mark.parametrize( + "guest_attending_status, search_text, attending_filter, expected_number_of_filtered_guests", + [ + (True, "an", "all", 1), # should pass, this is the same as before + (True, "be", "all", 0), # should pass, this is the same as before + (True, "be", "attending", 0), # should pass since search doesn't match + (True, "be", "not_attending", 0), # should pass since search doesn't match + (True, "an", "attending", 1), # should pass since search matches and status isn't checked + (True, "an", "not_attending", 0), # should fail since search matches but filter doesn't + (True, "", "attending", 1), # should pass since empty search matches the result + (True, "", "not_attending", 0), # should fail, since search matches, but filter doesn't + (False, "an", "all", 1), # should pass since filter is "all" + (False, "be", "all", 0), # should pass since filter is "all" + (False, "be", "attending", 0), # should pass since search doesn't match + (False, "be", "not_attending", 0), # should pass since search doesn't match + (False, "an", "attending", 0), # should fail since "an" matches, but "attending" shouldn't + (False, "an", "not_attending", 1), # should pass since filter matches even if not checked + (False, "", "attending", 0), # should fail since filter doesn't match output + (False, "", "not_attending", 1), # should pass since filter matches output even if not checked + ], +) +def test_filter_guest_by_status_and_search( + guest_attending_status, + search_text, + attending_filter, + expected_number_of_filtered_guests, + authenticated_client, + create_user, + create_party, + create_guest, +): + party = create_party(organizer=create_user) + create_guest(party=party, name="Anna", attending=guest_attending_status) + + url = reverse("partial_filter_guests", args=[party.uuid]) + + data = {"attending_filter": attending_filter, "guest_search": search_text} + + response = authenticated_client(create_user).post(url, data) + + assert len(response.context["guests"]) == expected_number_of_filtered_guests diff --git a/party/tests/test_new_party.py b/party/tests/test_new_party.py new file mode 100644 index 0000000..2577c8e --- /dev/null +++ b/party/tests/test_new_party.py @@ -0,0 +1,79 @@ +import pytest +from django.urls import reverse + +from party.models import Party + + +@pytest.mark.django_db +def test_create_party(authenticated_client, create_user): + url = reverse("page_new_party") + data = { + "party_date": "2025-06-06", + "party_time": "18:00:00", + "venue": "My Venue", + "invitation": "Come to my party!", + } + + response = authenticated_client(create_user).post(url, data) + + assert response.status_code == 302 + assert Party.objects.count() == 1 + + +def test_create_party_invitation_too_short_returns_error(authenticated_client, create_user): + url = reverse("page_new_party") + data = { + "party_date": "2025-06-06", + "party_time": "18:00:00", + "venue": "My Venue", + "invitation": "Too short", + } + + response = authenticated_client(create_user).post(url, data) + + assert not response.context["form"].is_valid() + assert "You really should write an invitation." in response.content.decode() + assert Party.objects.count() == 0 + + +def test_create_party_past_date_returns_error(authenticated_client, create_user): + url = reverse("page_new_party") + + data = { + "party_date": "2020-06-06", + "party_time": "18:00:00", + "venue": "My Venue", + "invitation": "Come to my party!", + } + + response = authenticated_client(create_user).post(url, data) + + assert not response.context["form"].is_valid() + assert "You chose a date in the past." in response.content.decode() + assert Party.objects.count() == 0 + + +def test_partial_check_party_date(authenticated_client, create_user): + url = reverse("partial_check_party_date") + data = { + "party_date": "2020-06-06", + } + + response = authenticated_client(create_user).get(url, data) + + assert response.status_code == 200 + assert 'id="id_party_date"' in response.content.decode() + assert "You chose a date in the past." in response.content.decode() + + +def test_partial_check_invitation(authenticated_client, create_user): + url = reverse("partial_check_invitation") + data = { + "invitation": "Too short", + } + + response = authenticated_client(create_user).get(url, data) + + assert response.status_code == 200 + assert 'id="id_invitation"' in response.content.decode() + assert "You really should write an invitation." in response.content.decode() diff --git a/party/tests/test_party_detail.py b/party/tests/test_party_detail.py new file mode 100644 index 0000000..704838a --- /dev/null +++ b/party/tests/test_party_detail.py @@ -0,0 +1,54 @@ +import datetime +from urllib.parse import urlencode + +import pytest +from django.urls import reverse + +from party.models import Party + + +@pytest.mark.django_db +def test_party_detail_page_returns_whole_page_with_single_party(authenticated_client, create_user, django_user_model, create_party): + party = create_party(organizer=create_user) + + url = reverse("page_single_party", args=[party.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert response.context_data["party"] == party + + +@pytest.mark.django_db +def test_party_detail_partial_get_method_returns_a_form_prefilled_with_party_details(authenticated_client, create_user, django_user_model, create_party): + party = create_party(organizer=create_user) + + url = reverse("partial_party_detail", args=[party.uuid]) + response = authenticated_client(create_user).get(url) + + assert response.status_code == 200 + assert "form" in response.context + assert response.context["form"].instance == party + + +@pytest.mark.django_db +def test_party_detail_partial_put_method_returns_updated_party_details(authenticated_client, create_user, create_party): + party = create_party(organizer=create_user) + + url = reverse("partial_party_detail", args=[party.uuid]) + + data = urlencode( + { + "party_date": "2025-06-06", + "party_time": "18:00:00", + "venue": "New Venue", + "invitation": "New Bla bla", + } + ) + + response = authenticated_client(create_user).put(url, content_type="application/json", data=data) + + assert response.status_code == 200 + assert Party.objects.get(uuid=party.uuid).party_date == datetime.date(2025, 6, 6) + assert Party.objects.get(uuid=party.uuid).party_time == datetime.time(18, 0) + assert Party.objects.get(uuid=party.uuid).venue == "New Venue" + assert Party.objects.get(uuid=party.uuid).invitation == "New Bla bla" diff --git a/party/tests/test_party_list.py b/party/tests/test_party_list.py index 1279d54..2ac8707 100644 --- a/party/tests/test_party_list.py +++ b/party/tests/test_party_list.py @@ -3,6 +3,8 @@ import pytest from django.urls import reverse +from party.views import PartyListPage + @pytest.mark.django_db def test_party_list_page_returns_list_of_users_future_parties(authenticated_client, create_user, create_party, django_user_model): @@ -36,3 +38,33 @@ def test_party_list_page_returns_list_of_users_future_parties(authenticated_clie assert response.status_code == 200 assert len(parties_list) == 2 assert parties_list == [valid_party_1, valid_party_2] + + +def test_party_list_page_returns_paginated_list_of_parties(authenticated_client, create_user, create_party, django_user_model): + today = datetime.date.today() + + for n in range(PartyListPage.paginate_by + 1): + create_party(organizer=create_user, party_date=today + datetime.timedelta(days=n), venue=f"venue {n}") + + url = reverse("page_party_list") + client = authenticated_client(create_user) + + response = client.get(url) + assert response.context["is_paginated"] is True + assert response.context["page_obj"].has_next() is True + assert len(list(response.context["parties"])) == PartyListPage.paginate_by + + response = client.get(f"{url}?page=2") + assert response.context["page_obj"].has_previous() is True + assert len(list(response.context["parties"])) == 1 + + +def test_party_list_page_returns_different_template_for_htmx_request(authenticated_client, create_user): + url = reverse("page_party_list") + client = authenticated_client(create_user) + + response = client.get(url) + assert response.template_name[0] == "party/party_list/page_parties_list.html" + + response = client.get(url, HTTP_HX_REQUEST="") + assert response.template_name[0] == "party/party_list/partial_parties_list.html" diff --git a/party/urls.py b/party/urls.py index 63ba56d..450b3f3 100644 --- a/party/urls.py +++ b/party/urls.py @@ -1,9 +1,44 @@ from django.urls import path -from party import views + +from . import views list_parties_urlpatterns = [ path("", views.PartyListPage.as_view(), name="page_party_list"), ] -urlpatterns = list_parties_urlpatterns +party_detail_urlpatterns = [ + path("party//", views.PartyDetailPage.as_view(), name="page_single_party"), + path("party//details/", views.PartyDetailPartial.as_view(), name="partial_party_detail"), +] + +new_party_urlpatterns = [ + path("party/new/", views.page_new_party, name="page_new_party"), + path("party/new/check-date/", views.partial_check_party_date, name="partial_check_party_date"), + path("party/new/check-invitation/", views.partial_check_invitation, name="partial_check_invitation"), +] + +gift_registry_urlpatterns = [ + path("party//gifts/", views.GiftRegistryPage.as_view(), name="page_gift_registry"), + path("gifts//", views.GiftDetailPartial.as_view(), name="partial_gift_detail"), + path("gifts//form/", views.GiftUpdateFormPartial.as_view(), name="partial_gift_update"), + path("gifts//delete/", views.delete_gift_partial, name="partial_gift_delete"), + path("party//new-gift/", views.GiftCreateFormPartial.as_view(), name="partial_new_gift"), +] + +guest_list_urlpatterns = [ + path("party//guests/", views.GuestListPage.as_view(), name="page_guest_list"), + path("party//guests/mark-attending/", views.mark_attending_partial, name="partial_mark_attending"), + path("party//guests/mark-not-attending/", views.mark_not_attending_partial, name="partial_mark_not_attending"), + path("party//guests/filter/", views.filter_guests_partial, name="partial_filter_guests"), +] + + +general_patterns = [ + path("login/", views.LoginPage.as_view(), name="party_login"), +] + + +urlpatterns = ( + general_patterns + list_parties_urlpatterns + party_detail_urlpatterns + new_party_urlpatterns + gift_registry_urlpatterns + guest_list_urlpatterns +) diff --git a/party/views/__init__.py b/party/views/__init__.py index 5841b59..282bf7f 100644 --- a/party/views/__init__.py +++ b/party/views/__init__.py @@ -1,4 +1,26 @@ +from .gift_registry_views import GiftRegistryPage, GiftUpdateFormPartial, GiftDetailPartial, delete_gift_partial, GiftCreateFormPartial +from .guest_list_views import GuestListPage, mark_attending_partial, mark_not_attending_partial, filter_guests_partial +from .general_views import LoginPage +from .new_party_views import page_new_party, partial_check_party_date, partial_check_invitation +from .party_details_views import PartyDetailPage, PartyDetailPartial from .party_list_views import PartyListPage -__all__ = ["PartyListPage", ] +__all__ = [ + "PartyListPage", + "PartyDetailPage", + "PartyDetailPartial", + "page_new_party", + "partial_check_party_date", + "partial_check_invitation", + "GiftRegistryPage", + "GiftUpdateFormPartial", + "GiftDetailPartial", + "delete_gift_partial", + "GiftCreateFormPartial", + "GuestListPage", + "mark_attending_partial", + "mark_not_attending_partial", + "filter_guests_partial", + "LoginPage", +] diff --git a/party/views/general_views.py b/party/views/general_views.py new file mode 100644 index 0000000..a5befc9 --- /dev/null +++ b/party/views/general_views.py @@ -0,0 +1,5 @@ +from django.contrib.auth.views import LoginView + + +class LoginPage(LoginView): + template_name = "party/general/page_party_organizer_login.html" diff --git a/party/views/gift_registry_views.py b/party/views/gift_registry_views.py new file mode 100644 index 0000000..6c05b8e --- /dev/null +++ b/party/views/gift_registry_views.py @@ -0,0 +1,95 @@ + +from django.http import QueryDict +from django.shortcuts import get_object_or_404, render +from django.views import View +from django.views.decorators.http import require_http_methods +from django.views.generic import DetailView, ListView + +from party.forms import GiftForm +from party.models import Gift, Party + + +class GiftRegistryPage(ListView): + model = Gift + template_name = "party/gift_registry/page_gift_registry.html" + context_object_name = "gifts" + + def get_queryset(self): + return Gift.objects.filter(party_id=self.kwargs["party_uuid"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["party"] = Party.objects.get(uuid=self.kwargs["party_uuid"]) + return context + + +class GiftDetailPartial(DetailView): + model = Gift + template_name = "party/gift_registry/partial_gift_detail.html" + context_object_name = "gift" + pk_url_kwarg = "gift_uuid" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["party"] = self.object.party + return context + + +class GiftUpdateFormPartial(View): + def get(self, request, gift_uuid, *args, **kwargs): + gift = get_object_or_404(Gift, uuid=gift_uuid) + form = GiftForm(instance=gift) + + return render( + request, + "party/gift_registry/partial_gift_update.html", + {"form": form, "gift": gift}, + ) + + def put(self, request, gift_uuid, *args, **kwargs): + data = QueryDict(request.body).dict() + gift = Gift.objects.get(uuid=gift_uuid) + form = GiftForm(data, instance=gift) + + if form.is_valid(): + form.save() + + return render(request, "party/gift_registry/partial_gift_detail.html", {"gift": gift, "party": gift.party}) + + return render( + request, + "party/gift_registry/partial_gift_update.html", + {"form": form, "gift": gift} + ) + + +@require_http_methods(["DELETE"]) +def delete_gift_partial(request, gift_uuid): + gift = get_object_or_404(Gift, uuid=gift_uuid) + gift.delete() + + return render(request, "party/gift_registry/partial_gift_removed.html") + + +class GiftCreateFormPartial(View): + + def get(self, request, party_uuid, *args, **kwargs): + form = GiftForm() + + return render(request, "party/gift_registry/partial_gift_new.html", {"form": form, "party_id": party_uuid}) + + def post(self, request, party_uuid, *args, **kwargs): + + party = get_object_or_404(Party, uuid=party_uuid) + form = GiftForm(request.POST) + + if form.is_valid(): + gift = form.save(commit=False) + gift.party = party + gift.save() + + return render( + request, "party/gift_registry/partial_gift_detail.html", {"gift": gift, "party": party} + ) + + return render(request, "party/gift_registry/partial_gift_new.html", {"form": form, "party_id": party_uuid}) diff --git a/party/views/guest_list_views.py b/party/views/guest_list_views.py new file mode 100644 index 0000000..d9c4985 --- /dev/null +++ b/party/views/guest_list_views.py @@ -0,0 +1,91 @@ +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import QueryDict +from django.shortcuts import render +from django.views.decorators.http import require_http_methods +from django.views.generic import ListView + +from party.models import Guest + + +class GuestListPage(LoginRequiredMixin, ListView): + model = Guest + template_name = "party/guest_list/page_guest_list.html" + context_object_name = "guests" + + def get_queryset(self): + return Guest.objects.filter(party_id=self.kwargs["party_uuid"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["party_id"] = self.kwargs["party_uuid"] + context["attending_num"] = self.object_list.filter(attending=True).count() + + return context + + +@login_required +@require_http_methods(["PUT"]) +def mark_attending_partial(request, party_uuid): + mark_attending = QueryDict(request.body).getlist("guest_ids") + Guest.objects.filter(uuid__in=mark_attending).update(attending=True) + + guests = Guest.objects.filter(party_id=party_uuid) + + return render(request, "party/guest_list/partial_guest_filter_and_list.html", {"guests": guests, "party_id": party_uuid}) + + +@login_required +@require_http_methods(["PUT"]) +def mark_not_attending_partial(request, party_uuid): + mark_not_attending = QueryDict(request.body).getlist("guest_ids") + Guest.objects.filter(uuid__in=mark_not_attending).update(attending=False) + + guests = Guest.objects.filter(party_id=party_uuid) + + return render(request, "party/guest_list/partial_guest_filter_and_list.html", {"guests": guests, "party_id": party_uuid}) + + +def filter_attending(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id, attending=True) + + +def filter_not_attending(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id, attending=False) + + +def filter_attending_and_search(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id, attending=True, name__icontains=kwargs["search_text"]) + + +def filter_not_attending_and_search(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id, attending=False, name__icontains=kwargs["search_text"]) + + +def filter_search(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id, name__icontains=kwargs["search_text"]) + + +def filter_default(party_id, **kwargs): + return Guest.objects.filter(party_id=party_id) + + +QUERY_FILTERS= { + ("attending", False): filter_attending, + ("not_attending", False): filter_not_attending, + ("attending", True): filter_attending_and_search, + ("not_attending", True): filter_not_attending_and_search, + ("all", True): filter_search, +} + + +@require_http_methods(["POST"]) +def filter_guests_partial(request, party_uuid): + attending_filter = request.POST.get("attending_filter") + search_text = request.POST.get("guest_search") + + query_filter = QUERY_FILTERS.get((attending_filter, bool(search_text)), filter_default) + + guests = query_filter(party_id=party_uuid, search_text=search_text) + + return render(request, "party/guest_list/partial_guest_list.html", {"guests": guests}) diff --git a/party/views/new_party_views.py b/party/views/new_party_views.py new file mode 100644 index 0000000..0322e42 --- /dev/null +++ b/party/views/new_party_views.py @@ -0,0 +1,35 @@ +from crispy_forms.templatetags.crispy_forms_filters import as_crispy_field +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import redirect, render + +from party.forms import PartyForm + + +@login_required +def page_new_party(request): + form = PartyForm() + + if request.method == "POST": + form = PartyForm(request.POST) + if form.is_valid(): + party = form.save(commit=False) + party.organizer = request.user + party.save() + return redirect("page_single_party", party_uuid=party.uuid) + + return render(request, "party/new_party/page_new_party.html", {"form": form}) + + +@login_required +def partial_check_party_date(request): + form = PartyForm(request.GET) + + return HttpResponse(as_crispy_field(form["party_date"])) + + +@login_required +def partial_check_invitation(request): + form = PartyForm(request.GET) + + return HttpResponse(as_crispy_field(form["invitation"])) diff --git a/party/views/party_details_views.py b/party/views/party_details_views.py new file mode 100644 index 0000000..4d44c96 --- /dev/null +++ b/party/views/party_details_views.py @@ -0,0 +1,34 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import QueryDict +from django.shortcuts import render, get_object_or_404 +from django.views import View +from django.views.generic import DetailView + +from party.forms import PartyForm +from party.models import Party + + +class PartyDetailPage(LoginRequiredMixin, DetailView): + model = Party + template_name = "party/party_detail/page_party_detail.html" + pk_url_kwarg = "party_uuid" + context_object_name = "party" + + +class PartyDetailPartial(LoginRequiredMixin, View): + + def get(self, request, party_uuid, *args, **kwargs): + party = get_object_or_404(Party, uuid=party_uuid) + form = PartyForm(instance=party) + + return render(request, "party/party_detail/partial_party_edit_form.html", {"party": party, "form": form}) + + def put(self, request, party_uuid, *args, **kwargs): + party = get_object_or_404(Party, uuid=party_uuid) + data = QueryDict(request.body).dict() + form = PartyForm(data, instance=party) + + if form.is_valid(): + form.save() + + return render(request, "party/party_detail/partial_party_detail.html", {"party": party}) diff --git a/party/views/party_list_views.py b/party/views/party_list_views.py index fe68919..87b0b85 100644 --- a/party/views/party_list_views.py +++ b/party/views/party_list_views.py @@ -7,10 +7,15 @@ class PartyListPage(LoginRequiredMixin, ListView): - template_name = "party/party_list/page_parties_list.html" + model = Party context_object_name = "parties" + paginate_by = 6 def get_queryset(self): - return Party.objects.filter(organizer=self.request.user, party_date__gte=datetime.date.today()).order_by( - "party_date" - ) + return Party.objects.filter(organizer=self.request.user, party_date__gte=datetime.date.today()).order_by("party_date") + + def get_template_names(self): + if "HTTP_HX_REQUEST" in self.request.META: + return ["party/party_list/partial_parties_list.html"] + else: + return ["party/party_list/page_parties_list.html"] diff --git a/requirements.txt b/requirements.txt index aa73306..55eb8e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,11 @@ +crispy-bootstrap4==2022.1 Django==4.2.7 +django-allauth==0.58.2 django-tailwind[reload]==3.6.0 +dj-database-url==2.1.0 +gunicorn==21.0.1 +psycopg==3.1.13 +psycopg-binary==3.1.13 pytest==7.4.3 pytest-django==4.7.0 +whitenoise==6.6.0 diff --git a/staticfiles/admin/css/autocomplete.css b/staticfiles/admin/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/staticfiles/admin/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/staticfiles/admin/css/autocomplete.css.gz b/staticfiles/admin/css/autocomplete.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..7cd12df9483ef372338842a86eb0aedf148d04aa GIT binary patch literal 1147 zcmV->1cdt^iwFP!00002|J_;ZZsRBv{@-s>)U8MMhPG4T({S@nSql zcD3)m*kIxdPU7p#Y=5K)V9s}L-#OrvFcQkI2=8eOQ6_oF_X#7CfPe0Q`a4i8BOk0h zvVXn%_wMd%T0F3VA&(`ZF%b~9A8xNQ+vN)*LU)n$!K0TPd+Z79GX0cDY7Vo#os*s-NiDBuFCRUv0w_}~W;e?S;; ze1d;vlt?}P=j&MS^wD*^BYRk(TbQxW?5@2ObPFlmi19%xgR7PdQ24PEJd3d-+Jc=4 zG98aTcu0;Q<&0vx>Kv!FrBEPDv(yJqQ}KmkYO@g}R>BB2%Ct4umKrc6at0Q2q6!&7 zC3%LKNOMdHr?DhLgV{2U6bzB0F(XPoJQ#QZXLv?<2}2=?lnl%8*LY?&C`br?Fd$0j zte!1_^OWZ6e1HNKb(ti3T=Jv{Mg2?Y$Lw!g{bu#CMjkl%`t!@n3y=cEX@XRQ%C7IV zgh}Oz5npFlZNliYR;j0w33xiCsuuH1h?A}QvG%^0!hpn1DXB-HeaH+KHiWC2Ol`L` zQ2H>?cTC0GCn4FV_c3W?GA37M8R6B))Fx~OTrOy2^}#en0aNrfn5HojA0#IDOw}df zV6sK3f-HdWn(x#eigHdqhVO}W&;Bkt8iP#@m9;vRB_HaTITM#)EQ>HX z!h=AH;PTI9lxMW$l$MS&itk_Xo0Y;^b{G-&hdfOWRgIhVwOS`^HJwwhh|{n?t52Be zwlK8ZS#i3t#oE<1tlI}{JvO`9@4J+z11|tvZP_l+pKEi-ps&Z&F2K*8o5^kxtauI zpZl41!(<4uPL2&qIQQ^8lex>sUm#s(`~GPOUd__>}6IAH(8syvET{jhq#qu?&O+6 zQe$Sb*;2xACxH5q?ZOr1&$wi*)xVn+YaNVl(CU1Ut8%YB3PwoTwz^6+VZjHL5G9F1 zgW{0rLu!F(khX%PDsEHotu?gS@Fb7y)E2j+y(?aM_Jva3hNL4I ziT1nTjJ)OhC-2j|Bxnjif+AZz-lWLL`+PUXPT_j8A-t=OmW!`u+jw-{6E#Pe{^+6C zi`W4|+Y?1tIKg;JL#Y|2ek)xVrVVkmyuF5?U%Amg{(9(psrBeq>*+UAtCzk{ZhhDP z2Yq`PV(m7>^E8HlFj5tj li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.base-svgs { + display: none; +} diff --git a/staticfiles/admin/css/base.css.gz b/staticfiles/admin/css/base.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..fa107378d2c081d0ff997822aad5f8f2a13c0929 GIT binary patch literal 4737 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 PoeudwYI * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/staticfiles/admin/css/changelists.css.gz b/staticfiles/admin/css/changelists.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..108028ffb0d372e01226f033207e8dd7a10fcce5 GIT binary patch literal 1564 zcmV+%2IKi3iwFP!00002|HW9_juST+zTc;?Kou;Chh*6mXjVm9VB2n$vJ!073&e$I z>}1?^#)Iu)lOk^W0PP$0N$S&_51CDoN)RMI{KxM<`RNnz`lpMxKVB}c-ru|jPo5qf zJ&Y5St_h=3fiFiu|G`vd4DCX&Vq|-6mk^`t6jDVt(k(-&kWg;(_gpHv+QFEoiliz8 zGD9(e5m9%9q~}Lpn@oc0J6v(GvG)*7iSYal{SYkl2jz-89vhm%JBn2jf_aCcLJ=eW zvOlz#&%d+hJU8fAClq6nt|(zxf-j|}DNo5c=$u|rridW8!64!S69ENXCt6i|^Jw_y zms0wKxErVQdA1$95(px5rsNC^9mvp{rbuxyJE~S6D$ZGi#H@A_)6ckT^EWk$foTF>&;Spe~o`8V#W9!+=exGw!PHAhvFHd>8)%UJmO06 zjlPp@f#i&0@EM865GIHUeVk|*F)og~C<{Zf}4w1%gb(9dVeGY;f_CSJXn(^Iy|8bJp;=-fikzD*(w?% z25k_B8jKh-G7v-slK{nv@)X7iiEksmHJav|v=wB*nL&YBQDmzM2C+^8L<})-sKRXq zp7L}$E6&wKk2*WwTwgC=UA%MF^}*7%ATn{d$>NapUwJ~G0`0>lR)Mh&1F|t z?XK$cTGGu&WE-(uo7YS5q}`~~)Krgs1YWkpb=XroMxcq1aYJ;7grQb7x}^&GEx0kL z5pCJ-Ni-tb`=l)Fexi8v&!2xiI`0}e;tcoX4%0qD8|Fh1+!Wvg&q(@lk{EwK8lsgK zU3L)^295m~=(Z9EcDEJpkRkmuPAJ3uee;+f7#)X14Y4i*G}WpW`^lp=CL+e;+w;lY z_((GBRVI*xkyw$IC7hIJQ+ltaVx5lAEkozU{ZgR5KGkBhI8qh59_#ti2731_%4d5e zD9hu%bM@kQSWI=7V9a!dE6uRCX=$a!1H(ku$u}1_m*CC2tL5^|^>40FHb>spsM+)K z(H5x;T>A=QrmPTDv)0=Dd}adtd83-a literal 0 HcmV?d00001 diff --git a/staticfiles/admin/css/dark_mode.css b/staticfiles/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/staticfiles/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/staticfiles/admin/css/dark_mode.css.gz b/staticfiles/admin/css/dark_mode.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..faebccea590bbf329596f47495cc027ed38ea3c8 GIT binary patch literal 849 zcmV-X1FrlZiwFP!00002|D{!3PunmQeb2A3*b7A%T1pE`HE9xJf`@GnAnjq=gJa($ zR$V)@9STDG@AF3pO-tG$Y$3J2r{{j1d-=Km#tAyCG~}QSrb;M{4J}~-DPly=2k1FL z?xb2N+k8d{<4W@d(N8J*bT&QDCTzBD!Fl0~`GvpU*xED28Y6@=>sm5z8J+pNX8Bul zurWmcK}(_~mqi3kF302HWD^vfMaaeQa(LMZ#cK+r zOE&;Qn@6u4Z84$>vQa0D)~eMqL^+<0Nr#;ZWdLWjwMs^gaX7QL5wSf1Hw`D^{w4k| zNlLpAzZtOvTR#wA4t($jf0`-in}MDQNVciJPy0KSzfJeazM9{y_+*>fcWQmB(vv+@ z-mCFstG@S8_a55bs_bN!s<&ynSJBBnYTm77r{U8hbboXG{Tki-H9{E?7NjGfsP&A=$b5jB@O2<6X zu!t{9&TP5*z__Udd3wn*lq~sH3-N?X&YUBv0VMMC0UB9&w8$v51f-;cm{Ki~3(S23 z7q+FPD^GZE74@Y^LewY8M%_;yxT%SAgPj}ZJUsV=v$2FoZIxR>Yjp9OAQ=d}EJ0!q zTPMwgqVu;|o7J|9)x+MwDt!I-pbfZwUZ>*QtDisaHs$Md&^&PJhM>*-cn_S$}OX!(8~a8u^>v2=11Oc1!ngcpPriv}MVy+e8(i;D>LiN!8tg b!#A4S{4*b{Z$rF^Y6S2frdt^naSH$dGXj{n literal 0 HcmV?d00001 diff --git a/staticfiles/admin/css/dashboard.css b/staticfiles/admin/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/staticfiles/admin/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/staticfiles/admin/css/dashboard.css.gz b/staticfiles/admin/css/dashboard.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..06db5fd5cf463e2a3989b143df3b5bdc73962e1e GIT binary patch literal 267 zcmV+m0rdVKiwFP!00002|AmmTZi6rohIgLgmM*9Ux^y5D4M?rj1}RA&z;F&e#n{Mp zN>SB!FA&nEQ#{zZ-}n9h*>MPYwtIdmvuzGx97M>StPHmZ9`E70l<*b6xO!ueRaWq0 z3iH7xWfgn}*L2hxJShR5SJVCr6MC7#Lz3LxAn-y+@`AaJ*CGXNdds_;Bwtu0*C{@X zb1G9GSF_e}MA8kj)JUH~^eEcdFAiZ_tc%Savi1I@-0a}3%->&&=~seF!bwHE_GGjo z=a-O{+lDk#;xO7Jv)BJNMN8*YG3N&*m>;_NIa-{jG9SVol<8BDgede`_2SdBzbl~> Rb&lMW@dMe)7Ztey007?gfr div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/staticfiles/admin/css/forms.css.gz b/staticfiles/admin/css/forms.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..ed2543618f008add7bb2b0a6f811e7ab9b3805d4 GIT binary patch literal 2199 zcmV;I2x#{oiwFP!00002|Fs%xZ`-!^dwvD6;i63&DYg?QnT-u|8u!AO6Bn_A4Y(Kz z+M;bCw5UmxlPu_ezlV}YkrHV;8``37Or3Lh-Y@0Xa1*nHqb#BC-#vxFityB5r0Kik z{?XCt2lRPze~a!XzfaMJ)1xDQ$&!tiuqX6-gy0X`l4MEQQxC1fARtjcKaFt^gwd*p z+O7DxI9jrZdujML>7kZ?)$Vi?vMHX-SrU-Mn={VY22kQ>lrkCyXp58gp0~zfLc@r7 z3r1N2OnuY22sf(}qR}qLq8{1+gOt?pDV`Hrp#6`(#N_867HhJ2 zoU`X2{w(QiaIy*`kFyw9?^{W?a;k!;#!Fn{m&))cS#k;LFDZEji6V|cw4{U#!ZfD% zrH91RQf&h6DZy~J_|&-HWR!mixiY zXo9XEX0yp%Ax*A*P=uf0AO!zOxkW8Wami(=lqQZJM!8NaRArRni8rTg@#w%tx=JCI z__~L1#+eIT_q+VG=5#&ebs!wh;a^Moo{BBa=`t$$He6PD+idpEp6pGU*-&2%^`GejN}(&Tj|mfABI65P83*%(;73m! z6pwPNtV{Q47k+2((2E#z3cBX`mVGho+&Q;3oQst zg?vOYWGs2+%XE{cjE}j0tO?aYBwFq|1P3glFQ!}f(1p-m-<7V)BbgJ-Q^QGQI=84= zmn5Th>lWJT>y8vLc~FmsN(a{T-DFzeKvKA!YU_%=e z)4o~c9J%R@1;9J2$=?%&40RzTcS{X8*{O`T17t@?d*|{7k@oiH#W$qIi^_cBVM;jU zY4OuFdo~#<8-tG4Y5qRC8P?kZpi6B1QKx14Ne-u6G5-c_3P}={2zN0a*dxezw99qv zS2tBQgw*Cx@H9mx;K5@%Gh zA^gJ2T~VaN9?IgFBnzC91~vvvN;nZtXiLski7iOB(bD1xIhr@mpdE|q?$ESwRmsaq zPS>5T?%J_S-OcP#Fd~D`lyN~>Y8A2Bj5H<9k>4(8K7hBo^8CBXSJ|+)WQ#04aX(*+ z5ZLh?FL<~m?i_BIJttP_B=^;TELehh$Re%0Im}__wt8%3wtBpWSHo}zvnne3!*n*e zE!KNQO)qllcAEF^w_;HkQ=&Hp^&S?Q$wF||eCp6LX;%8#-oRKVTaxVzMl>D1))<;u zTa84jb*+d=e1MW|sDIU!`KozO1$XXWDzJz9v2D(8DS$>(wYPz~lDJEXd5l*i_2f_J z$1wO>E|6-u_`KzJB;tkz8Px4!obk6`SQ-j%n8G|tM}WK{iRI!>MPTcOsx)*RuH0bl zN&}x+0z5{)k(Yb?Gzta*sB$M^Lt76IqZ=Eul0Nw4=x#i^8{L7wr;!)+t<->q85Qbr)$a7d4!OfJs#w8s`=gv6i2Mt*pT7vu2$fRgXvTDGsR%vQ6H%+5G zY-|SFt=4~b-X{ax59!eJpj5!7Ee9z`Es*{tN96a&YIQ`1N55ovk#Dm&Q zo<~Zj=1C1Afafb3uT5*V8ZR!&Z(sN1koryQ`ZbbVvn}08M`Jf`WHUU65YKq&-o{p< zdVSHB?q>-4)9wMtaN_QTmb?yr!`IA_UrQZaw(KcBqdsShru*tj-@R~j5L8=A>maS| z`np!e#3l$}L>G9n!$C`s$~O;D$|1$TQXl_i%0Gbl?{%p%3Kn;sxK)ktfy%~~K3x0? zgzuk*o7HIu9i0c@qH*`r?dllpEZB}1B&C>OZrylaSo677tjH$jFJ8VWbgkCJ%kANK zFp~>9G`P7zle-~0MdQijw}&r9PgSNyk23a{S+|?2xctMHnU!Wrk$ZLNHHbpiw(9Av z?8)Tcn4;U3HpdG$&|MowNeNCC>)agL)>d9s{O(G=Ux`UlEnxS)Zad(0!h*upqKtXV Z5I%~NyXLj6}G007d*PW%7> literal 0 HcmV?d00001 diff --git a/staticfiles/admin/css/login.css b/staticfiles/admin/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/staticfiles/admin/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/staticfiles/admin/css/login.css.gz b/staticfiles/admin/css/login.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..ca9d533055da1635cd52842a4569b1046aabcdb5 GIT binary patch literal 417 zcmV;S0bc$eiwFP!00002|8-K)O2aS|ecx9EM9^Wjc5WiwyCSFqe?ZdauDvczN|L%w z@!w6`q;p$sA&{GMPtG~H(=mBoJwCmXht=B)8Ba%}iBLI8@;xH%OIfzbwW=jg$S2cR zlyaswAc4~~zh1VO0x&OZLRf9pa`e+PTok}KXwP(vYAO}u9A%!6cv0<0d{^zd`bTXn zuJ<%k(t>m?nOi{*Q5UgiUpp){_yvw_63-SI%8smL+BGC3`lp?Ff=mgegU9Ze3X~h# z9$X)Fdttt_w=s*HjdqF7;0$G-#CfKv;`>9MsnYItXT&iXC6EVI47HO4y`%$}YR0iP z37Llw)hFn+P}_tQ$T`Tax@j8ofg-7s%gh16r~YZph$q#f;gyluotX2e;n8 zGkX~_-8Hk+qI|xtbR@vkX^D2oattMjn*VJ6nyrPMrA%NhhsJsmv{DTQY&(FSf4K1% L;-h8;z5@UNH15V| literal 0 HcmV?d00001 diff --git a/staticfiles/admin/css/nav_sidebar.css b/staticfiles/admin/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/staticfiles/admin/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/staticfiles/admin/css/nav_sidebar.css.gz b/staticfiles/admin/css/nav_sidebar.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..7398c4df9feb1b5f31ae72b632bf2debd3f25dd7 GIT binary patch literal 779 zcmV+m1N8hKiwFP!00002|Fu@zQrj>Pea}~D%5;Ef?3Jwu79`TV1#bFel9%HPH>ln9YLfG2Xl1Z{7a2brq~m! zrQl@C%oU{>6GjRJ=uc{M?VqrClNwzr-iSkQ9BF=_QR=PY(%4E@Xwiuf!^YY?0=f#8 zD+9SV?&6@s4NY2{f^jZXS_h0p{>g>;`oTE);WwEsxX-F7^La`2>adt-qZs8ywCO5iV-?oyRnN%n`RQTNL34SStGfdfn z862Q@9)+r8iPXE0u+r&48!z6ae}gsamB-7x`A8LCX(py2j3ChleALIKy!^FTJUm=B z&vGVIU^9_8;N(9+esn>|x3N-fg%y#I!XbfVf^$$KZbx5^6{L$+f0i&@yR)Ht+tf1) za?MK##AW`4=P6{C=#u zGho$zbyuz?^PN9~k(T^KJeVoS0XMeb79>W1tZ4M^dV1hLAzrf`$1&SeKR7Xy@l`cF zgx1II`t2<^8}9C|#rvKE7mQqWEZ4Vd)Ov(2{+wJ)-lyK#S6V?+QS?y#c_aDxi_zJs zy-M$8&7O}vON7KbJ++RJ-dBgazQmquQS|CWFOjKNeIgAjLI2CfkkLKhIhUn>KnG)5 zAD*OpTbs1{$GPLK_wa8iz#YTrBLqrQ1GbdTPR?--*0K~R7^BVjiFXVsb*#wGvwzZY Jd|`$P005G@hg1Ln literal 0 HcmV?d00001 diff --git a/staticfiles/admin/css/responsive.css b/staticfiles/admin/css/responsive.css new file mode 100644 index 0000000..9ce4f67 --- /dev/null +++ b/staticfiles/admin/css/responsive.css @@ -0,0 +1,998 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row { + align-items: center; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/staticfiles/admin/css/responsive.css.gz b/staticfiles/admin/css/responsive.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..7e8070e420218983e484c007f4bc83fd9b11d207 GIT binary patch literal 3432 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 To~Qb9zDM#8M89D%WCQ>J