feat: Implement RSVP functionality with WebSocket support
- Added WebSocket connection for real-time updates on activity attendees and capacity. - Created base HTML template for consistent layout across event pages. - Developed event details page to display event information and activities. - Implemented RSVP page with multi-step navigation for email, guests, activities, questions, and notes. - Added partial templates for dynamic content rendering during RSVP process. - Introduced utility functions for page management and RSVP creation. - Updated ASGI configuration to support WebSocket connections. - Enhanced logging for better debugging and error tracking. - Integrated channels for handling real-time communication in RSVP process.
This commit is contained in:
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.formatOnType": true,
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.autoFormatStrings": true,
|
||||
"python.analysis.completeFunctionParens": true,
|
||||
"python.analysis.enableTroubleshootMissingImports": true,
|
||||
"github.copilot.editor.enableCodeActions": false,
|
||||
"github.copilot.enable": {
|
||||
"*": false,
|
||||
"plaintext": false,
|
||||
"markdown": false,
|
||||
"scminput": false
|
||||
}
|
||||
}
|
||||
Generated
+98
-98
@@ -18,118 +18,118 @@
|
||||
"default": {
|
||||
"asgiref": {
|
||||
"hashes": [
|
||||
"sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4",
|
||||
"sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"
|
||||
"sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce",
|
||||
"sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==3.11.0"
|
||||
"version": "==3.11.1"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d",
|
||||
"sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26"
|
||||
"sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3",
|
||||
"sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.12'",
|
||||
"version": "==6.0"
|
||||
"version": "==6.0.3"
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643",
|
||||
"sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e",
|
||||
"sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e",
|
||||
"sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc",
|
||||
"sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642",
|
||||
"sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6",
|
||||
"sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1",
|
||||
"sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b",
|
||||
"sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399",
|
||||
"sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba",
|
||||
"sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad",
|
||||
"sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47",
|
||||
"sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739",
|
||||
"sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b",
|
||||
"sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f",
|
||||
"sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10",
|
||||
"sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52",
|
||||
"sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d",
|
||||
"sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b",
|
||||
"sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a",
|
||||
"sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9",
|
||||
"sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d",
|
||||
"sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098",
|
||||
"sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905",
|
||||
"sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b",
|
||||
"sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3",
|
||||
"sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371",
|
||||
"sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953",
|
||||
"sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01",
|
||||
"sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca",
|
||||
"sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e",
|
||||
"sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7",
|
||||
"sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27",
|
||||
"sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082",
|
||||
"sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e",
|
||||
"sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d",
|
||||
"sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8",
|
||||
"sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a",
|
||||
"sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad",
|
||||
"sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3",
|
||||
"sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a",
|
||||
"sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d",
|
||||
"sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353",
|
||||
"sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee",
|
||||
"sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b",
|
||||
"sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b",
|
||||
"sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a",
|
||||
"sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7",
|
||||
"sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef",
|
||||
"sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a",
|
||||
"sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a",
|
||||
"sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257",
|
||||
"sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07",
|
||||
"sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4",
|
||||
"sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c",
|
||||
"sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c",
|
||||
"sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4",
|
||||
"sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe",
|
||||
"sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8",
|
||||
"sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5",
|
||||
"sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6",
|
||||
"sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e",
|
||||
"sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8",
|
||||
"sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e",
|
||||
"sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275",
|
||||
"sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3",
|
||||
"sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76",
|
||||
"sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227",
|
||||
"sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9",
|
||||
"sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5",
|
||||
"sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79",
|
||||
"sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca",
|
||||
"sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa",
|
||||
"sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b",
|
||||
"sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e",
|
||||
"sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197",
|
||||
"sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab",
|
||||
"sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79",
|
||||
"sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2",
|
||||
"sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363",
|
||||
"sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0",
|
||||
"sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e",
|
||||
"sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782",
|
||||
"sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925",
|
||||
"sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0",
|
||||
"sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b",
|
||||
"sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced",
|
||||
"sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c",
|
||||
"sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344",
|
||||
"sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9",
|
||||
"sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"
|
||||
"sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9",
|
||||
"sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da",
|
||||
"sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f",
|
||||
"sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642",
|
||||
"sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713",
|
||||
"sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850",
|
||||
"sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9",
|
||||
"sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0",
|
||||
"sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9",
|
||||
"sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8",
|
||||
"sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6",
|
||||
"sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd",
|
||||
"sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5",
|
||||
"sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c",
|
||||
"sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35",
|
||||
"sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1",
|
||||
"sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff",
|
||||
"sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38",
|
||||
"sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4",
|
||||
"sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af",
|
||||
"sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60",
|
||||
"sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986",
|
||||
"sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13",
|
||||
"sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717",
|
||||
"sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e",
|
||||
"sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b",
|
||||
"sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15",
|
||||
"sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a",
|
||||
"sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb",
|
||||
"sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d",
|
||||
"sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b",
|
||||
"sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e",
|
||||
"sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a",
|
||||
"sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f",
|
||||
"sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a",
|
||||
"sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce",
|
||||
"sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc",
|
||||
"sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f",
|
||||
"sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586",
|
||||
"sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f",
|
||||
"sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9",
|
||||
"sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8",
|
||||
"sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40",
|
||||
"sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60",
|
||||
"sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c",
|
||||
"sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0",
|
||||
"sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334",
|
||||
"sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af",
|
||||
"sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735",
|
||||
"sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524",
|
||||
"sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf",
|
||||
"sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b",
|
||||
"sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2",
|
||||
"sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9",
|
||||
"sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7",
|
||||
"sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e",
|
||||
"sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4",
|
||||
"sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4",
|
||||
"sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b",
|
||||
"sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397",
|
||||
"sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c",
|
||||
"sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e",
|
||||
"sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029",
|
||||
"sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3",
|
||||
"sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052",
|
||||
"sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984",
|
||||
"sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293",
|
||||
"sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523",
|
||||
"sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f",
|
||||
"sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b",
|
||||
"sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80",
|
||||
"sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f",
|
||||
"sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79",
|
||||
"sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23",
|
||||
"sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8",
|
||||
"sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e",
|
||||
"sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3",
|
||||
"sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e",
|
||||
"sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36",
|
||||
"sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f",
|
||||
"sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5",
|
||||
"sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f",
|
||||
"sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6",
|
||||
"sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32",
|
||||
"sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20",
|
||||
"sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202",
|
||||
"sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0",
|
||||
"sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3",
|
||||
"sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563",
|
||||
"sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090",
|
||||
"sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==12.0.0"
|
||||
"version": "==12.1.1"
|
||||
},
|
||||
"sqlparse": {
|
||||
"hashes": [
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
# friend-event-rsvp
|
||||
|
||||
A personal event RSVP tool. Meant for personal home use and a single administrator. Unique feature is multiple activities per event.
|
||||
A personal event RSVP tool. Meant for personal home use and a single administrator. Unique feature is multiple activities per event.
|
||||
|
||||
# Development
|
||||
`pip install --user pipenv`
|
||||
|
||||
Inside the project root: `pipenv install`
|
||||
|
||||
Everytime you start: `pipenv shell`
|
||||
|
||||
Check that is works by running `which python`
|
||||
|
||||
|
||||
# Ideas and notes
|
||||
|
||||
## Een
|
||||
Steps: Allemaal altijd renderen. Ze vragen de huidige stap op, en checken of ze zelf aan de beurt zijn en renderen op basis daarvan: klein en groen als klaar.
|
||||
Dat bovenste is dus meteen een slecht idee. De pagina kan beter alle stukjes meteen renderen? En dan met javascript op de pagina bepalen welke uitgeklapt moet zijn?
|
||||
Oplossing wordt het toevoegen van en step nummer, die toch dan bijhoudt waar je precies in de stappen bent
|
||||
|
||||
## Twee
|
||||
VOor de paginas. Het moet gewoon altijd paginas zijn. Link heet invite/<code>.
|
||||
Die maakt rsvp aan, en stuurt door naar rsvp/<unique>/<step>
|
||||
Status renamen naar step, en die opslaan in de database
|
||||
Status is dan nog maar 3 dingen: incomplete, going, not going.
|
||||
Een copy dingetje om de unique url te kopiëren met uitleg voor later
|
||||
|
||||
## Drie
|
||||
Invalidate invite na gebruik? Ze moeten nu de edit token gebruiken, want die krijgen ze per mail!
|
||||
Als ze niet binnen zoveel minuten de rsvp afronden, of afbreken, dan krijgen ze de mail eerder?
|
||||
|
||||
## Vier
|
||||
Geen nieuwe gasten toevoegen, als er al een activity geselecteerd is die vol is
|
||||
|
||||
## Vijf
|
||||
|
||||
@@ -5,6 +5,7 @@ from events.models import *
|
||||
admin.site.register(Event)
|
||||
admin.site.register(Activity)
|
||||
admin.site.register(ActivityGroup)
|
||||
admin.site.register(ActivitySelection)
|
||||
admin.site.register(Invite)
|
||||
admin.site.register(RSVP)
|
||||
admin.site.register(Guest)
|
||||
|
||||
@@ -3,3 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
name = 'events'
|
||||
|
||||
def ready(self):
|
||||
import events.signals
|
||||
@@ -0,0 +1,34 @@
|
||||
import logging
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ActivityConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
if "url_route" not in self.scope:
|
||||
raise ValueError
|
||||
|
||||
self.event_id = self.scope["url_route"]["kwargs"]["event_id"]
|
||||
|
||||
# Subscribe to database changes for this event
|
||||
await self.channel_layer.group_add(
|
||||
f"activity_{self.event_id}",
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
# Remove from group on disconnect
|
||||
await self.channel_layer.group_discard(
|
||||
f"activity_{self.event_id}",
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
async def group_message(self, event):
|
||||
logger.debug(f"Message {self.event_id}: {event}")
|
||||
|
||||
activity_json = event.get("activity_json")
|
||||
|
||||
if activity_json:
|
||||
await self.send(text_data=activity_json)
|
||||
@@ -0,0 +1,13 @@
|
||||
# forms.py
|
||||
from django import forms
|
||||
from .models import RSVP, Guest
|
||||
|
||||
class RSVPForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RSVP
|
||||
fields = ['responder_email', 'send_email_updates']
|
||||
|
||||
class GuestForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Guest
|
||||
fields = ['name', 'age_group']
|
||||
+187
-17
@@ -1,11 +1,14 @@
|
||||
# models.py
|
||||
import uuid
|
||||
from typing import Self
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from events.utils.pageutils import is_next_page, is_previous_page
|
||||
|
||||
User = settings.AUTH_USER_MODEL
|
||||
|
||||
@@ -37,7 +40,7 @@ class Event(TimestampedModel):
|
||||
image = models.ImageField(upload_to=event_image_path, null=True, blank=True)
|
||||
|
||||
# Behavioural settings
|
||||
allow_rsvp_without_invite = models.BooleanField(default=True)
|
||||
allow_rsvp_without_invite = models.BooleanField(default=False)
|
||||
allow_comments = models.BooleanField(default=True)
|
||||
default_email_updates_optin = models.BooleanField(default=True)
|
||||
|
||||
@@ -65,9 +68,10 @@ class Event(TimestampedModel):
|
||||
return last.end_time if last else None
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
if not self.start_time:
|
||||
last = self.activities.order_by('-end_time').first()
|
||||
if not last:
|
||||
return False
|
||||
return timezone.now() >= (self.start_time - self.response_lock_before)
|
||||
return timezone.now() >= (last.start_time - self.response_lock_before)
|
||||
|
||||
def total_attendees(self) -> int:
|
||||
return Guest.objects.filter(rsvp__event=self).count()
|
||||
@@ -124,6 +128,95 @@ class Activity(TimestampedModel):
|
||||
def attendee_count(self) -> int:
|
||||
return Guest.objects.filter(rsvp__activity_selections__activity=self).count()
|
||||
|
||||
def attendee_list(self) -> list[list[str]]:
|
||||
"""
|
||||
Create a json list of names who are joining this activity
|
||||
|
||||
Context:
|
||||
- An activity is linked to RSVPs in ActivitySelection
|
||||
- An RSVP has a guests property which is a list of Guest objects for that group
|
||||
- A guest can have the main_invitee property set, but not neceserilly
|
||||
- Each Guest has a name property
|
||||
- A name can be: "John", or "John Doe", or "John Peter Doe", or for foreign guests: "John van Doe"
|
||||
|
||||
Steps:
|
||||
1. Get the RSVPs which are coming to Activity (using ActivitySelection)
|
||||
2. For each RSVP, get the guest list
|
||||
2.1. Sort the list so that the Guest with property main_invitee is first,
|
||||
then based on the age group (old to young)
|
||||
2.2. Transform all names for privacy reasons to be the first name only
|
||||
2.3. If a first name is duplicate in another rsvp guest group, then add the last name letter
|
||||
For example: "John D.", or "John van D."
|
||||
3. Create a list of lists: [['John D.', 'Jane', 'Jack H.'], ['Maria'], ['John van D.', 'Peter'], ['Jack K.']]
|
||||
4. Return this list
|
||||
|
||||
"""
|
||||
|
||||
selected_rsvps = ActivitySelection.objects.filter(activity=self).values_list('rsvp', flat=True)
|
||||
|
||||
if not selected_rsvps:
|
||||
return []
|
||||
|
||||
age_order = {
|
||||
'adult': 0,
|
||||
'child': 1,
|
||||
'baby': 2
|
||||
}
|
||||
|
||||
def sort_key(guest):
|
||||
# main_invitee first (False = 0, True = 1)
|
||||
main_invitee_priority = 0 if guest.is_main_invitee else 1
|
||||
# Then by age group (old to young)
|
||||
age_priority = age_order.get(guest.age_group, 2)
|
||||
return (main_invitee_priority, age_priority)
|
||||
|
||||
# Store all first names
|
||||
first_names_tracker = {}
|
||||
for rsvp_id in selected_rsvps:
|
||||
guests = Guest.objects.filter(rsvp=rsvp_id)
|
||||
for guest in guests:
|
||||
name_parts = guest.name.split()
|
||||
first_name = name_parts[0]
|
||||
|
||||
if first_name in first_names_tracker:
|
||||
first_names_tracker[first_name] += 1
|
||||
else:
|
||||
first_names_tracker[first_name] = 1
|
||||
|
||||
# Create the lists
|
||||
guest_list = []
|
||||
for rsvp_id in selected_rsvps:
|
||||
guests_non_sort = Guest.objects.filter(rsvp=rsvp_id, )
|
||||
guests = sorted(guests_non_sort, key=sort_key)
|
||||
|
||||
rsvp_list = []
|
||||
|
||||
for guest in guests:
|
||||
name_parts = guest.name.split()
|
||||
first_name = name_parts[0]
|
||||
|
||||
# If no duplicate and no last name, just first_name
|
||||
name_to_store = first_name
|
||||
|
||||
# Check if name is duplicate
|
||||
if first_name in first_names_tracker and first_names_tracker[first_name] > 1:
|
||||
if len(name_parts) == 2:
|
||||
name_to_store = first_name + " " + name_parts[-1][0] + "."
|
||||
elif len(name_parts) > 2:
|
||||
name_to_store = first_name
|
||||
|
||||
for name_part in name_parts[1:-1]:
|
||||
name_to_store += " " + name_part
|
||||
|
||||
name_to_store += " " + name_parts[-1][0] + "."
|
||||
|
||||
rsvp_list += [name_to_store]
|
||||
|
||||
guest_list += [rsvp_list]
|
||||
|
||||
# Step 5: Return as JSON string
|
||||
return guest_list
|
||||
|
||||
|
||||
class Invite(TimestampedModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -139,23 +232,33 @@ class Invite(TimestampedModel):
|
||||
|
||||
|
||||
class RSVP(TimestampedModel):
|
||||
STATUS_CHOICES = [
|
||||
("yes", "Yes"),
|
||||
("no", "No"),
|
||||
("maybe", "Maybe"),
|
||||
("pending", "Pending"),
|
||||
]
|
||||
|
||||
class Page(models.TextChoices):
|
||||
INVALID = 'invalid', _('Invalid')
|
||||
EMAIL = 'email', _('Email')
|
||||
GUESTS = 'guests', _('Guests')
|
||||
ACTIVITIES = 'activities', _('Activities')
|
||||
QUESTIONS = 'questions', _('Questions')
|
||||
NOTES = 'notes', _('Notes')
|
||||
COMPLETED = 'completed', _('Completed')
|
||||
|
||||
class Status(models.TextChoices):
|
||||
INCOMPLETE = 'incomplete', _('Incomplete')
|
||||
NOT_COMING = 'not_coming', _('Not coming')
|
||||
YES_JOINING = 'joining', _('Yes, joining')
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
event = models.ForeignKey(Event, related_name='rsvps', on_delete=models.CASCADE)
|
||||
invite = models.ForeignKey(Invite, null=True, blank=True, related_name='rsvps', on_delete=models.SET_NULL)
|
||||
invite = models.OneToOneField(Invite, null=True, blank=True, related_name='rsvp', on_delete=models.SET_NULL)
|
||||
|
||||
edit_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
|
||||
responder_email = models.EmailField(blank=True)
|
||||
responder_email = models.EmailField(blank=False)
|
||||
send_email_updates = models.BooleanField(default=True)
|
||||
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
||||
page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID)
|
||||
furthest_page = models.CharField(max_length=16, choices=Page.choices, default=Page.INVALID)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.INCOMPLETE)
|
||||
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
@@ -163,12 +266,79 @@ class RSVP(TimestampedModel):
|
||||
activities = models.ManyToManyField(Activity, through='ActivitySelection', related_name='rsvps')
|
||||
|
||||
def __str__(self):
|
||||
return f"RSVP {self.id} — {self.event.title} — {self.status}"
|
||||
return f"RSVP {self.id} — {self.event.title} — {self.responder_email} — {self.status}"
|
||||
|
||||
def guest_count(self) -> int:
|
||||
return self.guests.count()
|
||||
|
||||
PAGE_ORDER = [
|
||||
'invalid',
|
||||
'email',
|
||||
'guests',
|
||||
'activities',
|
||||
'questions',
|
||||
'notes',
|
||||
'completed',
|
||||
]
|
||||
|
||||
def next_page(self) -> Page:
|
||||
"""Move to the next page and save to model."""
|
||||
|
||||
new_page = self.Page.INVALID
|
||||
|
||||
try:
|
||||
current_index = self.PAGE_ORDER.index(self.page)
|
||||
if current_index < len(self.PAGE_ORDER) - 1:
|
||||
new_page = self.Page(self.PAGE_ORDER[current_index + 1])
|
||||
except ValueError:
|
||||
# If page is not in the list (e.g., INVALID), start from EMAIL
|
||||
pass
|
||||
|
||||
if is_next_page(new_page, self.Page(self.furthest_page)):
|
||||
self.furthest_page = new_page
|
||||
|
||||
self.page = new_page
|
||||
self.save()
|
||||
|
||||
return new_page
|
||||
|
||||
def previous_page(self) -> Page:
|
||||
"""Move to the previous page and save to model"""
|
||||
|
||||
new_page = self.Page.INVALID
|
||||
|
||||
try:
|
||||
current_index = self.PAGE_ORDER.index(self.page)
|
||||
if current_index > 0:
|
||||
new_page = self.Page(self.PAGE_ORDER[current_index - 1])
|
||||
except ValueError:
|
||||
# If page is not in the list (e.g., INVALID), stay at INVALID
|
||||
pass
|
||||
|
||||
self.page = new_page
|
||||
self.save()
|
||||
|
||||
return new_page
|
||||
|
||||
def set_page(self, page:str) -> Page:
|
||||
"""Move to the specific page and save to model"""
|
||||
|
||||
new_page = self.Page.INVALID
|
||||
|
||||
try:
|
||||
new_page = self.Page(page)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.page = new_page
|
||||
self.save()
|
||||
|
||||
return new_page
|
||||
|
||||
def is_page_finished(self, page:Page) -> bool:
|
||||
"""return `True` if page is previous to the furthest reached page"""
|
||||
return is_previous_page(page, self.Page(self.furthest_page))
|
||||
|
||||
def clean(self):
|
||||
# raise if event is locked
|
||||
if self.event.is_locked():
|
||||
raise ValidationError('Responses are locked for this event.')
|
||||
|
||||
class ActivitySelection(models.Model):
|
||||
"""Join table: which activities an RSVP selected.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# chat/routing.py
|
||||
from django.urls import re_path
|
||||
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"ws/event/(?P<event_id>[0-9a-f-]{8}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{4}-[0-9a-f-]{12})/$", consumers.ActivityConsumer.as_asgi()),
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync, sync_to_async
|
||||
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from channels.layers import get_channel_layer
|
||||
from events.models import RSVP, Activity, Event, ActivitySelection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@receiver(post_save, sender=ActivitySelection)
|
||||
def activity_selection_changed(sender, instance, created, **kwargs):
|
||||
"""Broadcast activity selection changes to all connected clients."""
|
||||
activity:Activity = instance.activity
|
||||
rsvp:RSVP = instance.rsvp
|
||||
event:Event = activity.event
|
||||
|
||||
logger.debug(f"Signal {activity}")
|
||||
|
||||
capacity = activity.remaining_capacity()
|
||||
if capacity is None:
|
||||
capacity = 100
|
||||
|
||||
activity_json = json.dumps({
|
||||
"activity": str(activity.id),
|
||||
"action": "select",
|
||||
"attendees": activity.attendee_count(),
|
||||
"capacity": capacity,
|
||||
"guests": activity.attendee_list()
|
||||
})
|
||||
|
||||
# Send message to the group
|
||||
async_to_sync(send_channel)(event.id, activity_json)
|
||||
|
||||
@receiver(post_delete, sender=ActivitySelection)
|
||||
def activity_selection_deleted(sender, instance, **kwargs):
|
||||
"""Broadcast activity selection changes to all connected clients."""
|
||||
activity:Activity = instance.activity
|
||||
rsvp:RSVP = instance.rsvp
|
||||
event:Event = activity.event
|
||||
|
||||
logger.debug(f"Signal {activity}")
|
||||
|
||||
capacity = activity.remaining_capacity()
|
||||
if capacity is None:
|
||||
capacity = 100
|
||||
|
||||
activity_json = json.dumps({
|
||||
"activity": str(activity.id),
|
||||
"action": "remove",
|
||||
"attendees": activity.attendee_count(),
|
||||
"capacity": capacity,
|
||||
"guests": activity.attendee_list()
|
||||
})
|
||||
|
||||
# Send message to the group
|
||||
async_to_sync(send_channel)(event.id, activity_json)
|
||||
|
||||
|
||||
async def send_channel(event_id, activity_json):
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
await channel_layer.group_send(
|
||||
f"activity_{event_id}",
|
||||
{
|
||||
"type": "group.message",
|
||||
"activity_json": activity_json
|
||||
}
|
||||
)
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,163 @@
|
||||
:root {
|
||||
--neutral: #e0e0e0ff;
|
||||
--green: #d1e9d3ff;
|
||||
--red: #e9d0d0ff;
|
||||
--orange: #e7e0ceff;
|
||||
--blue: #b5bed8ff;
|
||||
--purple: #e1c9e2ff;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--accent-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.subcard {
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.activity {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.activity:hover {
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.activity.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--green) !important;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin-top: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
background: var(--neutral);
|
||||
color: var(--text-light);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.bg-green {
|
||||
background: var(--green) !important;
|
||||
}
|
||||
|
||||
.bd-green {
|
||||
border: 2px solid transparent;
|
||||
border-color: var(--green) !important;
|
||||
}
|
||||
|
||||
.bg-red {
|
||||
background: var(--red) !important;
|
||||
}
|
||||
|
||||
.bd-red {
|
||||
border: 2px solid transparent;
|
||||
border-color: var(--red) !important;
|
||||
}
|
||||
|
||||
.bg-orange {
|
||||
background: var(--orange) !important;
|
||||
}
|
||||
|
||||
.bd-orange {
|
||||
border: 2px solid transparent;
|
||||
border-color: var(--orange) !important;
|
||||
}
|
||||
|
||||
.bg-blue {
|
||||
background: var(--blue) !important;
|
||||
}
|
||||
|
||||
.bd-blue {
|
||||
border: 2px solid transparent;
|
||||
border-color: var(--blue) !important;
|
||||
}
|
||||
|
||||
.bg-purple {
|
||||
background: var(--pruple) !important;
|
||||
}
|
||||
|
||||
.bd-purple {
|
||||
border: 2px solid transparent;
|
||||
border-color: var(--purple) !important;
|
||||
}
|
||||
|
||||
.bg-black {
|
||||
background: black !important;
|
||||
}
|
||||
|
||||
.bd-black {
|
||||
border: 2px solid transparent;
|
||||
border-color: black !important;
|
||||
}
|
||||
|
||||
.bg-gray {
|
||||
background: gray !important;
|
||||
}
|
||||
|
||||
.bd-gray {
|
||||
border: 2px solid transparent;
|
||||
border-color: gray !important;
|
||||
}
|
||||
|
||||
.locked {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.horizontal-form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.horizontal-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.horizontal-form label {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.horizontal-form input,
|
||||
.horizontal-form select,
|
||||
.horizontal-form textarea {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--neutral);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.horizontal-form button {
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
const activitySocket = new WebSocket(
|
||||
'ws://'
|
||||
+ window.location.host
|
||||
+ '/ws/event/'
|
||||
+ eventId
|
||||
+ '/'
|
||||
);
|
||||
|
||||
activitySocket.onmessage = function(e) {
|
||||
console.log(e);
|
||||
data = JSON.parse(e.data);
|
||||
|
||||
// Activity attendees (when everyone can join)
|
||||
attendees = document.querySelector('#activity-' + data.activity + ' > .numatt');
|
||||
if(attendees){
|
||||
attendees.innerHTML = data.attendees;
|
||||
}
|
||||
|
||||
// Activity capacity (when there is a guest limit)
|
||||
capacity = document.querySelector('#activity-' + data.activity + ' > .numcap');
|
||||
if(capacity){
|
||||
capacity.innerHTML = data.capacity;
|
||||
}
|
||||
|
||||
// Activity capacity (when there is a guest limit)
|
||||
activity = document.querySelector('#activity-' + data.activity);
|
||||
if(activity){
|
||||
if(data.capacity >= guestCount){
|
||||
activity.classList.remove("bg-gray");
|
||||
activity.classList.remove("bd-gray");
|
||||
}
|
||||
else{
|
||||
activity.classList.add("bg-gray");
|
||||
activity.classList.add("bd-gray");
|
||||
}
|
||||
}
|
||||
|
||||
// Activity capacity (when there is a guest limit)
|
||||
attendees_names = document.querySelector('#activity-' + data.activity + ' > .stratt');
|
||||
if(attendees_names){
|
||||
html = "";
|
||||
|
||||
for(index in data.guests){
|
||||
html += "<span class='badge bg-blue'>" + data.guests[index].join(", ") + "</span>";
|
||||
}
|
||||
|
||||
attendees_names.innerHTML = html;
|
||||
}
|
||||
};
|
||||
|
||||
activitySocket.onclose = function(e) {
|
||||
console.error('Activity socket closed unexpectedly');
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}RSVP{% endblock %}</title>
|
||||
|
||||
<script src="{% static 'js/htmx.2.0.8.min.js' %}" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"></script>
|
||||
<script src="{% static 'js/htmx-ext-ws.2.0.4.min.js' %}" integrity="sha384-1RwI/nvUSrMRuNj7hX1+27J8XDdCoSLf0EjEyF69nacuWyiJYoQ/j39RT1mSnd2G"></script>
|
||||
<script src="{% static 'js/alpine.3.15.8.min.js' %}" defer></script>
|
||||
<link rel="stylesheet" href="{% static 'css/simple.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
|
||||
</head>
|
||||
|
||||
<body hx-headers='{"X-CSRFTOKEN": "{{ csrf_token }}"}' hx-ext="ws">
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ event.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<h1>{{ event.title }}</h1>
|
||||
|
||||
{% if event.description %}
|
||||
<p>{{ event.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<strong>When:</strong>
|
||||
{{ event.start_time|date:"DATETIME_FORMAT" }}
|
||||
–
|
||||
{{ event.end_time|date:"DATETIME_FORMAT" }}
|
||||
</p>
|
||||
|
||||
{% if is_locked %}
|
||||
<p class="locked">
|
||||
RSVPs are closed for this event.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% for group, activities in groups.items %}
|
||||
<h2 class="group-title">
|
||||
{{ group.title }}
|
||||
{% if group.single_choice %}
|
||||
<span class="badge">Choose one</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% for activity in activities %}
|
||||
{% include "events/partials/activity_card.html" %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% if ungrouped_activities %}
|
||||
<h2 class="group-title">Other activities</h2>
|
||||
|
||||
{% for activity in ungrouped_activities %}
|
||||
{% include "events/partials/activity_card.html" %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2 class="group-title">View details</h2>
|
||||
<div id="activity-detail-container">None selected</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{ page_email }}
|
||||
|
||||
{{ page_guest }}
|
||||
|
||||
{{ page_activities }}
|
||||
|
||||
{{ page_questions }}
|
||||
|
||||
{{ page_notes }}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ event.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if is_locked %}
|
||||
|
||||
<div class="card locked">
|
||||
It is not possible to respond to this event.
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<h2 class="group-title">
|
||||
Hello {{ invite.recipient_name }}
|
||||
{% if invite.allow_bring_guests %}
|
||||
<span class="badge bg-green">Allowed to bring guests</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<div id="rsvp-flow"
|
||||
hx-get="{% url 'events:rsvp-pages' rsvp.id page %}"
|
||||
hx-trigger="load">
|
||||
</div>
|
||||
|
||||
<button class="bg-red"
|
||||
hx-get="{% url 'events:rsvp-page-change' rsvp.id "previous" %}"
|
||||
hx-target="#rsvp-flow"
|
||||
>Previous</button>
|
||||
|
||||
<button
|
||||
hx-get="{% url 'events:rsvp-page-change' rsvp.id "next" %}"
|
||||
hx-target="#rsvp-flow"
|
||||
>Next</button>
|
||||
|
||||
<div id="rsvp-ws-live">
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% load static %}
|
||||
<script>
|
||||
const eventId = "{{ event.id }}";
|
||||
</script>
|
||||
<script src="{% static 'js/rsvp.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
<div
|
||||
class="card activity"
|
||||
hx-get="{% url 'events:partial-activity-details' activity.id %}"
|
||||
hx-target="#activity-detail-container"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<h3>{{ activity.title }}</h3>
|
||||
|
||||
<p>
|
||||
{{ activity.start_time|date:"DATETIME_FORMAT" }}
|
||||
{% if activity.end_time %}
|
||||
– {{ activity.end_time|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if activity.guest_limit %}
|
||||
<p>
|
||||
Places remaining:
|
||||
{{ activity.remaining_capacity }} /
|
||||
{{ activity.guest_limit }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<div class="card">
|
||||
<h2>{{ activity.title }}</h2>
|
||||
|
||||
{% if activity.description %}
|
||||
<p>{{ activity.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if activity.questions.exists %}
|
||||
<h3>Questions</h3>
|
||||
|
||||
{% for question in activity.questions.all %}
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<strong>{{ question.text }}</strong>
|
||||
|
||||
{% if question.scope == "guest" %}
|
||||
<p><em>Asked for each guest</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if question.kind == "single" %}
|
||||
{% for choice in question.choices.all %}
|
||||
<div>
|
||||
<label>
|
||||
<input type="radio" disabled>
|
||||
{{ choice.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif question.kind == "multiple" %}
|
||||
{% for choice in question.choices.all %}
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" disabled>
|
||||
{{ choice.label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif question.kind == "boolean" %}
|
||||
<div>
|
||||
<label>
|
||||
<input type="radio" disabled>
|
||||
Yes
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="radio" disabled>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="text" disabled style="width:100%;">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="card" id="rsvp-activity">
|
||||
<h3>What do you want to go to?</h3>
|
||||
|
||||
{% for group, activities in groups.items %}
|
||||
<h4 class="group-title">
|
||||
{{ group.title }}
|
||||
{% if group.single_choice %}
|
||||
<span class="badge">Choose one</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
|
||||
{% for activity in activities %}
|
||||
{% include "events/partials/rsvp_page_activities_details.html" %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% if ungrouped_activities %}
|
||||
<h4 class="group-title">Other activities</h4>
|
||||
|
||||
{% for activity in ungrouped_activities %}
|
||||
{% include "events/partials/rsvp_page_activities_details.html" %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="subcard cursor {% if activity.id in selected_activities %}bg-green{% elif activity.remaining_capacity < rsvp.guest_count %}bg-gray bd-gray{% endif %}"
|
||||
hx-post="{% url 'events:rsvp-post-activity' rsvp.id activity.id %}"
|
||||
hx-target="#rsvp-activity"
|
||||
hx-swap="outerHTML"
|
||||
|
||||
id="activity-{{activity.id}}"
|
||||
>
|
||||
<h3>{{ activity.title }}</h3>
|
||||
|
||||
<p>
|
||||
{{ activity.start_time|date:"DATETIME_FORMAT" }}
|
||||
{% if activity.end_time %}
|
||||
– {{ activity.end_time|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if activity.guest_limit %}
|
||||
<p>
|
||||
Places remaining:
|
||||
<div class="numcap">{{ activity.remaining_capacity }}</div> /
|
||||
{{ activity.guest_limit }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
People going:
|
||||
<div class="numatt">{{ activity.attendee_count }}</div>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Attendees:</h3>
|
||||
<p>
|
||||
<div class="stratt">
|
||||
|
||||
{% for guest_list in activity.attendee_list %}
|
||||
<span class="badge bg-blue">
|
||||
{{ guest_list|join:", " }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
guestCount = {{ rsvp.guest_count }};
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="card" id="rsvp-invite">
|
||||
|
||||
<h3>You are invited to {{ event.title }}</h3>
|
||||
<p> {{ event.description }} </p>
|
||||
|
||||
<div class="subcard">
|
||||
<h4>Message from the host</h4>
|
||||
<p> {{ invite.personalized_message }} </p>
|
||||
</div>
|
||||
|
||||
<div class="subcard">
|
||||
<h4>Email and updates</h4>
|
||||
<span class="badge bg-orange">You will always receive an email with a link to edit your response</span>
|
||||
<form method="post" hx-post="{% url 'events:rsvp-post-email' rsvp.id %}" hx-target="#post-result">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit"><div id="post-result">Save</div></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<div class="card" id="rsvp-guest">
|
||||
<h3>Who is coming to {{ event.title }}?</h3>
|
||||
|
||||
{% for guest in guests %}
|
||||
<div class="subcard">
|
||||
{{ guest.name }} <span class="badge">{{ guest.age_group }}</span>
|
||||
|
||||
<button
|
||||
hx-delete="{% url 'events:rsvp-delete-guest' rsvp.id guest.id %}"
|
||||
hx-target="#rsvp-guest"
|
||||
hx-swap="outerHTML"
|
||||
>Delete</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="subcard" id="rsvp-guest-add">
|
||||
<form method="post" hx-post="{% url 'events:rsvp-post-guest' rsvp.id %}"
|
||||
hx-target="#rsvp-guest"
|
||||
hx-swap="outerHTML"
|
||||
class="horizontal-form">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Add guest</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
<div class="subcard" id="rsvp-add-guest">
|
||||
<form method="post" hx-post="{% url 'events:partial-rsvp-guests-add' invite.code %}"
|
||||
hx-target="#rsvp-add-guest"
|
||||
hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Save and add another guest</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
<div class="card collapse {% if complete %}bd-green cursor{% endif %}"
|
||||
{% if complete %}
|
||||
hx-get="{% url 'events:rsvp-page-change' rsvp.id page %}"
|
||||
hx-target="#rsvp-flow"
|
||||
{% endif %}
|
||||
>
|
||||
{{note}}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="subcard">
|
||||
Notes?
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="subcard">
|
||||
Questions?
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
# urls.py
|
||||
from django.urls import path
|
||||
|
||||
from events.views.rsvp import post_step_activities
|
||||
from . import views
|
||||
|
||||
app_name = "events"
|
||||
|
||||
urlpatterns = [
|
||||
path("events/<slug:slug>/", views.event_details, name="event-detail"),
|
||||
|
||||
# Partials
|
||||
path(
|
||||
"htmx/activity/<uuid:activity_id>/",
|
||||
views.partial_activity_details,
|
||||
name="partial-activity-details",
|
||||
),
|
||||
|
||||
path("invite/<slug:slug>/rsvp/<str:code>/", views.invite_redirect, name="invite"),
|
||||
|
||||
# RSVP Form
|
||||
path("events/<slug:slug>/rsvp/<uuid:code>/page/<str:page>", views.rsvp_base_page, name="rsvp-base"),
|
||||
path("htmx/rsvp/<str:id>/page/<str:page>", views.rsvp_pages, name="rsvp-pages"),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/navigate/<str:page>",
|
||||
views.page_change,
|
||||
name="rsvp-page-change",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/email",
|
||||
views.post_step_email,
|
||||
name="rsvp-post-email",
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/guest",
|
||||
views.post_step_guest,
|
||||
name="rsvp-post-guest"
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/delete/guest/<str:gid>",
|
||||
views.delete_step_guest,
|
||||
name="rsvp-delete-guest"
|
||||
),
|
||||
|
||||
path(
|
||||
"htmx/rsvp/<str:id>/post/activity/<str:aid>",
|
||||
views.post_step_activities,
|
||||
name="rsvp-post-activity"
|
||||
)
|
||||
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
PAGE_ORDER = [
|
||||
'invalid',
|
||||
'email',
|
||||
'guests',
|
||||
'activities',
|
||||
'questions',
|
||||
'notes',
|
||||
'completed',
|
||||
]
|
||||
|
||||
### PAGES
|
||||
def is_previous_page(first:str, second:str) -> bool:
|
||||
"""return True if `first` is a previous page of `second`"""
|
||||
try:
|
||||
first_idx = PAGE_ORDER.index(first)
|
||||
second_idx = PAGE_ORDER.index(second)
|
||||
return first_idx < second_idx
|
||||
except ValueError:
|
||||
# If either page is not in the list, return False
|
||||
return False
|
||||
|
||||
def is_next_page(first:str, second:str) -> bool:
|
||||
"""return True if `first` is a next page of `second`"""
|
||||
try:
|
||||
first_idx = PAGE_ORDER.index(first)
|
||||
second_idx = PAGE_ORDER.index(second)
|
||||
return first_idx > second_idx
|
||||
except ValueError:
|
||||
# If either page is not in the list, return False
|
||||
return False
|
||||
@@ -0,0 +1,108 @@
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from ..models import RSVP, Activity, ActivitySelection, Event, Invite
|
||||
|
||||
def create_rsvp(event: Event, invite: Invite):
|
||||
rsvp = RSVP(event=event, invite=invite)
|
||||
rsvp.page = RSVP.Page.EMAIL
|
||||
rsvp.furthest_page = RSVP.Page.EMAIL
|
||||
|
||||
if(invite.recipient_email != ""):
|
||||
rsvp.responder_email = invite.recipient_email
|
||||
|
||||
rsvp.save()
|
||||
|
||||
return rsvp
|
||||
|
||||
def create_or_switch_activity_response(rsvp: RSVP, activity: Activity):
|
||||
"""
|
||||
This function will:
|
||||
1. Check if there already is a ActivitySelection for this pair
|
||||
- then remove it (unselecting)
|
||||
2. Always make sure that the new selection will fit the number of guests
|
||||
- Adding guests will be blocked when a selection is active which is full
|
||||
3. Check if the new selection is illegal (choose one in same group)
|
||||
- then remove the old one
|
||||
- add the new selection (switch choice)
|
||||
"""
|
||||
|
||||
# Step 1: Remove existing selection for this activity pair (unselecting)
|
||||
existing_selection = ActivitySelection.objects.filter(rsvp=rsvp, activity=activity).first()
|
||||
if existing_selection:
|
||||
existing_selection.delete()
|
||||
return
|
||||
|
||||
# Step 2: Check if adding guests will fit in the selected activity
|
||||
remaining_capacity = activity.remaining_capacity()
|
||||
if(remaining_capacity is None):
|
||||
remaining_capacity = 100
|
||||
|
||||
if rsvp.guest_count() > remaining_capacity:
|
||||
return
|
||||
|
||||
# Step 3: Check if new selection is illegal (choosing one in same group)
|
||||
# Get the activity's group if it exists
|
||||
activity_group = activity.group
|
||||
|
||||
if activity_group and activity_group.single_choice:
|
||||
# Get all other activities in the same group that are already selected
|
||||
other_selections = ActivitySelection.objects.filter(
|
||||
rsvp=rsvp,
|
||||
activity__group=activity_group
|
||||
).exclude(activity=activity)
|
||||
|
||||
if other_selections.exists():
|
||||
# Remove the old selection (switch choice)
|
||||
for other_selection in other_selections:
|
||||
other_selection.delete()
|
||||
|
||||
# Create and save the new selection
|
||||
selection = ActivitySelection(rsvp=rsvp, activity=activity)
|
||||
selection.save()
|
||||
|
||||
return selection
|
||||
|
||||
def create_context(rsvp:RSVP) -> dict:
|
||||
context = {
|
||||
"rsvp": rsvp,
|
||||
"event": rsvp.event,
|
||||
"invite": rsvp.invite
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
def add_to_context(context:dict, key:str, value) -> dict:
|
||||
context[key] = value
|
||||
|
||||
return context
|
||||
|
||||
def get_rsvp(context:dict) -> RSVP:
|
||||
rsvp = get_from_context(context, "rsvp")
|
||||
|
||||
if not rsvp:
|
||||
raise KeyError
|
||||
|
||||
return rsvp
|
||||
|
||||
def get_invite(context:dict) -> Invite:
|
||||
invite = get_from_context(context, "invite")
|
||||
|
||||
if not invite:
|
||||
raise KeyError
|
||||
|
||||
return invite
|
||||
|
||||
def get_event(context:dict) -> Event:
|
||||
event = get_from_context(context, "event")
|
||||
|
||||
if not event:
|
||||
raise KeyError
|
||||
|
||||
return event
|
||||
|
||||
def get_from_context(context:dict, key:str):
|
||||
if key in context:
|
||||
return context[key]
|
||||
|
||||
return False
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,2 @@
|
||||
from .views import *
|
||||
from .rsvp import *
|
||||
@@ -0,0 +1,257 @@
|
||||
# views.py
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.forms import ValidationError
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.template.loader import get_template
|
||||
from django.urls import reverse
|
||||
|
||||
from ..utils.utils import add_to_context, create_or_switch_activity_response, create_context, get_event, get_from_context, get_rsvp, create_rsvp
|
||||
|
||||
from ..forms import GuestForm, RSVPForm
|
||||
|
||||
from ..models import (
|
||||
Event,
|
||||
Guest,
|
||||
Invite,
|
||||
Activity,
|
||||
ActivityGroup,
|
||||
RSVP,
|
||||
ActivitySelection,
|
||||
)
|
||||
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Main entrypoint for rsvp page
|
||||
def rsvp_base_page(request, slug:str, code:str, page:str):
|
||||
event = get_object_or_404(Event, slug=slug)
|
||||
rsvp = get_object_or_404(RSVP, edit_token=code)
|
||||
|
||||
context = {
|
||||
"rsvp": rsvp,
|
||||
"event": event,
|
||||
"invite": rsvp.invite,
|
||||
"page": page,
|
||||
"is_locked": event.is_locked()
|
||||
}
|
||||
|
||||
return render(request, "events/event_rsvp.html", context)
|
||||
|
||||
|
||||
|
||||
# Render selector based on the current step of the RSVP process
|
||||
def rsvp_pages(request, id:str, page:str):
|
||||
logger.debug(f"rsvp_pages: id={id}, page={page}")
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
|
||||
if page == "":
|
||||
page = rsvp.page
|
||||
|
||||
context = create_context(rsvp)
|
||||
|
||||
rendercontent = {
|
||||
"page_email": _render_page_email(request, context, page),
|
||||
"page_guest": _render_page_guest(request, context, page),
|
||||
"page_activities": _render_page_activities(request, context, page),
|
||||
"page_questions": _render_page_questions(request, context, page),
|
||||
"page_notes": _render_page_notes(request, context, page)
|
||||
}
|
||||
|
||||
# Add `hx-push` header and set to value of `reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page])`
|
||||
|
||||
response = render(request, "events/event_pages.html", rendercontent)
|
||||
response["HX-Push-Url"] = reverse('events:rsvp-base', args=[get_event(context).slug, rsvp.edit_token, page])
|
||||
return response
|
||||
|
||||
# Email page
|
||||
def _render_page_email(request, context, page):
|
||||
|
||||
should_render = _render_page(request, get_rsvp(context), "Email updates", RSVP.Page.EMAIL, page)
|
||||
if(should_render != True): return should_render
|
||||
|
||||
# Render the form
|
||||
add_to_context(context, "form", RSVPForm(instance=get_rsvp(context)))
|
||||
|
||||
template = get_template("events/partials/rsvp_page_email.html")
|
||||
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
def _render_page_guest(request, context, page):
|
||||
|
||||
# Find guests
|
||||
guests = (
|
||||
Guest.objects
|
||||
.filter(rsvp=get_rsvp(context))
|
||||
)
|
||||
|
||||
note = f"You are coming with {len(guests)-1} guests"
|
||||
if(len(guests) == 1): note = "You are coming!"
|
||||
if(len(guests) == 0): note = "Nobody is coming (yet)"
|
||||
|
||||
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.GUESTS, page)
|
||||
if(should_render != True): return should_render
|
||||
|
||||
add_to_context(context, "guests", guests)
|
||||
add_to_context(context, "form", GuestForm())
|
||||
|
||||
template = get_template("events/partials/rsvp_page_guest.html")
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
def _render_page_activities(request, context, page):
|
||||
|
||||
activities = (
|
||||
Activity.objects
|
||||
.filter(event=get_event(context))
|
||||
.select_related("group")
|
||||
.order_by("start_time")
|
||||
)
|
||||
|
||||
note = f"There are {len(activities)} activities to choose from"
|
||||
|
||||
should_render = _render_page(request, get_rsvp(context), note, RSVP.Page.ACTIVITIES, page)
|
||||
if(should_render != True): return should_render
|
||||
|
||||
groups = {}
|
||||
ungrouped = []
|
||||
|
||||
for activity in activities:
|
||||
|
||||
if activity.group:
|
||||
groups.setdefault(activity.group, []).append(activity)
|
||||
else:
|
||||
ungrouped.append(activity)
|
||||
|
||||
add_to_context(context, "groups", groups)
|
||||
add_to_context(context, "ungrouped_activities", ungrouped)
|
||||
|
||||
selected_activities = (
|
||||
ActivitySelection.objects
|
||||
.filter(rsvp=get_rsvp(context))
|
||||
)
|
||||
selected_activities_ids = [act.activity.id for act in selected_activities]
|
||||
add_to_context(context, "selected_activities", selected_activities_ids)
|
||||
|
||||
template = get_template("events/partials/rsvp_page_activities.html")
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
def _render_page_questions(request, context, page):
|
||||
|
||||
should_render = _render_page(request, get_rsvp(context), "Questions", RSVP.Page.QUESTIONS, page)
|
||||
if(should_render != True): return should_render
|
||||
|
||||
template = get_template("events/partials/rsvp_page_questions.html")
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
def _render_page_notes(request, context, page):
|
||||
|
||||
should_render = _render_page(request, get_rsvp(context), "Notes", RSVP.Page.NOTES, page)
|
||||
if(should_render != True): return should_render
|
||||
|
||||
template = get_template("events/partials/rsvp_page_notes.html")
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
# Render generic page placeholder
|
||||
def _render_page(request, rsvp:RSVP, note:str, page:RSVP.Page, current_page:RSVP.Page) -> bool | str:
|
||||
if page == current_page:
|
||||
return True
|
||||
|
||||
template = get_template("events/partials/rsvp_page_inactive.html")
|
||||
|
||||
context = {
|
||||
"note": note,
|
||||
"rsvp": rsvp,
|
||||
"page": page,
|
||||
"complete": rsvp.is_page_finished(page)
|
||||
}
|
||||
|
||||
return template.render(context=context, request=request)
|
||||
|
||||
# Change the page of the request
|
||||
def page_change(request, id:str, page:str):
|
||||
logger.debug(f"page_change: id={id}, page={page}")
|
||||
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
|
||||
if page == "next":
|
||||
rsvp.next_page()
|
||||
elif page == "previous":
|
||||
rsvp.previous_page()
|
||||
else:
|
||||
rsvp.set_page(page)
|
||||
|
||||
return rsvp_pages(request, id, rsvp.page)
|
||||
|
||||
# Email step: handle post data
|
||||
def post_step_email(request, id:str):
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
form = RSVPForm(request.POST)
|
||||
if form.is_valid():
|
||||
rsvp.responder_email = form.cleaned_data['responder_email']
|
||||
rsvp.send_email_updates = form.cleaned_data['send_email_updates']
|
||||
rsvp.save()
|
||||
|
||||
return HttpResponse('Success', status=200)
|
||||
|
||||
return HttpResponseBadRequest(form.errors)
|
||||
|
||||
# Guest step: handle post data
|
||||
def post_step_guest(request, id:str):
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
form = GuestForm(request.POST)
|
||||
if form.is_valid():
|
||||
guest = form.save(commit=False)
|
||||
guest.event = rsvp.event
|
||||
guest.invite = rsvp.invite
|
||||
guest.rsvp = rsvp
|
||||
guest.save()
|
||||
|
||||
content = _render_page_guest(request, create_context(rsvp), RSVP.Page.GUESTS)
|
||||
return HttpResponse(content)
|
||||
|
||||
return HttpResponseBadRequest(form.errors)
|
||||
|
||||
# Guest step: handle post data
|
||||
def delete_step_guest(request, id:str, gid:str):
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
guest = get_object_or_404(Guest, id=gid)
|
||||
|
||||
if request.method != 'DELETE':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if guest.rsvp.id != rsvp.id:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
guest.delete()
|
||||
|
||||
content = _render_page_guest(request, create_context(rsvp), RSVP.Page.GUESTS)
|
||||
return HttpResponse(content)
|
||||
|
||||
# Guest step: handle post data
|
||||
def post_step_activities(request, id:str, aid:str):
|
||||
rsvp = get_object_or_404(RSVP, id=id)
|
||||
activity = get_object_or_404(Activity, id=aid)
|
||||
|
||||
if request.method != 'POST':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
create_or_switch_activity_response(rsvp, activity)
|
||||
except ValidationError as e:
|
||||
logger.info(str(e))
|
||||
return HttpResponseBadRequest(str(e))
|
||||
|
||||
content = _render_page_activities(request, create_context(rsvp), RSVP.Page.ACTIVITIES)
|
||||
return HttpResponse(content)
|
||||
@@ -0,0 +1,93 @@
|
||||
# views.py
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.urls import reverse
|
||||
|
||||
from events.utils.utils import create_rsvp
|
||||
|
||||
from ..forms import GuestForm
|
||||
|
||||
from ..models import (
|
||||
Event,
|
||||
Guest,
|
||||
Invite,
|
||||
Activity,
|
||||
ActivityGroup,
|
||||
RSVP,
|
||||
ActivitySelection,
|
||||
)
|
||||
|
||||
def invite_redirect(request, slug, code):
|
||||
"""
|
||||
Redirect from the unique invite page
|
||||
to the rsvp response page which is linked to the response
|
||||
"""
|
||||
|
||||
event = get_object_or_404(Event, slug=slug)
|
||||
invite = get_object_or_404(Invite, code=code)
|
||||
|
||||
# The invite code does not match the event
|
||||
if invite.event.id != event.id:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# Create RSVP if it doesn't exist, otherwise get it
|
||||
try:
|
||||
rsvp = RSVP.objects.get(invite=invite)
|
||||
except RSVP.DoesNotExist:
|
||||
rsvp = create_rsvp(event, invite)
|
||||
|
||||
return redirect(reverse('events:rsvp-base', args=[slug, rsvp.edit_token, rsvp.page]))
|
||||
|
||||
def event_details(request, slug):
|
||||
"""
|
||||
RSVP page for an event.
|
||||
Shows event info and list of activities.
|
||||
"""
|
||||
event = get_object_or_404(Event, slug=slug)
|
||||
|
||||
activities = (
|
||||
Activity.objects
|
||||
.filter(event=event, allow_rsvp=True)
|
||||
.select_related("group")
|
||||
.order_by("start_time")
|
||||
)
|
||||
|
||||
groups = {}
|
||||
ungrouped = []
|
||||
|
||||
for activity in activities:
|
||||
if activity.group:
|
||||
groups.setdefault(activity.group, []).append(activity)
|
||||
else:
|
||||
ungrouped.append(activity)
|
||||
|
||||
context = {
|
||||
"event": event,
|
||||
"groups": groups,
|
||||
"ungrouped_activities": ungrouped,
|
||||
"allow_rsvp": event.allow_rsvp_without_invite,
|
||||
"is_locked": event.is_locked(),
|
||||
}
|
||||
|
||||
return render(request, "events/event_details.html", context)
|
||||
|
||||
|
||||
# //// ######## \\\\ #
|
||||
# PARTIALS #
|
||||
# //// ######## \\\\ #
|
||||
|
||||
def partial_activity_details(request, activity_id):
|
||||
"""
|
||||
HTMX partial: loads activity details (description + questions).
|
||||
"""
|
||||
activity = get_object_or_404(Activity, id=activity_id)
|
||||
|
||||
if not activity.allow_rsvp:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"events/partials/activity_detail.html",
|
||||
{"activity": activity},
|
||||
)
|
||||
+13
-1
@@ -9,8 +9,20 @@ https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
|
||||
import os
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rsvpproject.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from events.routing import websocket_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
|
||||
),
|
||||
})
|
||||
@@ -31,6 +31,7 @@ ALLOWED_HOSTS = []
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'daphne',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
@@ -62,12 +63,45 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.csrf',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'standard': {
|
||||
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||
},
|
||||
'simple':{
|
||||
'format': '%(levelname)s %(message)s'
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': 'error.log',
|
||||
'formatter': 'simple',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'events': {
|
||||
'handlers': ['file', 'console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
WSGI_APPLICATION = 'rsvpproject.wsgi.application'
|
||||
ASGI_APPLICATION = 'rsvpproject.asgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
@@ -80,6 +114,11 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer"
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
+2
-1
@@ -15,8 +15,9 @@ Including another URLconf
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('events.urls', namespace='events')),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user