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:
2026-03-22 12:10:25 +01:00
parent 2fcb678f57
commit ac7293244c
41 changed files with 1670 additions and 121 deletions
+20
View File
@@ -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"
}
]
}
+17
View File
@@ -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
View File
@@ -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": [
+34 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -3,3 +3,6 @@ from django.apps import AppConfig
class EventsConfig(AppConfig):
name = 'events'
def ready(self):
import events.signals
+34
View File
@@ -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)
+13
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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()),
]
+72
View File
@@ -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
}
)
File diff suppressed because one or more lines are too long
+163
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+53
View File
@@ -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');
};
+25
View File
@@ -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 %}
+9
View File
@@ -0,0 +1,9 @@
{{ page_email }}
{{ page_guest }}
{{ page_activities }}
{{ page_questions }}
{{ page_notes }}
+50
View File
@@ -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>
+55
View File
@@ -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"
)
]
+31
View File
@@ -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
+108
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
+2
View File
@@ -0,0 +1,2 @@
from .views import *
from .rsvp import *
+257
View File
@@ -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)
+93
View File
@@ -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
View File
@@ -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))
),
})
+39
View File
@@ -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
View File
@@ -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')),
]