diff --git a/.gitignore b/.gitignore index 35a1877f2..79a6e3647 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,9 @@ htmlcov/ # Test database db.sqlite3 -# Mac +# Misc .DS_Store +*.pem # React node_modules/ diff --git a/README.md b/README.md index 9237fee70..542249db7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ React/Next.js frontend and Django-based REST API. ## Installation +You will need to start both the backend and the frontend to do Penn Clubs development. + You will need to start both the backend and the frontend to develop on Penn Clubs. Clubs supports Mac and Linux/WSL development. Questions? Check out our [extended guide](https://github.com/pennlabs/penn-clubs/wiki/Development-Guide) for FAQs. @@ -84,3 +86,13 @@ Use `$ yarn test` to run Cypress tests. ### Development Click `Login` to log in as a test user. The `./manage.py populate` command creates a test user for you with username `bfranklin` and password `test`. Go to `/api/admin` to login to this account. + +#### Ticketing + +To test ticketing locally, you will need to [install](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation) `mkcert`, enter the `frontend` directory, and run the following commands: + +- `$ mkcert -install` +- `$ mkcert localhost 127.0.0.1 ::1` +- `$ export DOMAIN=https://localhost:3001 NODE_TLS_REJECT_UNAUTHORIZED=0` + +Then, after the frontend is running, run `yarn ssl-proxy` **in a new terminal window** and access the application at [https://localhost:3001](https://localhost:3001). \ No newline at end of file diff --git a/backend/Pipfile b/backend/Pipfile index c97c74300..c54f42e0b 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -36,7 +36,7 @@ django-runtime-options = "*" social-auth-app-django = "*" django-redis = "*" channels-redis = "*" -uwsgi = {version = "*", sys_platform = "== 'linux'"} +uwsgi = {version ="==2.0.24", sys_platform = "== 'linux'"} uvloop = {version = "*", sys_platform = "== 'linux'"} uvicorn = {extras = ["standard"], version = "*"} gunicorn = "*" @@ -54,6 +54,8 @@ pandas = "*" drf-excel = "*" numpy = "*" inflection = "*" +cybersource-rest-client-python = "*" +pyjwt = "*" [requires] python_version = "3.11" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index cdecb8eda..9bac992aa 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4057df7b74f8c1e641c8f0fad1954492c21eac3651768db019f8d174749a018e" + "sha256": "745dba9015c559c2b445cbcdd35bee72beea730749e1838627c860946f971af9" }, "pipfile-spec": 6, "requires": { @@ -321,41 +321,39 @@ }, "cryptography": { "hashes": [ - "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee", - "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576", - "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d", - "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30", - "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413", - "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb", - "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da", - "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4", - "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd", - "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc", - "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8", - "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1", - "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc", - "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e", - "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8", - "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940", - "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400", - "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7", - "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16", - "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278", - "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74", - "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec", - "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1", - "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2", - "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c", - "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922", - "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a", - "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6", - "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1", - "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e", - "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac", - "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7" + "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960", + "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a", + "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc", + "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a", + "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf", + "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1", + "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39", + "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406", + "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a", + "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a", + "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c", + "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be", + "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15", + "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2", + "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d", + "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157", + "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003", + "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248", + "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a", + "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec", + "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309", + "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7", + "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d" ], "markers": "python_version >= '3.7'", - "version": "==42.0.5" + "version": "==41.0.7" + }, + "cybersource-rest-client-python": { + "hashes": [ + "sha256:f1843572ddf5da67a0b70a29f0b6bc936e6bcdb8f1939912f0ea3b98b82d74f1" + ], + "index": "pypi", + "version": "==0.0.53" }, "daphne": { "hashes": [ @@ -365,6 +363,14 @@ "markers": "python_version >= '3.8'", "version": "==4.1.2" }, + "datetime": { + "hashes": [ + "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120", + "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3" + ], + "markers": "python_version >= '3.7'", + "version": "==5.5" + }, "defusedxml": { "hashes": [ "sha256:138c7d540a78775182206c7c97fe65b246a2f40b29471e1a2f1b0da76e7a3942", @@ -1196,20 +1202,60 @@ "markers": "python_version >= '3.8'", "version": "==2.22" }, + "pycryptodome": { + "hashes": [ + "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690", + "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7", + "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4", + "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd", + "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5", + "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc", + "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818", + "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab", + "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d", + "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a", + "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25", + "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091", + "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea", + "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a", + "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c", + "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72", + "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9", + "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6", + "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044", + "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04", + "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c", + "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e", + "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f", + "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b", + "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4", + "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33", + "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f", + "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e", + "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a", + "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2", + "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3", + "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.20.0" + }, "pyjwt": { "hashes": [ "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" ], + "index": "pypi", "markers": "python_version >= '3.7'", "version": "==2.8.0" }, "pyopenssl": { "hashes": [ - "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad", - "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f" + "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2", + "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac" ], - "version": "==24.1.0" + "markers": "python_version >= '3.6'", + "version": "==23.2.0" }, "pypng": { "hashes": [ @@ -1365,11 +1411,11 @@ }, "setuptools": { "hashes": [ - "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e", - "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c" + "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", + "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" ], "markers": "python_version >= '3.8'", - "version": "==69.2.0" + "version": "==69.5.1" }, "six": { "hashes": [ @@ -1414,11 +1460,11 @@ }, "sqlparse": { "hashes": [ - "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", - "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", + "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" ], - "markers": "python_version >= '3.5'", - "version": "==0.4.4" + "markers": "python_version >= '3.8'", + "version": "==0.5.0" }, "tatsu": { "hashes": [ @@ -1519,7 +1565,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.6'", + "markers": "python_version >= '3.8'", "version": "==2.2.1" }, "uvicorn": { @@ -1749,7 +1795,7 @@ ], "version": "==12.0" }, - "zope-interface": { + "zope.interface": { "hashes": [ "sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e", "sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf", @@ -2074,11 +2120,11 @@ }, "sqlparse": { "hashes": [ - "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", - "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", + "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" ], - "markers": "python_version >= '3.5'", - "version": "==0.4.4" + "markers": "python_version >= '3.8'", + "version": "==0.5.0" }, "unittest-xml-reporting": { "hashes": [ diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index d594e38c9..0535fc7b3 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -22,6 +22,7 @@ ApplicationSubmission, Asset, Badge, + Cart, Club, ClubApplication, ClubFair, @@ -50,6 +51,8 @@ TargetStudentType, TargetYear, Testimonial, + Ticket, + TicketTransactionRecord, Year, ZoomMeetingVisit, ) @@ -451,4 +454,7 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin): admin.site.register(Year, YearAdmin) admin.site.register(ZoomMeetingVisit, ZoomMeetingVisitAdmin) admin.site.register(AdminNote) +admin.site.register(Ticket) +admin.site.register(TicketTransactionRecord) +admin.site.register(Cart) admin.site.register(ApplicationCycle) diff --git a/backend/clubs/management/commands/populate.py b/backend/clubs/management/commands/populate.py index 5c8dd77c5..892865ce5 100644 --- a/backend/clubs/management/commands/populate.py +++ b/backend/clubs/management/commands/populate.py @@ -15,6 +15,7 @@ ApplicationQuestion, ApplicationSubmission, Badge, + Cart, Club, ClubApplication, ClubFair, @@ -28,6 +29,7 @@ StudentType, Tag, Testimonial, + Ticket, Year, ) @@ -759,4 +761,35 @@ def get_image(url): first_mship.save() count += 1 + # Add tickets + + hr = Club.objects.get(code="harvard-rejects") + + hr_events = Event.objects.filter(club=hr) + + for idx, e in enumerate(hr_events[:3]): + # Switch up person every so often + person = ben if idx < 2 else user_objs[1] + + # Create some unowned tickets + Ticket.objects.bulk_create( + [Ticket(event=e, type="Regular", price=10.10) for _ in range(10)] + ) + + Ticket.objects.bulk_create( + [Ticket(event=e, type="Premium", price=100.10) for _ in range(5)] + ) + + # Create some owned tickets and tickets in cart + for i in range((idx + 1) * 10): + if i % 5: + Ticket.objects.create( + event=e, owner=person, type="Regular", price=i + ) + else: + c, _ = Cart.objects.get_or_create(owner=person) + c.tickets.add( + Ticket.objects.create(event=e, type="Premium", price=i) + ) + self.stdout.write("Finished populating database!") diff --git a/backend/clubs/migrations/0091_cart_ticket.py b/backend/clubs/migrations/0091_cart_ticket.py new file mode 100644 index 000000000..7441013da --- /dev/null +++ b/backend/clubs/migrations/0091_cart_ticket.py @@ -0,0 +1,90 @@ +# Generated by Django 3.2.8 on 2022-11-12 20:05 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("clubs", "0090_auto_20230106_1443"), + ] + + operations = [ + migrations.CreateModel( + name="Cart", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "owner", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="cart", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Ticket", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("type", models.CharField(max_length=100)), + ("holding_expiration", models.DateTimeField(blank=True, null=True)), + ( + "carts", + models.ManyToManyField( + blank=True, related_name="tickets", to="clubs.Cart" + ), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="tickets", + to="clubs.event", + ), + ), + ( + "holder", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="held_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/clubs/migrations/0092_auto_20221118_1424.py b/backend/clubs/migrations/0092_auto_20221118_1424.py new file mode 100644 index 000000000..3a799d29d --- /dev/null +++ b/backend/clubs/migrations/0092_auto_20221118_1424.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.15 on 2022-11-18 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0091_cart_ticket"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalclub", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical club", + "verbose_name_plural": "historical clubs", + }, + ), + migrations.AlterField( + model_name="historicalclub", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/backend/clubs/migrations/0095_merge_20240128_1321.py b/backend/clubs/migrations/0095_merge_20240128_1321.py new file mode 100644 index 000000000..7254d57ce --- /dev/null +++ b/backend/clubs/migrations/0095_merge_20240128_1321.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.18 on 2024-01-28 18:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0092_auto_20221118_1424"), + ("clubs", "0094_applicationcycle_release_date"), + ] + + operations = [] diff --git a/backend/clubs/migrations/0096_merge_20240304_1450.py b/backend/clubs/migrations/0096_merge_20240304_1450.py new file mode 100644 index 000000000..f3b45a75f --- /dev/null +++ b/backend/clubs/migrations/0096_merge_20240304_1450.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.3 on 2024-03-04 19:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0095_merge_20240128_1321"), + ("clubs", "0095_rm_field_add_count"), + ] + + operations = [ + ] diff --git a/backend/clubs/migrations/0097_ticket_price.py b/backend/clubs/migrations/0097_ticket_price.py new file mode 100644 index 000000000..5c6c5fa97 --- /dev/null +++ b/backend/clubs/migrations/0097_ticket_price.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-04-03 04:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0096_merge_20240304_1450"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="price", + field=models.DecimalField(decimal_places=2, default=0, max_digits=5), + preserve_default=False, + ), + ] diff --git a/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py new file mode 100644 index 000000000..4e6ea6e7e --- /dev/null +++ b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.3 on 2024-04-14 20:25 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0097_ticket_price"), + ] + + operations = [ + migrations.CreateModel( + name="TicketTransactionRecord", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "reconciliation_id", + models.CharField(blank=True, max_length=100, null=True), + ), + ("total_amount", models.DecimalField(decimal_places=2, max_digits=5)), + ( + "buyer_phone", + phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=128, null=True, region=None + ), + ), + ("buyer_first_name", models.CharField(max_length=100)), + ("buyer_last_name", models.CharField(max_length=100)), + ( + "buyer_email", + models.EmailField(blank=True, max_length=254, null=True), + ), + ], + ), + migrations.AddField( + model_name="ticket", + name="transaction_record", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tickets", + to="clubs.tickettransactionrecord", + ), + ), + ] diff --git a/backend/clubs/migrations/0100_merge_20240412_2206.py b/backend/clubs/migrations/0100_merge_20240412_2206.py new file mode 100644 index 000000000..090b09a4a --- /dev/null +++ b/backend/clubs/migrations/0100_merge_20240412_2206.py @@ -0,0 +1,13 @@ +# Generated by Django 5.0.4 on 2024-04-13 02:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0096_merge_20240304_1450"), + ("clubs", "0099_remove_club_elo_remove_historicalclub_elo_and_more"), + ] + + operations = [] diff --git a/backend/clubs/migrations/0101_merge_20240414_1708.py b/backend/clubs/migrations/0101_merge_20240414_1708.py new file mode 100644 index 000000000..b694a9b16 --- /dev/null +++ b/backend/clubs/migrations/0101_merge_20240414_1708.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.3 on 2024-04-14 21:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0098_tickettransactionrecord_ticket_transaction_record"), + ("clubs", "0100_merge_20240412_2206"), + ] + + operations = [] diff --git a/backend/clubs/migrations/0102_event_ticket_order_limit.py b/backend/clubs/migrations/0102_event_ticket_order_limit.py new file mode 100644 index 000000000..79515fad4 --- /dev/null +++ b/backend/clubs/migrations/0102_event_ticket_order_limit.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-14 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0101_merge_20240414_1708"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="ticket_order_limit", + field=models.IntegerField(default=10), + ), + ] diff --git a/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py new file mode 100644 index 000000000..a22e2a541 --- /dev/null +++ b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.4 on 2024-04-16 16:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0102_event_ticket_order_limit"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="group_discount", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=3, + default=0, + ), + ), + migrations.AddField( + model_name="ticket", + name="group_size", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/clubs/migrations/0104_cart_checkout_context.py b/backend/clubs/migrations/0104_cart_checkout_context.py new file mode 100644 index 000000000..3f7c3f220 --- /dev/null +++ b/backend/clubs/migrations/0104_cart_checkout_context.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-21 00:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0103_ticket_group_discount_ticket_group_size"), + ] + + operations = [ + migrations.AddField( + model_name="cart", + name="checkout_context", + field=models.CharField(blank=True, max_length=8297, null=True), + ), + ] diff --git a/backend/clubs/migrations/0105_ticket_attended.py b/backend/clubs/migrations/0105_ticket_attended.py new file mode 100644 index 000000000..28ce3cc12 --- /dev/null +++ b/backend/clubs/migrations/0105_ticket_attended.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.4 on 2024-04-22 03:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0104_cart_checkout_context"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="attended", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index b59439629..d13c87ade 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1,17 +1,21 @@ +import base64 import datetime import os import re import uuid import warnings +from io import BytesIO from urllib.parse import urlparse import pytz +import qrcode import requests import yaml from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives +from django.core.signing import Signer from django.core.validators import validate_email from django.db import models, transaction from django.db.models import Sum @@ -325,7 +329,6 @@ class Club(models.Model): # cache club aggregation counts favorite_count = models.IntegerField(default=0) membership_count = models.IntegerField(default=0) - # cache club rankings rank = models.IntegerField(default=0) @@ -922,6 +925,7 @@ class Event(models.Model): parent_recurring_event = models.ForeignKey( RecurringEvent, on_delete=models.CASCADE, blank=True, null=True ) + ticket_order_limit = models.IntegerField(default=10) OTHER = 0 RECRUITMENT = 1 @@ -952,6 +956,10 @@ def create_thumbnail(self, request=None): def __str__(self): return self.name + @property + def tickets_count(self): + return Ticket.objects.count(event=self) + class Favorite(models.Model): """ @@ -1774,6 +1782,136 @@ class Meta: unique_together = (("question", "submission"),) +class Cart(models.Model): + """ + Represents an instance of a ticket cart for a user + """ + + owner = models.OneToOneField( + get_user_model(), related_name="cart", on_delete=models.CASCADE + ) + # Capture context from Cybersource should be 8297 chars + checkout_context = models.CharField(max_length=8297, blank=True, null=True) + + +class TicketManager(models.Manager): + # Update holds for all tickets + def update_holds(self): + expired_tickets = self.select_for_update().filter( + holder__isnull=False, holding_expiration__lte=timezone.now() + ) + + if not expired_tickets: + return + + with transaction.atomic(): + for ticket in expired_tickets: + ticket.holder = None + self.bulk_update(expired_tickets, ["holder"]) + + +class TicketTransactionRecord(models.Model): + """ + Represents an instance of a transaction record for an ticket, used for bookkeeping + """ + + reconciliation_id = models.CharField(max_length=100, null=True, blank=True) + total_amount = models.DecimalField(max_digits=5, decimal_places=2) + buyer_phone = PhoneNumberField(null=True, blank=True) + buyer_first_name = models.CharField(max_length=100) + buyer_last_name = models.CharField(max_length=100) + buyer_email = models.EmailField(blank=True, null=True) + + +class Ticket(models.Model): + """ + Represents an instance of a ticket for an event + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + event = models.ForeignKey( + Event, related_name="tickets", on_delete=models.DO_NOTHING + ) + type = models.CharField(max_length=100) + owner = models.ForeignKey( + get_user_model(), + related_name="owned_tickets", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + holder = models.ForeignKey( + get_user_model(), + related_name="held_tickets", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + holding_expiration = models.DateTimeField(null=True, blank=True) + carts = models.ManyToManyField(Cart, related_name="tickets", blank=True) + price = models.DecimalField(max_digits=5, decimal_places=2) + group_discount = models.DecimalField( + max_digits=3, + decimal_places=2, + default=0, + blank=True, + ) + group_size = models.PositiveIntegerField(null=True, blank=True) + transaction_record = models.ForeignKey( + TicketTransactionRecord, + related_name="tickets", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + attended = models.BooleanField(default=False) + objects = TicketManager() + + def get_qr(self): + """ + Return a QR code image linking to the ticket page + """ + if not self.owner: + return None + + signer = Signer() + token = signer.sign_object({"owner": self.owner.id, "ticket_id": str(self.id)}) + qr_image = qrcode.make( + f"https://{settings.DOMAINS[0]}/api/tickets/validate/{token}/", + box_size=20, + border=0, + ) + return qr_image + + def send_confirmation_email(self): + """ + Send a confirmation email to the ticket owner after purchase + """ + owner = self.owner + + output = BytesIO() + qr_image = self.get_qr() + qr_image.save(output, format="PNG") + decoded_image = base64.b64encode(output.getvalue()).decode("ascii") + + context = { + "first_name": self.owner.first_name, + "name": self.event.name, + "type": self.type, + "start_time": self.event.start_time, + "end_time": self.event.end_time, + "qr": decoded_image, + } + + if self.owner.email: + send_mail_helper( + name="ticket_confirmation", + subject=f"Ticket confirmation for {owner.get_full_name()}", + emails=[owner.email], + context=context, + ) + + @receiver(models.signals.pre_delete, sender=Asset) def asset_delete_cleanup(sender, instance, **kwargs): if instance.file: diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 266d3686e..d03a05573 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -187,7 +187,7 @@ def has_permission(self, request, view): class EventPermission(permissions.BasePermission): """ - Officers and above can create/update/delete events. + Officers and above can create/update/delete events and view ticket buyers. Everyone else can view and list events. """ @@ -223,7 +223,13 @@ def has_object_permission(self, request, view, obj): if not old_type == FAIR_TYPE and new_type == FAIR_TYPE: return False - + elif view.action in ["buyers", "create_tickets"]: + if not request.user.is_authenticated: + return False + membership = find_membership_helper(request.user, obj.club) + return membership is not None and membership.role <= Membership.ROLE_OFFICER + elif view.action in ["add_to_cart", "remove_from_cart"]: + return request.user.is_authenticated return True diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index f6d5a80eb..04d1d70ab 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -55,6 +55,7 @@ TargetStudentType, TargetYear, Testimonial, + Ticket, Year, ) from clubs.utils import clean @@ -362,8 +363,12 @@ class ClubEventSerializer(serializers.ModelSerializer): image_url = serializers.SerializerMethodField("get_image_url") large_image_url = serializers.SerializerMethodField("get_large_image_url") url = serializers.SerializerMethodField("get_event_url") + ticketed = serializers.SerializerMethodField("get_ticketed") creator = serializers.HiddenField(default=serializers.CurrentUserDefault()) + def get_ticketed(self, obj) -> bool: + return obj.tickets.count() > 0 + def get_event_url(self, obj): # if no url, return that if not obj.url: @@ -501,6 +506,7 @@ class Meta: "location", "name", "start_time", + "ticketed", "type", "url", ] @@ -1745,6 +1751,22 @@ class Meta: fields = ("club", "role", "title", "active", "public") +class TicketSerializer(serializers.ModelSerializer): + """ + Used to return a ticket object + """ + + owner = serializers.SerializerMethodField("get_owner_name") + event = EventSerializer() + + def get_owner_name(self, obj): + return obj.owner.get_full_name() if obj.owner else "None" + + class Meta: + model = Ticket + fields = ("id", "event", "type", "owner", "price") + + class UserUUIDSerializer(serializers.ModelSerializer): """ Used to get the uuid of a user (for ICS Calendar export) @@ -2807,6 +2829,7 @@ def save(self): "You cannot edit committees once the application is open" ) # nasty hack for idempotency + prev_committee_names = prev_committees.values("name") for prev_committee in prev_committees: if prev_committee.name not in committees: prev_committee.delete() diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 69d517551..4395aa838 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -43,6 +43,7 @@ SubscribeViewSet, TagViewSet, TestimonialViewSet, + TicketViewSet, UserGroupAPIView, UserPermissionAPIView, UserUpdateAPIView, @@ -69,6 +70,7 @@ router.register(r"searches", SearchQueryViewSet, basename="searches") router.register(r"memberships", MembershipViewSet, basename="members") router.register(r"requests", MembershipRequestViewSet, basename="requests") +router.register(r"tickets", TicketViewSet, basename="tickets") router.register(r"schools", SchoolViewSet, basename="schools") router.register(r"majors", MajorViewSet, basename="majors") diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6ded99725..3f0d04f6b 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -1,4 +1,5 @@ import argparse +import base64 import collections import datetime import functools @@ -8,14 +9,23 @@ import re import secrets import string +from functools import wraps +from typing import Tuple from urllib.parse import urlparse +import jwt import pandas as pd import pytz import qrcode import requests from asgiref.sync import async_to_sync from channels.layers import get_channel_layer +from CyberSource import ( + PaymentsApi, + TransientTokenDataApi, + UnifiedCheckoutCaptureContextApi, +) +from CyberSource.rest import ApiException from dateutil.parser import parse from django.conf import settings from django.contrib.auth import get_user_model @@ -27,7 +37,9 @@ from django.core.mail import EmailMultiAlternatives from django.core.management import call_command, get_commands, load_command_class from django.core.serializers.json import DjangoJSONEncoder +from django.core.signing import BadSignature, Signer from django.core.validators import validate_email +from django.db import transaction from django.db.models import ( Case, CharField, @@ -35,6 +47,7 @@ DurationField, ExpressionWrapper, F, + Max, Prefetch, Q, TextField, @@ -80,6 +93,7 @@ ApplicationSubmission, Asset, Badge, + Cart, Club, ClubApplication, ClubFair, @@ -102,6 +116,8 @@ Subscribe, Tag, Testimonial, + Ticket, + TicketTransactionRecord, Year, ZoomMeetingVisit, get_mail_type_annotation, @@ -172,6 +188,7 @@ SubscribeSerializer, TagSerializer, TestimonialSerializer, + TicketSerializer, UserClubVisitSerializer, UserClubVisitWriteSerializer, UserMembershipInviteSerializer, @@ -190,6 +207,19 @@ from clubs.utils import fuzzy_lookup_club, html_to_text +def update_holds(func): + """ + Decorator to update ticket holds + """ + + @wraps(func) + def wrap(self, request, *args, **kwargs): + Ticket.objects.update_holds() + return func(self, request, *args, **kwargs) + + return wrap + + def file_upload_endpoint_helper(request, code): obj = get_object_or_404(Club, code=code) if "file" in request.data and isinstance(request.data["file"], UploadedFile): @@ -303,6 +333,24 @@ def hour_to_string_helper(hour): return hour_string +def validate_transient_token(cc: str, tt: str) -> Tuple[bool, str]: + """Validate the integrity of the transient token using + the public key (JWK) obtained from the capture context""" + + try: + _, body, _ = cc.split(".") + decoded_body = json.loads(base64.b64decode(body + "===")) + jwk = decoded_body["flx"]["jwk"] + + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + # This will throw if the key is invalid + jwt.decode(tt, key=public_key, algorithms=["RS256"]) + return (True, "Successfully decoded the JWT") + + except Exception as e: + return (False, str(e)) + + class ReportViewSet(viewsets.ModelViewSet): """ retrieve: @@ -1576,7 +1624,6 @@ def booths(self, request, *args, **kwargs): """ club = self.get_object() res = ClubFairBooth.objects.filter(club=club).select_related("club").all() - return Response(ClubBoothSerializer(res, many=True).data) def get_operation_id(self, **kwargs): @@ -2264,6 +2311,19 @@ class ClubEventViewSet(viewsets.ModelViewSet): destroy: Delete an event. + + tickets: + Get or create tickets for particular event + + buyers: + Get information about the buyers of an event's ticket + + remove_from_cart: + Remove a ticket for this event from cart + + add_to_cart: + Add a ticket for this event to cart + """ permission_classes = [EventPermission | IsSuperuser] @@ -2284,6 +2344,368 @@ def get_serializer_class(self): return EventWriteSerializer return EventSerializer + @action(detail=True, methods=["post"]) + @transaction.atomic + @update_holds + def add_to_cart(self, request, *args, **kwargs): + """ + Add a certain number of tickets to the cart + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + quantities: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + success: + type: boolean + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + success: + type: boolean + --- + """ + event = self.get_object() + cart, _ = Cart.objects.get_or_create(owner=self.request.user) + + quantities = request.data.get("quantities") + if not quantities: + return Response( + {"detail": "Quantities must be specified"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + num_requested = sum(item["count"] for item in quantities) + num_carted = cart.tickets.filter(event=event).count() + + if num_requested + num_carted > event.ticket_order_limit: + return Response( + { + "detail": f"Order exceeds the maximum ticket limit of " + f"{event.ticket_order_limit}." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + for item in quantities: + type = item["type"] + count = item["count"] + + # Count unowned/unheld tickets of requested type + # We don't need a lock since we aren't changing the holder or owner + tickets = ( + Ticket.objects.filter( + event=event, type=type, owner__isnull=True, holder__isnull=True + ) + .prefetch_related("carts") + .exclude(carts__owner=self.request.user) + ) + + if tickets.count() < count: + return Response( + {"detail": f"Not enough tickets of type {type} left!"}, + status=status.HTTP_403_FORBIDDEN, + ) + cart.tickets.add(*tickets[:count]) + + cart.save() + return Response({"detail": "Successfully added to cart"}) + + @action(detail=True, methods=["post"]) + @transaction.atomic + @update_holds + def remove_from_cart(self, request, *args, **kwargs): + """ + Remove a certain type/number of tickets from the cart + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + quantities: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + --- + """ + event = self.get_object() + quantities = request.data.get("quantities") + if not quantities: + return Response( + {"detail": "Quantities must be specified"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cart = get_object_or_404(Cart, owner=self.request.user) + + for item in quantities: + type = item["type"] + count = item["count"] + tickets_to_remove = cart.tickets.filter(type=type, event=event) + + # Ensure we don't try to remove more tickets than we can + count = min(count, tickets_to_remove.count()) + cart.tickets.remove(*tickets_to_remove[:count]) + + cart.save() + return Response({"detail": "Successfully removed from cart"}) + + @action(detail=True, methods=["get"]) + def buyers(self, request, *args, **kwargs): + """ + Get information about ticket buyers + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + buyers: + type: array + items: + type: object + properties: + fullname: + type: string + id: + type: string + owner_id: + type: integer + type: + type: string + --- + """ + tickets = Ticket.objects.filter(event=self.get_object()).annotate( + fullname=Concat("owner__first_name", Value(" "), "owner__last_name") + ) + + buyers = tickets.filter(owner__isnull=False).values( + "fullname", "id", "owner_id", "type", "owner__email" + ) + + return Response({"buyers": buyers}) + + @action(detail=True, methods=["get"]) + def tickets(self, request, *args, **kwargs): + """ + Get information about tickets for particular event + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + totals: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer + price: + type: number + available: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer + price: + type: number + --- + """ + event = self.get_object() + tickets = Ticket.objects.filter(event=event) + + # Take price of first ticket of given type for now + totals = ( + tickets.values("type") + .annotate(price=Max("price")) + .annotate(count=Count("type")) + .order_by("type") + ) + available = ( + tickets.filter(owner__isnull=True, holder__isnull=True) + .values("type") + .annotate(price=Max("price")) + .annotate(count=Count("type")) + .order_by("type") + ) + return Response({"totals": list(totals), "available": list(available)}) + + @tickets.mapping.put + @transaction.atomic + def create_tickets(self, request, *args, **kwargs): + """ + Create ticket offerings for event + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + quantities: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer + price: + type: number + group_size: + type: number + required: false + group_discount: + type: number + required: false + order_limit: + type: int + required: false + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + "400": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + --- + """ + event = self.get_object() + + quantities = request.data.get("quantities", []) + if not quantities: + return Response( + {"detail": "Quantities must be specified"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + for item in quantities: + if not item.get("type") or not item.get("count"): + return Response( + {"detail": "Specify type and count to create some tickets."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Ticket prices must be non-negative + if item.get("price", 0) < 0: + return Response( + {"detail": "Ticket price cannot be negative"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Group discounts must be between 0 and 1 + if item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1: + return Response( + {"detail": "Group discount must be between 0 and 1"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Min group sizes must be greater than 1 + if item.get("group_size", 2) <= 1: + return Response( + {"detail": "Min group size must be greater than 1"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Tickets must specify both group_discount and group_size or neither + if ("group_discount" in item) != ("group_size" in item): + return Response( + { + "detail": ( + "Ticket must specify either both group_discount " + "and group_size or neither" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Atomicity ensures idempotency + Ticket.objects.filter(event=event).delete() # Idempotency + tickets = [ + Ticket( + event=event, + type=item["type"], + price=item.get("price", 0), + group_discount=item.get("group_discount", 0), + group_size=item.get("group_size", None), + ) + for item in quantities + for _ in range(item["count"]) + ] + + Ticket.objects.bulk_create(tickets) + + order_limit = request.data.get("order_limit", None) + if order_limit is not None: + event.ticket_order_limit = order_limit + event.save() + + return Response({"detail": "success"}) + @action(detail=True, methods=["post"]) def upload(self, request, *args, **kwargs): """ @@ -2471,6 +2893,12 @@ class EventViewSet(ClubEventViewSet): destroy: Delete an event. + + fair: + Get information about a fair listing + + owned: + Return all events that the user has officer permissions over. """ def get_operation_id(self, **kwargs): @@ -2653,6 +3081,28 @@ def owned(self, request, *args, **kwargs): return Response(EventSerializer(events, many=True).data) + def destroy(self, request, *args, **kwargs): + """ + Checks if there are tickets linked to this event before deletion. + """ + event = self.get_object() + now = timezone.now() + + if ( + now <= event.end_time + and Ticket.objects.filter(event=event, owner__isnull=False).exists() + ): + return Response( + { + "detail": "Cannot delete event with active tickets \ + associated with it." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Proceed with deletion if no active tickets are linked + return super().destroy(request, *args, **kwargs) + class TestimonialViewSet(viewsets.ModelViewSet): """ @@ -4285,6 +4735,523 @@ def get_object(self): return user +class TicketViewSet(viewsets.ModelViewSet): + """ + get: + Get a specific ticket owned by a user + + list: + List all tickets owned by a user + + cart: + List all unowned/unheld tickets currently in a user's cart + + initiate_checkout: + Initiate a hold on the tickets in a user's cart and create a capture context + + complete_checkout: + Complete the checkout process after we have obtained an auth on a user's card + + qr: + Get a ticket's QR code + + validate: + Validate a ticket's QR code and mark attendance + """ + + permission_classes = [IsAuthenticated] + serializer_class = TicketSerializer + http_method_names = ["get", "post"] + lookup_field = "id" + + @transaction.atomic + @update_holds + @action(detail=False, methods=["get"]) + def cart(self, request, *args, **kwargs): + """ + Validate tickets in a cart and return them + --- + requestBody: + content: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + tickets: + allOf: + - $ref: "#/components/schemas/Ticket" + sold_out: + type: integer + --- + """ + + cart, _ = Cart.objects.prefetch_related("tickets").get_or_create( + owner=self.request.user + ) + + # Replace in-cart tickets that have been bought/held by someone else + tickets_to_replace = cart.tickets.filter( + Q(owner__isnull=False) | Q(holder__isnull=False) + ).exclude(holder=self.request.user) + + # In most cases, we won't need to replace, so exit early + if not tickets_to_replace.exists(): + return Response( + { + "tickets": TicketSerializer(cart.tickets.all(), many=True).data, + "sold_out": 0, + }, + ) + + sold_out_count = 0 + + replacement_tickets = [] + tickets_in_cart = cart.tickets.values_list("id", flat=True) + for ticket_class in tickets_to_replace.values("type", "event").annotate( + count=Count("id") + ): + # We don't need to lock since we aren't updating holder/owner + tickets = Ticket.objects.filter( + event=ticket_class["event"], + type=ticket_class["type"], + owner__isnull=True, + holder__isnull=True, + ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]] + + sold_out_count += ticket_class["count"] - tickets.count() + replacement_tickets.extend(list(tickets)) + + cart.tickets.remove(*tickets_to_replace) + if replacement_tickets: + cart.tickets.add(*replacement_tickets) + cart.save() + + return Response( + { + "tickets": TicketSerializer(cart.tickets.all(), many=True).data, + "sold_out": sold_out_count, + }, + ) + + @action(detail=False, methods=["post"]) + @update_holds + @transaction.atomic + def initiate_checkout(self, request, *args, **kwargs): + """ + Checkout all tickets in cart and create a Cybersource capture context + + NOTE: this does NOT buy tickets, it simply initiates a checkout process + which includes a 10-minute ticket hold + + Once the user has entered their payment details and submitted the form + the request will be routed to complete_checkout + + 403 implies a stale cart. + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + success: + type: boolean + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + success: + type: boolean + --- + """ + cart = get_object_or_404(Cart, owner=self.request.user) + + # Cart must have at least one ticket + if not cart.tickets.exists(): + return Response( + { + "success": False, + "detail": "No tickets selected for checkout.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # skip_locked is important here because if any of the tickets in cart + # are locked, we shouldn't block. + tickets = cart.tickets.select_for_update(skip_locked=True).filter( + Q(holder__isnull=True) | Q(holder=self.request.user), owner__isnull=True + ) + + # Assert that the filter succeeded in freezing all the tickets for checkout + if tickets.count() != cart.tickets.count(): + return Response( + { + "success": False, + "detail": "Cart is stale, invoke /api/tickets/cart to refresh", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # Calculate cart total, applying group discounts where appropriate + ticket_type_counts = { + item["type"]: item["count"] + for item in cart.tickets.values("type").annotate(count=Count("type")) + } + + cart_total = sum( + ticket.price * (1 - ticket.group_discount) + if ticket.group_size + and ticket_type_counts[ticket.type] >= ticket.group_size + else ticket.price + for ticket in tickets + ) + + if not cart_total: + return Response( + { + "success": False, + "detail": "Cart total must be nonzero to generate capture context.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + capture_context_request = { + "_target_origins": [settings.CYBERSOURCE_TARGET_ORIGIN], + "_client_version": settings.CYBERSOURCE_CLIENT_VERSION, + "_allowed_card_networks": [ + "VISA", + "MASTERCARD", + "AMEX", + "DISCOVER", + ], + "_allowed_payment_types": ["PANENTRY", "SRC"], + "_country": "US", + "_locale": "en_US", + "_capture_mandate": { + "_billing_type": "FULL", + "_request_email": True, + "_request_phone": True, + "_request_shipping": True, + "_show_accepted_network_icons": True, + }, + "_order_information": { + "_amount_details": { + "_total_amount": f"{cart_total:.2f}", + "_currency": "USD", + } + }, + } + + try: + context, http_status, _ = UnifiedCheckoutCaptureContextApi( + settings.CYBERSOURCE_CONFIG + ).generate_unified_checkout_capture_context_with_http_info( + json.dumps(capture_context_request) + ) + if not context or http_status >= 400: + raise ApiException( + reason=f"Received {context} with HTTP status {http_status}", + ) + + # Tie generated capture context to user cart + if cart.checkout_context != context: + cart.checkout_context = context + cart.save() + + # Place hold on tickets for 10 mins + holding_expiration = timezone.now() + datetime.timedelta(minutes=10) + tickets.update( + holder=self.request.user, holding_expiration=holding_expiration + ) + + return Response({"success": True, "detail": context}) + except ApiException as e: + return Response( + { + "success": False, + "detail": f"Unable to generate capture context: {e}", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + @action(detail=False, methods=["post"]) + @update_holds + @transaction.atomic + def complete_checkout(self, request, *args, **kwargs): + """ + Complete the checkout after the user has entered their payment details + and obtained a transient token on the frontend. + + 403 implies a stale cart. + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + transient_token: + type: string + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + success: + type: boolean + --- + """ + tt = request.data.get("transient_token") + cart = get_object_or_404( + Cart.objects.prefetch_related("tickets"), owner=self.request.user + ) + + cc = cart.checkout_context + if cc is None: + return Response( + {"success": False, "detail": "Associated capture context not found"}, + status=status.HTTP_500_BAD_REQUEST, + ) + + ok, message = validate_transient_token(cc, tt) + if not ok: + # Cleanup state since the purchase failed + cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() + return Response( + {"success": False, "detail": message}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Guard against holds expiring before the capture context + tickets = cart.tickets.filter(holder=self.request.user, owner__isnull=True) + if tickets.count() != cart.tickets.count(): + return Response( + { + "success": False, + "detail": "Cart is stale, invoke /api/tickets/cart to refresh", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + _, http_status, transaction_data = TransientTokenDataApi( + settings.CYBERSOURCE_CONFIG + ).get_transaction_for_transient_token(tt) + + if not transaction_data or http_status >= 400: + raise ApiException( + reason=f"Received {transaction_data} with HTTP status {http_status}" + ) + transaction_data = json.loads(transaction_data) + except ApiException as e: + # Cleanup state since the purchase failed + cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() + + return Response( + { + "success": False, + "detail": f"Transaction failed: {e}", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + create_payment_request = {"tokenInformation": {"transientTokenJwt": tt}} + + try: + payment_response, http_status, _ = PaymentsApi( + settings.CYBERSOURCE_CONFIG + ).create_payment(json.dumps(create_payment_request)) + + if payment_response.status != "AUTHORIZED": + raise ApiException(reason="Payment response status is not authorized") + reconciliation_id = payment_response.reconciliation_id + + if not payment_response or http_status >= 400: + raise ApiException( + reason=f"Received {payment_response} with HTTP status {http_status}" + ) + except ApiException as e: + # Cleanup state since the purchase failed + cart.tickets.update(holder=None, owner=None) + cart.checkout_context = None + cart.save() + + return Response( + { + "success": False, + "detail": f"Transaction failed: {e}", + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Archive transaction data for historical purposes. + # We're explicitly using the response data over what's in self.request.user + orderInfo = transaction_data["orderInformation"] + transaction_record = TicketTransactionRecord.objects.create( + reconciliation_id=str(reconciliation_id), + total_amount=float(orderInfo["amountDetails"]["totalAmount"]), + buyer_first_name=orderInfo["billTo"]["firstName"], + buyer_last_name=orderInfo["billTo"]["lastName"], + buyer_phone=orderInfo["billTo"]["phoneNumber"], + buyer_email=orderInfo["billTo"]["email"], + ) + + # At this point, we have validated that the payment was authorized + # Give the tickets to the user + tickets = ( + cart.tickets.select_for_update() + .filter(holder=self.request.user) + .prefetch_related("carts") + ) + tickets.update( + owner=request.user, holder=None, transaction_record=transaction_record + ) + cart.tickets.clear() + for ticket in tickets: + ticket.send_confirmation_email() + + Ticket.objects.update_holds() + + cart.checkout_context = None + cart.save() + + return Response( + { + "success": True, + "detail": "Payment successful.", + } + ) + + @action(detail=True, methods=["get"]) + def qr(self, request, *args, **kwargs): + """ + Return a QR code png image representing a link to the ticket. + --- + operationId: Generate QR Code for ticket + responses: + "200": + description: Return a png image representing a QR code to the ticket. + content: + image/png: + schema: + type: binary + --- + """ + ticket = self.get_object() + qr_image = ticket.get_qr() + response = HttpResponse(content_type="image/png") + qr_image.save(response, "PNG") + return response + + @action(detail=False, methods=["get"], url_path="validate/(?P[^/.]+)") + def validate(self, request, *args, **kwargs): + """ + Validate a ticket's QR code and mark attendance. + --- + responses: + "200": + content: + application/json: + schema: + type: object + properties: + ticket: + $ref: '#/components/schemas/Ticket' + previously_scanned: + type: boolean + "400": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + --- + """ + token = kwargs.get("token") + signer = Signer() + try: + obj = signer.unsign_object(token) + except BadSignature: + return Response( + {"detail": "Invalid token"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ticket = ( + Ticket.objects.filter(id=obj.get("ticket_id")) + .select_related("event__club") + .first() + ) + if not ticket: + return Response( + {"detail": "Ticket not found"}, status=status.HTTP_400_BAD_REQUEST + ) + + is_owner = request.user == ticket.owner + is_officer = Membership.objects.filter( + person=request.user, + club=ticket.event.club, + role__lte=Membership.ROLE_OFFICER, + active=True, + ).exists() + + if not (is_owner or is_officer): + return Response( + {"detail": "You do not have permission to scan this ticket!"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if ticket.owner.id != obj.get("owner"): + return Response( + {"detail": "Stale token"}, status=status.HTTP_400_BAD_REQUEST + ) + + previously_scanned = ticket.attended + if not previously_scanned and is_officer: + ticket.attended = True + ticket.transferable = False + ticket.save() + + return Response( + { + "ticket": TicketSerializer(ticket).data, + "previously_scanned": previously_scanned, + } + ) + + def get_queryset(self): + return Ticket.objects.filter(owner=self.request.user.id) + + class MemberInviteViewSet(viewsets.ModelViewSet): """ update: diff --git a/backend/pennclubs/settings/base.py b/backend/pennclubs/settings/base.py index 2b71a8e24..cbdac66cd 100644 --- a/backend/pennclubs/settings/base.py +++ b/backend/pennclubs/settings/base.py @@ -255,3 +255,6 @@ PHONENUMBER_DB_FORMAT = "NATIONAL" PHONENUMBER_DEFAULT_REGION = "US" + +# Cybersource settings +CYBERSOURCE_CLIENT_VERSION = "0.15" diff --git a/backend/pennclubs/settings/ci.py b/backend/pennclubs/settings/ci.py index 417929077..0f2e707ed 100644 --- a/backend/pennclubs/settings/ci.py +++ b/backend/pennclubs/settings/ci.py @@ -20,3 +20,13 @@ # Allow http callback for DLA os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + +# Cybersource settings +CYBERSOURCE_CONFIG = { + "authentication_type": "http_signature", + "merchantid": "testrest", + "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda", + "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=", + "run_environment": "apitest.cybersource.com", +} +CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001" diff --git a/backend/pennclubs/settings/development.py b/backend/pennclubs/settings/development.py index e578f4cfd..90c2d1504 100644 --- a/backend/pennclubs/settings/development.py +++ b/backend/pennclubs/settings/development.py @@ -17,7 +17,7 @@ os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" # Allow requests from frontend -CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"] +CSRF_TRUSTED_ORIGINS = ["https://localhost:3001", "http://localhost:3000"] # Use console email backend during development EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" @@ -32,3 +32,13 @@ # Caching settings CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} + +# Cybersource settings +CYBERSOURCE_CONFIG = { + "authentication_type": "http_signature", + "merchantid": "testrest", + "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda", + "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=", + "run_environment": "apitest.cybersource.com", +} +CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001" diff --git a/backend/pennclubs/settings/production.py b/backend/pennclubs/settings/production.py index 2be4f552d..dcaf37299 100644 --- a/backend/pennclubs/settings/production.py +++ b/backend/pennclubs/settings/production.py @@ -74,3 +74,13 @@ "KEY_PREFIX": "django", } } + +# Cybersource settings +CYBERSOURCE_CONFIG = { + "authentication_type": "http_signature", + "merchantid": os.getenv("MERCHANT_ID"), + "merchant_keyid": os.getenv("MERCHANT_KEYID"), + "merchant_secretkey": os.getenv("MERCHANT_SECRETKEY"), + "run_environment": "api.cybersource.com", +} +CYBERSOURCE_TARGET_ORIGIN = "https://pennclubs.com" diff --git a/backend/templates/emails/ticket_confirmation.html b/backend/templates/emails/ticket_confirmation.html new file mode 100644 index 000000000..7b2991e64 --- /dev/null +++ b/backend/templates/emails/ticket_confirmation.html @@ -0,0 +1,49 @@ + + +{% extends 'emails/base.html' %} + +{% block content %} +

Thanks for using Penn Clubs!

+ +

+ {{ first_name }}, thank you for your recent purchase of a ticket to {{ name }} with ticket type {{type }}. +

+ +

+ As a reminder, the event starts at {{ start_time }} and ends at {{ end_time }}. + + +

+ +

+ Please be 10 minutes early for a smooth seating experience. +

+ +

Below is a + QR code for + your confirmation.

+ + + + +

Note: all tickets issued by us are non-refundable and non-transferable.

+ + + +

+ If you have any questions, feel free to respond to this email. +

+{% endblock %} \ No newline at end of file diff --git a/backend/tests/clubs/test_documentation.py b/backend/tests/clubs/test_documentation.py index 01865073a..67bfe8170 100644 --- a/backend/tests/clubs/test_documentation.py +++ b/backend/tests/clubs/test_documentation.py @@ -204,8 +204,8 @@ def test_openapi_docs(self): ) if "application/json" in content["content"]: json_content = content["content"]["application/json"] - self.assertTrue("schema" in json_content) try: + self.assertTrue("schema" in json_content) self.verify_schema(json_content["schema"]) except AssertionError as e: raise AssertionError( diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py new file mode 100644 index 000000000..c91266de9 --- /dev/null +++ b/backend/tests/clubs/test_ticketing.py @@ -0,0 +1,924 @@ +import json +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.db.models import ( + Count, +) +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from clubs.models import Cart, Club, Event, Ticket, TicketTransactionRecord + + +def commonSetUp(self): + self.client = APIClient() + + self.user1 = get_user_model().objects.create_user( + "jadams", "jadams@sas.upenn.edu", "test" + ) + self.user1.first_name = "John" + self.user1.last_name = "Adams" + self.user1.is_staff = True + self.user1.is_superuser = True + self.user1.save() + + self.user2 = get_user_model().objects.create_user( + "bfranklin", "bfranklin@seas.upenn.edu", "test" + ) + self.user2.first_name = "Benjamin" + self.user2.last_name = "Franklin" + self.user2.save() + + self.club1 = Club.objects.create( + code="test-club", + name="Test Club", + approved=True, + email="example@example.com", + ) + + self.event1 = Event.objects.create( + code="test-event", + club=self.club1, + name="Test Event", + start_time=timezone.now() + timezone.timedelta(days=2), + end_time=timezone.now() + timezone.timedelta(days=3), + ) + + self.ticket_totals = [ + {"type": "normal", "count": 20, "price": 15.0}, + {"type": "premium", "count": 10, "price": 30.0}, + ] + + self.tickets1 = [ + Ticket.objects.create(type="normal", event=self.event1, price=15.0) + for _ in range(20) + ] + self.tickets2 = [ + Ticket.objects.create(type="premium", event=self.event1, price=30.0) + for _ in range(10) + ] + + +class TicketEventTestCase(TestCase): + """ + Test cases related to the methods on the EventViewSet + that correspond to the ticketing project: + + tickets (get), tickets (put), buyers, add_to_cart, remove_from_cart + """ + + def setUp(self): + commonSetUp(self) + + def test_create_ticket_offerings(self): + self.client.login(username=self.user1.username, password="test") + qts = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + } + + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + qts, + format="json", + ) + + aggregated_tickets = list( + Ticket.objects.filter(event=self.event1, type__contains="_") + .values("type", "price") + .annotate(count=Count("id")) + ) + for t1, t2 in zip(qts["quantities"], aggregated_tickets): + self.assertEqual(t1["type"], t2["type"]) + self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.02) + self.assertEqual(t1["count"], t2["count"]) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + def test_create_ticket_offerings_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + qts = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + } + + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + qts, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + + def test_create_ticket_offerings_bad_data(self): + self.client.login(username=self.user1.username, password="test") + bad_data = [ + { + # Bad toplevel field + "quant1t13s": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + }, + { + "quantities": [ + # Negative price + {"type": "_normal", "count": 20, "price": -10}, + {"type": "_premium", "count": 10, "price": -20}, + ] + }, + { + "quantities": [ + # Bad field members + {"abcd": "_normal", "abcde": 20, "price": -10}, + ] + }, + ] + + for data in bad_data: + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + data, + format="json", + ) + self.assertIn(resp.status_code, [400], resp.content) + self.assertEqual(Ticket.objects.filter(type__contains="_").count(), 0, data) + + def test_get_tickets_information_no_tickets(self): + # Delete all the tickets + Ticket.objects.all().delete() + + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + data = resp.json() + self.assertEqual(data["totals"], [], data["totals"]) + self.assertEqual(data["available"], [], data["available"]) + + def test_get_tickets_information(self): + # Buy all normal tickets + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + data = resp.json() + self.assertEqual(data["totals"], self.ticket_totals, data["totals"]) + self.assertEqual( + data["available"], + # Only premium tickets available + [t for t in self.ticket_totals if t["type"] == "premium"], + data["available"], + ) + + def test_get_tickets_buyers(self): + self.client.login(username=self.user1.username, password="test") + + # Buy all normal tickets + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)), + ) + + data = resp.json() + # Assert ownership correctly determined + for owned_ticket in data["buyers"]: + self.assertEqual(owned_ticket["owner_id"], self.user1.id) + + def test_get_tickets_buyers_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)), + ) + + self.assertEqual(resp.status_code, 403, resp) + + def test_add_to_cart(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + def test_add_to_cart_twice_accumulates(self): + self.client.login(username=self.user1.username, password="test") + + # Adding 3 tickets twice + for _ in range(2): + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse( + "club-events-add-to-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 6, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 4, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 2, cart.tickets) + + def test_add_to_cart_order_limit_exceeded(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 200}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn( + "Order exceeds the maximum ticket limit of", resp.data["detail"], resp.data + ) + + def test_add_to_cart_tickets_unavailable(self): + self.client.login(username=self.user1.username, password="test") + + # Delete all but 1 ticket + for t in list(Ticket.objects.all())[:-1]: + t.delete() + + # Try to add two tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.data) + self.assertIn( + "Not enough tickets of type normal left!", resp.data["detail"], resp.data + ) + + def test_remove_from_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + # Remove all but one from normal + tickets_to_remove = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + + resp = self.client.post( + reverse( + "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_remove, + format="json", + ) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.filter(type="normal").count(), 1, cart.tickets) + + def test_remove_from_cart_extra(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + # Remove more than what exists...still ok. + tickets_to_remove = { + "quantities": [ + {"type": "normal", "count": 200}, + {"type": "premium", "count": 100}, + ] + } + resp = self.client.post( + reverse( + "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_remove, + format="json", + ) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 0, cart.tickets) + + +@dataclass +class MockPaymentResponse: + status: str = "AUTHORIZED" + reconciliation_id: str = "abced" + + +@contextmanager +def mock_cybersource_apis(): + """Mock cybersource APIs and validate_transient_token""" + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context, patch( + ".".join( + [ + "CyberSource", + "TransientTokenDataApi", + "get_transaction_for_transient_token", + ] + ) + ) as fake_get_transaction, patch( + ".".join( + [ + "CyberSource", + "PaymentsApi", + "create_payment", + ] + ) + ) as fake_create_payment, patch( + "clubs.views.validate_transient_token" + ) as fake_validate_tt: + fake_validate_tt.return_value = (True, "") + fake_cap_context.return_value = "abcde", 200, None + fake_get_transaction.return_value = ( + "", + 200, + json.dumps( + { + "orderInformation": { + "amountDetails": { + "totalAmount": 20, + }, + "billTo": { + "firstName": "Rohan", + "lastName": "Gupta", + "phoneNumber": "3021239234", + "email": "r@g.com", + }, + } + } + ), + ) + fake_create_payment.return_value = MockPaymentResponse(), 200, "" + yield ( + fake_cap_context, + fake_get_transaction, + fake_create_payment, + fake_validate_tt, + ) + + +class TicketTestCase(TestCase): + """ + Test cases related to the methods on the TicketViewSet + that correspond to the ticketing project: + + get, list, initiate_checkout, complete_checkout + """ + + def setUp(self): + commonSetUp(self) + + def test_get_ticket_owned_by_me(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + + # Fail to get when not owned + resp = self.client.get( + reverse("tickets-detail", args=(ticket.id,)), format="json" + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Succeed when owned + ticket.owner = self.user1 + ticket.save() + resp = self.client.get( + reverse("tickets-detail", args=(ticket.id,)), format="json" + ) + data = resp.json() + self.assertEqual(resp.status_code, 200, resp.content) + + # Test the serializer API + for field in ["price", "id", "type", "owner", "event"]: + self.assertIn(field, data, data) + + def test_list_tickets_owned_by_me(self): + self.client.login(username=self.user1.username, password="test") + + # Fail to get when not owned + resp = self.client.get(reverse("tickets-list"), format="json") + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json(), [], resp) + + # List all 5 tickets when owned + for ticket in self.tickets1[:5]: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get(reverse("tickets-list"), format="json") + data = resp.json() + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(data), 5, data) + + def test_get_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # None are sold out + self.assertEqual(len(data["tickets"]), 5, data) + for t1, t2 in zip(data["tickets"], tickets_to_add): + self.assertEqual(t1["id"], str(t2.id)) + self.assertEqual(data["sold_out"], 0, data) + + def test_get_cart_replacement_required(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # Sell the first two + for selling_ticket in tickets_to_add[:2]: + selling_ticket.owner = self.user2 + selling_ticket.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # The cart still has 5 tickets: just replaced with available ones + self.assertEqual(len(data["tickets"]), 5, data) + self.assertEqual(data["sold_out"], 0, data) + + in_cart = set(map(lambda t: t["id"], data["tickets"])) + to_add = set(map(lambda t: str(t.id), tickets_to_add)) + + # 3 tickets are the same + self.assertEqual(len(in_cart & to_add), 3, in_cart | to_add) + + def test_get_cart_replacement_required_sold_out(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # There are 5 tickets in the cart. We will sell + # all but 3 tickets of this type to someone + # This should force 2 tickets reporting as sold out + for selling_ticket in self.tickets1[:-3]: + selling_ticket.owner = self.user2 + selling_ticket.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # The cart now has 3 tickets + self.assertEqual(len(data["tickets"]), 3, data) + + # 2 tickets have been sold out + self.assertEqual(data["sold_out"], 2, data) + + in_cart = set(map(lambda t: t["id"], data["tickets"])) + to_add = set(map(lambda t: str(t.id), tickets_to_add)) + + # 0 tickets are the same (we sell all but last 3) + self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add) + + def test_initiate_checkout(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate checkout + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Capture context should be tied to cart + cart = Cart.objects.filter(owner=self.user1).first() + self.assertIsNotNone(cart.checkout_context) + self.assertEqual(cart.checkout_context, cap_context_data) + + # Tickets should be held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets) + self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets) + + def test_initiate_concurrent_checkouts(self): + self.client.login(username=self.user1.username, password="test") + + # Add tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate first checkout + cap_context_data = "abc" + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_1 = cart.checkout_context + + # Initiate second checkout + cap_context_data = "def" # simulate capture context changing between checkouts + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_2 = cart.checkout_context + + # Stored capture context should change between checkouts + self.assertNotEqual(cap_context_1, cap_context_2) + + def test_initiate_checkout_fails_with_empty_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Assert non existent cart + cart, created = Cart.objects.get_or_create(owner=self.user1) + self.assertTrue(created) + + # Initiate checkout, fail with 400 + # NOTE: If the cart does not exist, we will have a 404 + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = "abcde", 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertEquals(resp.status_code, 400, resp.content) + + # Tickets are not held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertFalse(held_tickets.exists()) + + def test_initiate_checkout_stale_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # In the meantime, someone snipes a ticket we added by holding + sniped_ticket = self.tickets1[0] + sniped_ticket.holder = self.user2 + sniped_ticket.save() + + # Initiate checkout for the first time + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = "abcde", 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn("Cart is stale", resp.data["detail"], resp.data) + + # Tickets are not held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + # Ok, so now we call /api/tickets/cart to refresh + resp = self.client.get(reverse("tickets-cart"), format="json") + + # Initiate checkout again...this should work + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Tickets are held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertNotIn(sniped_ticket, held_tickets, held_tickets) + self.assertEqual(held_tickets.count(), 5, held_tickets) + + def test_complete_checkout(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + with mock_cybersource_apis(): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertIn("Payment successful", resp.data["detail"], resp.data) + + # Ownership transfered + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 2, owned_tickets) + + # Cart empty + user_cart = Cart.objects.get(owner=self.user1) + self.assertEqual(user_cart.tickets.count(), 0, user_cart) + + # Tickets held is 0 + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + # Transaction record created + TicketTransactionRecord.objects.filter( + reconciliation_id=MockPaymentResponse().reconciliation_id + ).exists() + + def test_complete_checkout_stale_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis(): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + # Make holds expire prematurely, creating a stale cart + for ticket in held_tickets: + ticket.holding_expiration = timezone.now() - timedelta(minutes=1) + ticket.save() + + # Invoking this API endpoint causes all holds to be expired + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn("Cart is stale", resp.data["detail"], resp.content) + + # Ownership not transfered + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + def test_complete_checkout_validate_token_fails(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis() as (_, _, _, fake_validate_token): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + fake_validate_token.return_value = (False, "Validation failed") + + # Try to complete + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + + # Fails because token validation failed + self.assertEqual(resp.status_code, 500, resp.content) + self.assertIn("Validation failed", resp.data["detail"], resp.content) + + # Ownership not transfered + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + # Hold cancelled + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + def test_complete_checkout_cybersource_fails(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis() as (_, fake_create_payment, _, _): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + fake_create_payment.return_value = ( + MockPaymentResponse(status="UNAUTHORIZED"), + 400, + "", + ) + + # Try to complete + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + + # Fails because cybersource fails + self.assertEqual(resp.status_code, 500, resp.content) + self.assertIn("Transaction failed", resp.data["detail"], resp.content) + self.assertIn("HTTP status 400", resp.data["detail"], resp.content) + + # Ownership not transfered + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + # Hold cancelled + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) diff --git a/frontend/components/BaseLayout.tsx b/frontend/components/BaseLayout.tsx new file mode 100644 index 000000000..f7862138a --- /dev/null +++ b/frontend/components/BaseLayout.tsx @@ -0,0 +1,51 @@ +import { PropsWithChildren } from 'react' +import { ToastContainer } from 'react-toastify' +import styled from 'styled-components' + +import AuthPrompt from '~/components/common/AuthPrompt' +import Footer from '~/components/Footer' +import Header from '~/components/Header' +import { NAV_HEIGHT, SNOW } from '~/constants' +import { GlobalStyle, RenderPageWrapper, ToastStyle } from '~/renderPage' +import { PermissionsContext } from '~/utils' +import { createBasePropFetcher } from '~/utils/getBaseProps' + +type BaseLayoutProps = Awaited< + ReturnType> +> & { + authRequired?: boolean +} + +export const Wrapper = styled.div` + min-height: calc(100vh - ${NAV_HEIGHT}); + background: ${SNOW}; +` + +export const BaseLayout: React.FC> = ({ + auth, + permissions, + children, + authRequired, +}) => { + const authError = !!(authRequired && !auth.authenticated) + return ( + <> + + +
+ {authError ? : {children}} +