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..5031e8a53 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,9 @@ pandas = "*" drf-excel = "*" numpy = "*" inflection = "*" +cybersource-rest-client-python = "*" +pyjwt = "*" +freezegun = "*" [requires] python_version = "3.11" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index cdecb8eda..e319b9e1c 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4057df7b74f8c1e641c8f0fad1954492c21eac3651768db019f8d174749a018e" + "sha256": "af92de209d2c1cbc993a93920faad16210c881c09fb904ed2495b65a42bf9516" }, "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", @@ -511,6 +517,15 @@ "markers": "python_version >= '3.8'", "version": "==3.13.4" }, + "freezegun": { + "hashes": [ + "sha256:10939b0ba0ff5adaecf3b06a5c2f73071d9678e507c5eaedb23c761d56ac774b", + "sha256:55e0fc3c84ebf0a96a5aa23ff8b53d70246479e9a68863f1fcac5a3e52f19dd6" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.4.0" + }, "gunicorn": { "hashes": [ "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0", @@ -1196,20 +1211,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 +1420,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 +1469,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 +1574,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.6'", + "markers": "python_version >= '3.8'", "version": "==2.2.1" }, "uvicorn": { @@ -1749,7 +1804,7 @@ ], "version": "==12.0" }, - "zope-interface": { + "zope.interface": { "hashes": [ "sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e", "sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf", @@ -2074,11 +2129,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_event_ticket_drop_time.py b/backend/clubs/migrations/0105_event_ticket_drop_time.py new file mode 100644 index 000000000..81ac89a68 --- /dev/null +++ b/backend/clubs/migrations/0105_event_ticket_drop_time.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-21 04:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0104_cart_checkout_context"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="ticket_drop_time", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py b/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py new file mode 100644 index 000000000..f37777a9e --- /dev/null +++ b/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0.4 on 2024-04-21 21:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0105_event_ticket_drop_time"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name="ticket", + name="transaction_record", + ), + migrations.AddField( + model_name="ticket", + name="transferable", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="tickettransactionrecord", + name="ticket", + field=models.ForeignKey( + default="", + on_delete=django.db.models.deletion.PROTECT, + related_name="transaction_records", + to="clubs.ticket", + ), + preserve_default=False, + ), + migrations.CreateModel( + name="TicketTransferRecord", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "receiver", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="received_transfers", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "sender", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_transfers", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ticket", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="transfer_records", + to="clubs.ticket", + ), + ), + ], + ), + ] diff --git a/backend/clubs/migrations/0107_ticket_attended.py b/backend/clubs/migrations/0107_ticket_attended.py new file mode 100644 index 000000000..16b0d87ce --- /dev/null +++ b/backend/clubs/migrations/0107_ticket_attended.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.4 on 2024-04-25 07:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0106_remove_ticket_transaction_record_ticket_transferable_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="attended", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/migrations/0108_ticket_buyable.py b/backend/clubs/migrations/0108_ticket_buyable.py new file mode 100644 index 000000000..514dedc89 --- /dev/null +++ b/backend/clubs/migrations/0108_ticket_buyable.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-28 17:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0107_ticket_attended"), + ] + + operations = [ + migrations.AddField( + model_name="ticket", + name="buyable", + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index b59439629..238984a2d 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1,11 +1,14 @@ +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 @@ -325,7 +328,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 +924,8 @@ 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) + ticket_drop_time = models.DateTimeField(null=True, blank=True) OTHER = 0 RECRUITMENT = 1 @@ -1774,6 +1778,174 @@ 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 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) + transferable = models.BooleanField(default=True) + attended = models.BooleanField(default=False) + # TODO: change to enum between All, Club, None + buyable = models.BooleanField(default=True) + objects = TicketManager() + + def get_qr(self): + """ + Return a QR code image linking to the ticket page + """ + if not self.owner: + return None + + url = f"https://{settings.DOMAINS[0]}/api/tickets/{self.id}" + qr_image = qrcode.make(url, 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, + ) + + +class TicketTransactionRecord(models.Model): + """ + Represents an instance of a transaction record for an ticket, used for bookkeeping + """ + + ticket = models.ForeignKey( + Ticket, related_name="transaction_records", on_delete=models.PROTECT + ) + 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 TicketTransferRecord(models.Model): + """ + Represents a transfer of ticket ownership, used for bookkeeping + """ + + ticket = models.ForeignKey( + Ticket, related_name="transfer_records", on_delete=models.PROTECT + ) + sender = models.ForeignKey( + get_user_model(), + related_name="sent_transfers", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + receiver = models.ForeignKey( + get_user_model(), + related_name="received_transfers", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + def send_confirmation_email(self): + """ + Send confirmation email to the sender and recipient of the transfer. + """ + context = { + "sender_first_name": self.sender.first_name, + "receiver_first_name": self.receiver.first_name, + "receiver_username": self.receiver.username, + "event_name": self.ticket.event.name, + "type": self.ticket.event.type, + } + + send_mail_helper( + name="ticket_transfer", + subject=f"Ticket transfer confirmation for {self.ticket.event.name}", + emails=[self.sender.email, self.receiver.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..daf103333 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", "issue_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 4a1b7be12..496b7566b 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) @@ -2809,6 +2831,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..03e6152bd 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 @@ -28,6 +38,7 @@ from django.core.management import call_command, get_commands, load_command_class from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import validate_email +from django.db import transaction from django.db.models import ( Case, CharField, @@ -35,6 +46,7 @@ DurationField, ExpressionWrapper, F, + Max, Prefetch, Q, TextField, @@ -80,6 +92,7 @@ ApplicationSubmission, Asset, Badge, + Cart, Club, ClubApplication, ClubFair, @@ -102,6 +115,9 @@ Subscribe, Tag, Testimonial, + Ticket, + TicketTransactionRecord, + TicketTransferRecord, 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] @@ -2285,220 +2345,484 @@ def get_serializer_class(self): return EventSerializer @action(detail=True, methods=["post"]) - def upload(self, request, *args, **kwargs): + @transaction.atomic + @update_holds + def add_to_cart(self, request, *args, **kwargs): """ - Upload a picture for the event. + Add a certain number of tickets to the cart --- requestBody: content: - multipart/form-data: + application/json: schema: type: object properties: - file: - type: object - format: binary + quantities: + type: array + items: + type: object + properties: + type: + type: string + count: + type: integer responses: "200": - description: Returned if the file was successfully uploaded. - content: &upload_resp + content: application/json: schema: - type: object - properties: + type: object + properties: detail: type: string - description: The status of the file upload. - url: + success: + type: boolean + "403": + content: + application/json: + schema: + type: object + properties: + detail: type: string - description: > - The URL of the newly uploaded file. - Only exists if the file was successfully - uploaded. - "400": - description: Returned if there was an error while uploading the file. - content: *upload_resp + success: + type: boolean --- """ - event = Event.objects.get(id=kwargs["id"]) - self.check_object_permissions(request, event) + event = self.get_object() + cart, _ = Cart.objects.get_or_create(owner=self.request.user) - resp = upload_endpoint_helper(request, Event, "image", "image", pk=event.pk) + # Cannot add tickets that haven't dropped yet + if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: + return Response( + {"detail": "Ticket drop time has not yet elapsed"}, + status=status.HTTP_403_FORBIDDEN, + ) - # if image uploaded, create thumbnail - if status.is_success(resp.status_code): - event.create_thumbnail(request) + quantities = request.data.get("quantities") + if not quantities: + return Response( + {"detail": "Quantities must be specified", "success": False}, + status=status.HTTP_400_BAD_REQUEST, + ) - return resp + num_requested = sum(item["count"] for item in quantities) + num_carted = cart.tickets.filter(event=event).count() - def create(self, request, *args, **kwargs): + 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}.", + "success": False, + }, + 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, + buyable=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": f"Successfully added {count} to cart", "success": True} + ) + + @action(detail=True, methods=["post"]) + @transaction.atomic + @update_holds + def remove_from_cart(self, request, *args, **kwargs): """ - Has the option to create a recurring event by specifying an offset and an - end date. Additionaly, do not let non-superusers create events with the - `FAIR` type through the API. + Remove a certain type/number of tickets from the cart --- requestBody: content: application/json: schema: - allOf: - - $ref: "#/components/schemas/EventWrite" - - type: object - properties: - is_recurring: - type: boolean - description: > - If this value is set, then make - recurring events instead of a single event. - offset: - type: number - description: > - The offset between recurring events, in days. - Only specify this if the event is recurring. - end_date: + 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 - format: date-time - description: > - The date when all items in the recurring event - series should end. Only specify this if the - event is recurring. - + success: + type: boolean --- """ - # get event type - type = request.data.get("type", 0) - if type == Event.FAIR and not self.request.user.is_superuser: - raise DRFValidationError( - detail="Approved activities fair events have already been created. " - "See above for events to edit, and " - f"please email {settings.FROM_EMAIL} if this is en error." + event = self.get_object() + quantities = request.data.get("quantities") + if not quantities: + return Response( + { + "detail": "Quantities must be specified", + "success": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if not all(isinstance(item, dict) for item in quantities): + return Response( + { + "detail": "Quantities must be a list of dictionaries", + "success": False, + }, + status=status.HTTP_400_BAD_REQUEST, ) + cart = get_object_or_404(Cart, owner=self.request.user) - # handle recurring events - if request.data.get("is_recurring", None) is not None: - parent_recurring_event = RecurringEvent.objects.create() - event_data = request.data.copy() - start_time = parse(event_data.pop("start_time")) - end_time = parse(event_data.pop("end_time")) - offset = event_data.pop("offset") - end_date = parse(event_data.pop("end_date")) - event_data.pop("is_recurring") + for item in quantities: + type = item["type"] + count = item["count"] + tickets_to_remove = cart.tickets.filter(type=type, event=event) - result_data = [] - while start_time < end_date: - event_data["start_time"] = start_time - event_data["end_time"] = end_time - event_serializer = EventWriteSerializer( - data=event_data, context={"request": request, "view": self} - ) - if event_serializer.is_valid(): - ev = event_serializer.save() - ev.parent_recurring_event = parent_recurring_event - result_data.append(ev) - else: - return Response( - event_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + # 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]) - start_time = start_time + datetime.timedelta(days=offset) - end_time = end_time + datetime.timedelta(days=offset) + cart.save() + return Response( + {"detail": f"Successfully removed {count} from cart", "success": True} + ) - Event.objects.filter(pk__in=[e.pk for e in result_data]).update( - parent_recurring_event=parent_recurring_event - ) + @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 + attended: + type: boolean + --- + """ + tickets = Ticket.objects.filter(event=self.get_object()).annotate( + fullname=Concat("owner__first_name", Value(" "), "owner__last_name") + ) - return Response(EventSerializer(result_data, many=True).data) + buyers = tickets.filter(owner__isnull=False).values( + "fullname", "id", "owner_id", "type", "attended", "owner__email" + ) - return super().create(request, *args, **kwargs) + return Response({"buyers": buyers}) - def destroy(self, request, *args, **kwargs): + @action(detail=True, methods=["get"]) + def tickets(self, request, *args, **kwargs): """ - Do not let non-superusers delete events with the FAIR type through the API. + 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) - if event.type == Event.FAIR and not self.request.user.is_superuser: - raise DRFValidationError( - detail="You cannot delete activities fair events. " - f"If you would like to do this, email {settings.FROM_EMAIL}." - ) - - return super().destroy(request, *args, **kwargs) + if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: + return Response({"totals": [], "available": []}) - def get_queryset(self): - qs = Event.objects.all() - is_club_specific = self.kwargs.get("club_code") is not None - if is_club_specific: - qs = qs.filter(club__code=self.kwargs["club_code"]) - qs = qs.filter( - Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True), - club__archived=False, - ) - else: - qs = qs.filter( - Q(club__approved=True) - | Q(type=Event.FAIR) - | Q(club__ghost=True) - | Q(club__isnull=True), - Q(club__isnull=True) | Q(club__archived=False), - ) + # 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, buyable=True) + .values("type") + .annotate(price=Max("price")) + .annotate(count=Count("type")) + .order_by("type") + ) + return Response({"totals": list(totals), "available": list(available)}) - return ( - qs.select_related("club", "creator") - .prefetch_related( - Prefetch( - "club__badges", - queryset=( - Badge.objects.filter( - fair__id=self.request.query_params.get("fair") + @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 + format: float + required: false + transferable: + type: boolean + buyable: + type: boolean + required: false + order_limit: + type: int + required: false + drop_time: + type: string + format: date-time + 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 + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + --- + """ + event = self.get_object() + + # Tickets can't be edited after they've dropped + if event.ticket_drop_time and timezone.now() > event.ticket_drop_time: + return Response( + {"detail": "Tickets cannot be edited after they have dropped"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Tickets can't be edited after they've been sold or held + if ( + Ticket.objects.filter(event=event) + .filter(Q(owner__isnull=False) | Q(holder__isnull=False)) + .exists() + ): + return Response( + { + "detail": "Tickets cannot be edited after they have been " + "sold or checked out" + }, + status=status.HTTP_403_FORBIDDEN, + ) + + 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" ) - if "fair" in self.request.query_params - else Badge.objects.filter(visible=True) - ), + }, + 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), + transferable=item.get("transferable", True), + buyable=item.get("buyable", True), ) - .order_by("start_time") - ) + for item in quantities + for _ in range(item["count"]) + ] + Ticket.objects.bulk_create(tickets) -class EventViewSet(ClubEventViewSet): - """ - list: - Return a list of events for the entire site. + order_limit = request.data.get("order_limit", None) + if order_limit is not None: + event.ticket_order_limit = order_limit + event.save() - retrieve: - Return a single event. + drop_time = request.data.get("drop_time", None) + if drop_time is not None: + try: + drop_time = datetime.datetime.strptime(drop_time, "%Y-%m-%dT%H:%M:%S%z") + except ValueError as e: + return Response( + {"detail": f"Invalid drop time: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) - destroy: - Delete an event. - """ + if drop_time < timezone.now(): + return Response( + {"detail": "Specified drop time has already elapsed"}, + status=status.HTTP_400_BAD_REQUEST, + ) - def get_operation_id(self, **kwargs): - return f"{kwargs['operId']} (Global)" + event.ticket_drop_time = drop_time + event.save() - @action(detail=False, methods=["get"]) - def fair(self, request, *args, **kwargs): + return Response({"detail": "Successfully created tickets"}) + + @action(detail=True, methods=["post"]) + @transaction.atomic + @update_holds + def issue_tickets(self, request, *args, **kwargs): """ - Get the minimal information required for a fair directory listing. - Groups by the start date of the event, and then the event category. - Each event's club must have an associated fair badge in order to be displayed. + Issue tickets that have already been created to users in bulk. --- - parameters: - - name: date - in: query - required: false - description: > - A date in YYYY-MM-DD format. - If specified, will preview how this endpoint looked on the specified - date. - type: string - - name: fair - in: query - required: false - description: > - A fair id. If specified, will preview how this endpoint will look for - that fair. Overrides the date field if both are specified. - type: number + requestBody: + content: + application/json: + schema: + type: object + properties: + tickets: + type: array + items: + type: object + properties: + username: + type: string + ticket_type: + type: string + responses: "200": content: @@ -2506,204 +2830,570 @@ def fair(self, request, *args, **kwargs): schema: type: object properties: - fair: - type: object - $ref: "#/components/schemas/ClubFair" - events: + detail: + type: string + "400": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + errors: type: array items: - type: object - properties: - start_time: - type: string - format: date-time - end_time: - type: string - format: date-time - events: - type: array - items: - type: object - properties: - category: - type: string - events: - type: array - items: - type: object - properties: - name: - type: string - code: - type: string + type: string --- """ - # accept custom date for preview rendering - date = request.query_params.get("date") - if date in {"null", "undefined"}: - date = None - if date: - date = parse(date) + event = self.get_object() - # accept custom fair for preview rendering - fair = request.query_params.get("fair") - if fair in {"null", "undefined"}: - fair = None - if fair: - fair = int(re.sub(r"\D", "", fair)) + tickets = request.data.get("tickets", []) - # cache the response for this endpoint with short timeout - if date is None: - key = f"events:fair:directory:{request.user.is_authenticated}:{fair}" - cached = cache.get(key) - if cached: - return Response(cached) - else: - key = None + if not tickets: + return Response( + {"detail": "tickets must be specified", "errors": []}, + status=status.HTTP_400_BAD_REQUEST, + ) - # lookup fair from id - if fair: - fair = get_object_or_404(ClubFair, id=fair) - else: - fair = ( - ClubFair.objects.filter( - end_time__gte=timezone.now() - datetime.timedelta(minutes=30) + for item in tickets: + if not item.get("username") or not item.get("ticket_type"): + return Response( + { + "detail": "Specify username and ticket type to issue tickets", + "errors": [], + }, + status=status.HTTP_400_BAD_REQUEST, ) - .order_by("start_time") - .first() - ) - if not date and fair is not None: - date = fair.start_time.date() - now = date or timezone.now() - events = Event.objects.filter( - type=Event.FAIR, club__badges__purpose="fair", club__badges__fair=fair - ) + usernames = [item.get("username") for item in tickets] + ticket_types = [item.get("ticket_type") for item in tickets] - # filter event range based on the fair times or provide a reasonable fallback - if fair is None: - events = events.filter( - start_time__lte=now + datetime.timedelta(days=7), - end_time__gte=now - datetime.timedelta(days=1), + # Validate all usernames + invalid_usernames = set(usernames) - set( + get_user_model() + .objects.filter(username__in=usernames) + .values_list("username", flat=True) + ) + if invalid_usernames: + return Response( + { + "detail": "Invalid usernames", + "errors": sorted(list(invalid_usernames)), + }, + status=status.HTTP_400_BAD_REQUEST, ) - else: - events = events.filter( - start_time__lte=fair.end_time, end_time__gte=fair.start_time + + # Validate all ticket types + invalid_types = set(ticket_types) - set( + Ticket.objects.filter(event=event).values_list("type", flat=True) + ) + if invalid_types: + return Response( + { + "detail": "Invalid ticket classes", + "errors": sorted(list(invalid_types)), + }, + status=status.HTTP_400_BAD_REQUEST, ) - events = events.values_list( - "start_time", "end_time", "club__name", "club__code", "club__badges__label" - ).distinct() - output = {} - for event in events: - # group by start date - ts = int(event[0].replace(second=0, microsecond=0).timestamp()) - if ts not in output: - output[ts] = { - "start_time": event[0], - "end_time": event[1], - "events": {}, - } + tickets = [] + for ticket_type, num_requested in collections.Counter(ticket_types).items(): + available_tickets = Ticket.objects.select_for_update( + skip_locked=True + ).filter( + event=event, type=ticket_type, owner__isnull=True, holder__isnull=True + )[:num_requested] - # group by category - category = event[4] - if category not in output[ts]["events"]: - output[ts]["events"][category] = { - "category": category, - "events": [], - } + if available_tickets.count() < num_requested: + return Response( + { + "detail": ( + f"Not enough tickets available for type: {ticket_type}" + ), + "errors": [], + }, + status=status.HTTP_400_BAD_REQUEST, + ) - output[ts]["events"][category]["events"].append( - {"name": event[2], "code": event[3]} - ) - for item in output.values(): - item["events"] = list( - sorted(item["events"].values(), key=lambda cat: cat["category"]) + tickets.extend(available_tickets) + + # Assign tickets to users + transaction_records = [] + + for username, ticket_type in zip(usernames, ticket_types): + user = get_user_model().objects.filter(username=username).first() + ticket = next( + ticket + for ticket in tickets + if ticket.type == ticket_type and ticket.owner is None ) - for category in item["events"]: - category["events"] = list( - sorted(category["events"], key=lambda e: e["name"].casefold()) + ticket.owner = user + ticket.holder = None + + transaction_records.append( + TicketTransactionRecord( + ticket=ticket, + total_amount=0.0, + buyer_first_name=user.first_name, + buyer_last_name=user.last_name, + buyer_email=user.email, ) + ) - output = list(sorted(output.values(), key=lambda cat: cat["start_time"])) - final_output = { - "events": output, - "fair": ClubFairSerializer(instance=fair).data, - } - if key: - cache.set(key, final_output, 60 * 5) + Ticket.objects.bulk_update(tickets, ["owner", "holder"]) + Ticket.objects.update_holds() - return Response(final_output) + TicketTransactionRecord.objects.bulk_create(transaction_records) - @action(detail=False, methods=["get"]) - def owned(self, request, *args, **kwargs): + for ticket in tickets: + ticket.send_confirmation_email() + + return Response( + {"success": True, "detail": f"Issued {len(tickets)} tickets", "errors": []} + ) + + @action(detail=True, methods=["post"]) + def upload(self, request, *args, **kwargs): """ - Return all events that the user has officer permissions over. + Upload a picture for the event. + --- + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: object + format: binary + responses: + "200": + description: Returned if the file was successfully uploaded. + content: &upload_resp + application/json: + schema: + type: object + properties: + detail: + type: string + description: The status of the file upload. + url: + type: string + description: > + The URL of the newly uploaded file. + Only exists if the file was successfully + uploaded. + "400": + description: Returned if there was an error while uploading the file. + content: *upload_resp + --- """ - if not request.user.is_authenticated: - return Response([]) + event = Event.objects.get(id=kwargs["id"]) + self.check_object_permissions(request, event) - now = timezone.now() + resp = upload_endpoint_helper(request, Event, "image", "image", pk=event.pk) - events = self.filter_queryset(self.get_queryset()).filter( - club__membership__person=request.user, - club__membership__role__lte=Membership.ROLE_OFFICER, - start_time__gte=now, - ) + # if image uploaded, create thumbnail + if status.is_success(resp.status_code): + event.create_thumbnail(request) - return Response(EventSerializer(events, many=True).data) + return resp + def create(self, request, *args, **kwargs): + """ + Has the option to create a recurring event by specifying an offset and an + end date. Additionaly, do not let non-superusers create events with the + `FAIR` type through the API. + --- + requestBody: + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/EventWrite" + - type: object + properties: + is_recurring: + type: boolean + description: > + If this value is set, then make + recurring events instead of a single event. + offset: + type: number + description: > + The offset between recurring events, in days. + Only specify this if the event is recurring. + end_date: + type: string + format: date-time + description: > + The date when all items in the recurring event + series should end. Only specify this if the + event is recurring. -class TestimonialViewSet(viewsets.ModelViewSet): - """ - list: - Return a list of testimonials for this club. + --- + """ + # get event type + type = request.data.get("type", 0) + if type == Event.FAIR and not self.request.user.is_superuser: + raise DRFValidationError( + detail="Approved activities fair events have already been created. " + "See above for events to edit, and " + f"please email {settings.FROM_EMAIL} if this is en error." + ) - create: - Create a new testimonial for this club. + # handle recurring events + if request.data.get("is_recurring", None) is not None: + parent_recurring_event = RecurringEvent.objects.create() + event_data = request.data.copy() + start_time = parse(event_data.pop("start_time")) + end_time = parse(event_data.pop("end_time")) + offset = event_data.pop("offset") + end_date = parse(event_data.pop("end_date")) + event_data.pop("is_recurring") - update: - Update a testimonial for this club. - All fields must be specified. + result_data = [] + while start_time < end_date: + event_data["start_time"] = start_time + event_data["end_time"] = end_time + event_serializer = EventWriteSerializer( + data=event_data, context={"request": request, "view": self} + ) + if event_serializer.is_valid(): + ev = event_serializer.save() + ev.parent_recurring_event = parent_recurring_event + result_data.append(ev) + else: + return Response( + event_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) - partial_update: - Update a testimonial for this club. - Specify only the fields you want to update. + start_time = start_time + datetime.timedelta(days=offset) + end_time = end_time + datetime.timedelta(days=offset) - retrieve: - Retrieve a single testimonial. + Event.objects.filter(pk__in=[e.pk for e in result_data]).update( + parent_recurring_event=parent_recurring_event + ) - destroy: - Delete a testimonial. - """ + return Response(EventSerializer(result_data, many=True).data) - serializer_class = TestimonialSerializer - permission_classes = [ClubItemPermission | IsSuperuser] + return super().create(request, *args, **kwargs) + + @update_holds + def destroy(self, request, *args, **kwargs): + """ + Do not let non-superusers delete events with the FAIR type through the API. + Check if there are bought or held tickets before deletion. + """ + event = self.get_object() + + if event.type == Event.FAIR and not self.request.user.is_superuser: + raise DRFValidationError( + detail="You cannot delete activities fair events. " + f"If you would like to do this, email {settings.FROM_EMAIL}." + ) + + if ( + Ticket.objects.filter(event=event) + .filter(Q(owner__isnull=False) | Q(holder__isnull=False)) + .exists() + ): + raise DRFValidationError( + detail=( + "This event cannot be deleted because there are tickets " + "that have been bought or are being checked out." + ) + ) + + return super().destroy(request, *args, **kwargs) def get_queryset(self): - return Testimonial.objects.filter(club__code=self.kwargs["club_code"]) + qs = Event.objects.all() + is_club_specific = self.kwargs.get("club_code") is not None + if is_club_specific: + qs = qs.filter(club__code=self.kwargs["club_code"]) + qs = qs.filter( + Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True), + club__archived=False, + ) + else: + qs = qs.filter( + Q(club__approved=True) + | Q(type=Event.FAIR) + | Q(club__ghost=True) + | Q(club__isnull=True), + Q(club__isnull=True) | Q(club__archived=False), + ) + + return ( + qs.select_related("club", "creator") + .prefetch_related( + Prefetch( + "club__badges", + queryset=( + Badge.objects.filter( + fair__id=self.request.query_params.get("fair") + ) + if "fair" in self.request.query_params + else Badge.objects.filter(visible=True) + ), + ), + "tickets", + ) + .order_by("start_time") + ) -class QuestionAnswerViewSet(viewsets.ModelViewSet): +class EventViewSet(ClubEventViewSet): """ list: - Return a list of questions and answers for this club. - - create: - Create a new question for this club. - - update: - Change the question or the answer for this club. + Return a list of events for the entire site. retrieve: - Return a single testimonial. + Return a single event. destroy: - Delete a testimonial. - """ + Delete an event. - serializer_class = QuestionAnswerSerializer - permission_classes = [QuestionAnswerPermission | IsSuperuser] + fair: + Get information about a fair listing + + owned: + Return all events that the user has officer permissions over. + """ + + def get_operation_id(self, **kwargs): + return f"{kwargs['operId']} (Global)" + + @action(detail=False, methods=["get"]) + def fair(self, request, *args, **kwargs): + """ + Get the minimal information required for a fair directory listing. + Groups by the start date of the event, and then the event category. + Each event's club must have an associated fair badge in order to be displayed. + --- + parameters: + - name: date + in: query + required: false + description: > + A date in YYYY-MM-DD format. + If specified, will preview how this endpoint looked on the specified + date. + type: string + - name: fair + in: query + required: false + description: > + A fair id. If specified, will preview how this endpoint will look for + that fair. Overrides the date field if both are specified. + type: number + responses: + "200": + content: + application/json: + schema: + type: object + properties: + fair: + type: object + $ref: "#/components/schemas/ClubFair" + events: + type: array + items: + type: object + properties: + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + events: + type: array + items: + type: object + properties: + category: + type: string + events: + type: array + items: + type: object + properties: + name: + type: string + code: + type: string + --- + """ + # accept custom date for preview rendering + date = request.query_params.get("date") + if date in {"null", "undefined"}: + date = None + if date: + date = parse(date) + + # accept custom fair for preview rendering + fair = request.query_params.get("fair") + if fair in {"null", "undefined"}: + fair = None + if fair: + fair = int(re.sub(r"\D", "", fair)) + + # cache the response for this endpoint with short timeout + if date is None: + key = f"events:fair:directory:{request.user.is_authenticated}:{fair}" + cached = cache.get(key) + if cached: + return Response(cached) + else: + key = None + + # lookup fair from id + if fair: + fair = get_object_or_404(ClubFair, id=fair) + else: + fair = ( + ClubFair.objects.filter( + end_time__gte=timezone.now() - datetime.timedelta(minutes=30) + ) + .order_by("start_time") + .first() + ) + if not date and fair is not None: + date = fair.start_time.date() + + now = date or timezone.now() + events = Event.objects.filter( + type=Event.FAIR, club__badges__purpose="fair", club__badges__fair=fair + ) + + # filter event range based on the fair times or provide a reasonable fallback + if fair is None: + events = events.filter( + start_time__lte=now + datetime.timedelta(days=7), + end_time__gte=now - datetime.timedelta(days=1), + ) + else: + events = events.filter( + start_time__lte=fair.end_time, end_time__gte=fair.start_time + ) + + events = events.values_list( + "start_time", "end_time", "club__name", "club__code", "club__badges__label" + ).distinct() + output = {} + for event in events: + # group by start date + ts = int(event[0].replace(second=0, microsecond=0).timestamp()) + if ts not in output: + output[ts] = { + "start_time": event[0], + "end_time": event[1], + "events": {}, + } + + # group by category + category = event[4] + if category not in output[ts]["events"]: + output[ts]["events"][category] = { + "category": category, + "events": [], + } + + output[ts]["events"][category]["events"].append( + {"name": event[2], "code": event[3]} + ) + for item in output.values(): + item["events"] = list( + sorted(item["events"].values(), key=lambda cat: cat["category"]) + ) + for category in item["events"]: + category["events"] = list( + sorted(category["events"], key=lambda e: e["name"].casefold()) + ) + + output = list(sorted(output.values(), key=lambda cat: cat["start_time"])) + final_output = { + "events": output, + "fair": ClubFairSerializer(instance=fair).data, + } + if key: + cache.set(key, final_output, 60 * 5) + + return Response(final_output) + + @action(detail=False, methods=["get"]) + def owned(self, request, *args, **kwargs): + """ + Return all events that the user has officer permissions over. + """ + if not request.user.is_authenticated: + return Response([]) + + now = timezone.now() + + events = self.filter_queryset(self.get_queryset()).filter( + club__membership__person=request.user, + club__membership__role__lte=Membership.ROLE_OFFICER, + start_time__gte=now, + ) + + return Response(EventSerializer(events, many=True).data) + + +class TestimonialViewSet(viewsets.ModelViewSet): + """ + list: + Return a list of testimonials for this club. + + create: + Create a new testimonial for this club. + + update: + Update a testimonial for this club. + All fields must be specified. + + partial_update: + Update a testimonial for this club. + Specify only the fields you want to update. + + retrieve: + Retrieve a single testimonial. + + destroy: + Delete a testimonial. + """ + + serializer_class = TestimonialSerializer + permission_classes = [ClubItemPermission | IsSuperuser] + + def get_queryset(self): + return Testimonial.objects.filter(club__code=self.kwargs["club_code"]) + + +class QuestionAnswerViewSet(viewsets.ModelViewSet): + """ + list: + Return a list of questions and answers for this club. + + create: + Create a new question for this club. + + update: + Change the question or the answer for this club. + + retrieve: + Return a single testimonial. + + destroy: + Delete a testimonial. + """ + + serializer_class = QuestionAnswerSerializer + permission_classes = [QuestionAnswerPermission | IsSuperuser] def get_queryset(self): club_code = self.kwargs["club_code"] @@ -3781,36 +4471,340 @@ def post(self, request): .get("leave_time", None) ) - meeting = ( - ZoomMeetingVisit.objects.filter( - meeting_id=meeting_id, - participant_id=participant_id, - leave_time__isnull=True, - ) - .order_by("-created_at") - .first() + meeting = ( + ZoomMeetingVisit.objects.filter( + meeting_id=meeting_id, + participant_id=participant_id, + leave_time__isnull=True, + ) + .order_by("-created_at") + .first() + ) + if meeting is not None: + meeting.leave_time = leave_time + meeting.save() + event_id = meeting.event.id + + if event_id is not None: + channel_layer = get_channel_layer() + if channel_layer is not None: + async_to_sync(channel_layer.group_send)( + f"events-live-{event_id}", {"type": "join_leave", "event": action} + ) + + return Response({"success": True}) + + +class MeetingZoomAPIView(APIView): + """ + get: Return a list of upcoming Zoom meetings for a user. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + """ + --- + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + meetings: + type: object + additionalProperties: + type: string + extra_details: + type: object + additionalProperties: + type: string + --- + """ + refresh = request.query_params.get("refresh", "false").lower() == "true" + + if request.user.is_authenticated: + key = f"zoom:meetings:{request.user.username}" + if not refresh: + res = cache.get(key) + if res is not None: + return Response(res) + + try: + data = zoom_api_call( + request.user, "GET", "https://api.zoom.us/v2/users/{uid}/meetings" + ) + except requests.exceptions.HTTPError as e: + raise DRFValidationError( + "An error occured while fetching meetings for current user." + ) from e + + # get meeting ids + body = data.json() + meetings = [meeting["id"] for meeting in body.get("meetings", [])] + + # get user events + if request.user.is_authenticated: + events = Event.objects.filter( + club__membership__role__lte=Membership.ROLE_OFFICER, + club__membership__person=request.user, + ) + else: + events = [] + + extra_details = {} + for event in events: + if event.url is not None and "zoom.us" in event.url: + match = re.search(r"(\d+)", urlparse(event.url).path) + if match is not None: + zoom_id = int(match[1]) + if zoom_id in meetings: + try: + individual_data = zoom_api_call( + request.user, + "GET", + f"https://api.zoom.us/v2/meetings/{zoom_id}", + ).json() + extra_details[individual_data["id"]] = individual_data + except requests.exceptions.HTTPError: + pass + + response = { + "success": data.ok, + "meetings": body, + "extra_details": extra_details, + } + if response["success"]: + cache.set(key, response, 120) + return Response(response) + + def delete(self, request): + """ + Delete the Zoom meeting for this event. + """ + event = get_object_or_404(Event, id=request.query_params.get("event")) + + if ( + not request.user.has_perm("clubs.manage_club") + and not event.club.membership_set.filter( + person=request.user, role__lte=Membership.ROLE_OFFICER + ).exists() + ): + return Response( + { + "success": False, + "detail": "You do not have permission to perform this action.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + if event.url: + match = re.search(r"(\d+)", urlparse(event.url).path) + if "zoom.us" in event.url and match is not None: + zoom_id = int(match[1]) + zoom_api_call( + request.user, "DELETE", f"https://api.zoom.us/v2/meetings/{zoom_id}" + ) + + event.url = None + event.save() + return Response( + { + "success": True, + "detail": "The Zoom meeting has been unlinked and deleted.", + } + ) + else: + return Response( + { + "success": True, + "detail": "There is no Zoom meeting configured for this event.", + } + ) + + def post(self, request): + """ + Create a new Zoom meeting for this event + or try to fix the existing zoom meeting. + --- + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + detail: + type: string + --- + """ + try: + event = Event.objects.get(id=request.query_params.get("event")) + except Event.DoesNotExist as e: + raise DRFValidationError( + "The event you are trying to modify does not exist." + ) from e + + eastern = pytz.timezone("America/New_York") + + # ensure user can do this + if not request.user.has_perm( + "clubs.manage_club" + ) and not event.club.membership_set.filter( + role__lte=Membership.ROLE_OFFICER, person=request.user + ): + return Response( + { + "success": False, + "detail": "You are not allowed to perform this action!", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + # add all other officers as alternative hosts + alt_hosts = [] + for mship in event.club.membership_set.filter( + role__lte=Membership.ROLE_OFFICER + ): + social = mship.person.social_auth.filter(provider="zoom-oauth2").first() + if social is not None: + alt_hosts.append(social.extra_data["email"]) + + # recommended zoom meeting settings + recommended_settings = { + "audio": "both", + "join_before_host": True, + "mute_upon_entry": True, + "waiting_room": False, + "meeting_authentication": True, + "authentication_domains": "upenn.edu,*.upenn.edu", + } + + if alt_hosts: + recommended_settings["alternative_hosts"] = ",".join(alt_hosts) + + if not event.url: + password = generate_zoom_password() + body = { + "topic": f"Virtual Activities Fair - {event.club.name}", + "type": 2, + "start_time": event.start_time.astimezone(eastern) + .replace(tzinfo=None, microsecond=0, second=0) + .isoformat(), + "duration": (event.end_time - event.start_time) + / datetime.timedelta(minutes=1), + "timezone": "America/New_York", + "agenda": f"Virtual Activities Fair Booth for {event.club.name}", + "password": password, + "settings": recommended_settings, + } + data = zoom_api_call( + request.user, + "POST", + "https://api.zoom.us/v2/users/{uid}/meetings", + json=body, + ) + out = data.json() + event.url = out.get("join_url", "") + event.save(update_fields=["url"]) + return Response( + { + "success": True, + "detail": "Your Zoom meeting has been created! " + "The following Zoom accounts have been made hosts:" + f" {', '.join(alt_hosts)}", + } + ) + else: + parsed_url = urlparse(event.url) + + if "zoom.us" not in parsed_url.netloc: + return Response( + { + "success": False, + "detail": "The current meeting link is not a Zoom link. " + "If you would like to have your Zoom link automatically " + "generated, please clear the URL field and try again.", + } + ) + + if "upenn.zoom.us" not in parsed_url.netloc: + return Response( + { + "success": False, + "detail": "The current meeting link is not a Penn Zoom link. " + "If you would like to have your Penn Zoom link automatically " + "generated, login with your Penn Zoom account, clear the URL " + "from your event, and try this process again.", + } + ) + + match = re.search(r"(\d+)", parsed_url.path) + if match is None: + return Response( + { + "success": False, + "detail": "Failed to parse your URL, " + "are you sure this is a valid Zoom link?", + } + ) + + zoom_id = int(match[1]) + + data = zoom_api_call( + request.user, "GET", f"https://api.zoom.us/v2/meetings/{zoom_id}" + ) + out = data.json() + event.url = out.get("join_url", event.url) + event.save(update_fields=["url"]) + + start_time = ( + event.start_time.astimezone(eastern) + .replace(tzinfo=None, microsecond=0, second=0) + .isoformat() ) - if meeting is not None: - meeting.leave_time = leave_time - meeting.save() - event_id = meeting.event.id - if event_id is not None: - channel_layer = get_channel_layer() - if channel_layer is not None: - async_to_sync(channel_layer.group_send)( - f"events-live-{event_id}", {"type": "join_leave", "event": action} - ) + body = { + "start_time": start_time, + "duration": (event.end_time - event.start_time) + / datetime.timedelta(minutes=1), + "timezone": "America/New_York", + "settings": recommended_settings, + } - return Response({"success": True}) + out = zoom_api_call( + request.user, + "PATCH", + f"https://api.zoom.us/v2/meetings/{zoom_id}", + json=body, + ) + + return Response( + { + "success": out.ok, + "detail": ( + "Your Zoom meeting has been updated. " + "The following accounts have been made hosts:" + f" {', '.join(alt_hosts)}" + if out.ok + else "Your Zoom meeting has not been updated. " + "Are you the owner of the meeting?" + ), + } + ) -class MeetingZoomAPIView(APIView): - """ - get: Return a list of upcoming Zoom meetings for a user. +class UserZoomAPIView(APIView): """ + get: Return information about the Zoom account associated with the logged in user. - permission_classes = [IsAuthenticated] + post: Update the Zoom account settings to be the recommended Penn Clubs settings. + """ def get(self, request): """ @@ -3824,465 +4818,802 @@ def get(self, request): properties: success: type: boolean - meetings: - type: object - additionalProperties: - type: string - extra_details: + settings: type: object additionalProperties: type: string + email: + type: string --- """ refresh = request.query_params.get("refresh", "false").lower() == "true" + no_cache = request.query_params.get("noCache", "false").lower() == "true" if request.user.is_authenticated: - key = f"zoom:meetings:{request.user.username}" - if not refresh: - res = cache.get(key) - if res is not None: - return Response(res) + key = f"zoom:user:{request.user.username}" + res = cache.get(key) + if res is not None: + if not refresh: + if res.get("success") is True: + return Response(res) + else: + cache.delete(key) + if no_cache: + cache.delete(key) try: - data = zoom_api_call( - request.user, "GET", "https://api.zoom.us/v2/users/{uid}/meetings" + response = zoom_api_call( + request.user, + "GET", + "https://api.zoom.us/v2/users/{uid}/settings", ) except requests.exceptions.HTTPError as e: raise DRFValidationError( - "An error occured while fetching meetings for current user." + "An error occured while fetching user information. " + "Your authentication with the Zoom API might have expired. " + "Try reconnecting your account." ) from e - # get meeting ids - body = data.json() - meetings = [meeting["id"] for meeting in body.get("meetings", [])] + social = request.user.social_auth.filter(provider="zoom-oauth2").first() + if social is None: + email = None + else: + email = social.extra_data.get("email") - # get user events + settings = response.json() + res = { + "success": settings.get("code") is None, + "settings": settings, + "email": email, + } + + if res["success"]: + cache.set(key, res, 900) + return Response(res) + + def post(self, request): + """ + --- + responses: + "200": + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + detail: + type: string + --- + """ if request.user.is_authenticated: - events = Event.objects.filter( - club__membership__role__lte=Membership.ROLE_OFFICER, - club__membership__person=request.user, - ) - else: - events = [] + key = f"zoom:user:{request.user.username}" + cache.delete(key) - extra_details = {} - for event in events: - if event.url is not None and "zoom.us" in event.url: - match = re.search(r"(\d+)", urlparse(event.url).path) - if match is not None: - zoom_id = int(match[1]) - if zoom_id in meetings: - try: - individual_data = zoom_api_call( - request.user, - "GET", - f"https://api.zoom.us/v2/meetings/{zoom_id}", - ).json() - extra_details[individual_data["id"]] = individual_data - except requests.exceptions.HTTPError: - pass + response = zoom_api_call( + request.user, + "PATCH", + "https://api.zoom.us/v2/users/{uid}/settings", + json={ + "in_meeting": { + "breakout_room": True, + "waiting_room": False, + "co_host": True, + "screen_sharing": True, + } + }, + ) + + return Response( + { + "success": response.ok, + "detail": ( + "Your user settings have been updated on Zoom." + if response.ok + else "Failed to update Zoom user settings." + ), + } + ) + + +class UserUpdateAPIView(generics.RetrieveUpdateAPIView): + """ + get: Return information about the logged in user, including bookmarks, + subscriptions, memberships, and school/major/graduation year information. + + put: Update information about the logged in user. + All fields are required. + + patch: Update information about the logged in user. + Only updates fields that are passed to the server. + """ + + permission_classes = [IsAuthenticated] + serializer_class = UserSerializer + + def get(self, request, *args, **kwargs): + """ + Cache the settings endpoint for 5 minutes or until user data is updated. + """ + key = f"user:settings:{request.user.username}" + val = cache.get(key) + if val: + return Response(val) + resp = super().get(request, *args, **kwargs) + cache.set(key, resp.data, 5 * 60) + return resp + + def put(self, request, *args, **kwargs): + """ + Clear the cache when putting user settings. + """ + key = f"user:settings:{request.user.username}" + cache.delete(key) + return super().put(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + """ + Clear the cache when patching user settings. + """ + key = f"user:settings:{request.user.username}" + cache.delete(key) + return super().patch(request, *args, **kwargs) + + def get_operation_id(self, **kwargs): + if kwargs["action"] == "get": + return "Retrieve Self User" + return None + + def get_object(self): + user = self.request.user + prefetch_related_objects( + [user], + "profile__school", + "profile__major", + ) + return user + + +class TicketViewSet(viewsets.ModelViewSet): + """ + get: + Get a specific ticket owned by a user + + list: + List all tickets owned by a user + + partial_update: + Update attendance for a ticket + + 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 - response = { - "success": data.ok, - "meetings": body, - "extra_details": extra_details, + transfer: + Transfer a ticket to another user + """ + + permission_classes = [IsAuthenticated] + serializer_class = TicketSerializer + http_method_names = ["get", "post", "patch"] + lookup_field = "id" + + @staticmethod + def _calculate_cart_total(cart) -> float: + """ + Calculate the total price of all tickets in a cart, applying discounts + where appropriate. Does not validate that the cart is valid. + + :param cart: Cart object + :return: Total price of all tickets in the cart + """ + ticket_type_counts = { + item["type"]: item["count"] + for item in cart.tickets.values("type").annotate(count=Count("type")) } - if response["success"]: - cache.set(key, response, 120) - return Response(response) + 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 cart.tickets.all() + ) + return cart_total - def delete(self, request): + def partial_update(self, request, *args, **kwargs): """ - Delete the Zoom meeting for this event. + Update a ticket's attendance (only accessible by club officers) + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + attended: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Ticket' + "400": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + --- """ - event = get_object_or_404(Event, id=request.query_params.get("event")) + attended = request.data.get("attended") + if attended is None or not isinstance(attended, bool): + return Response( + {"detail": "Missing boolean attribute 'attended'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + ticket = self.get_object() + ticket.attended = attended + ticket.save() + return Response(TicketSerializer(ticket).data) - if ( - not request.user.has_perm("clubs.manage_club") - and not event.club.membership_set.filter( - person=request.user, role__lte=Membership.ROLE_OFFICER - ).exists() - ): + @transaction.atomic + @update_holds + @action(detail=False, methods=["get"]) + def cart(self, request, *args, **kwargs): + """ + Validate tickets in a cart and return them. Replace in-cart tickets that + have been bought/held by someone else. + --- + requestBody: + content: {} + responses: + "200": + content: + application/json: + schema: + type: object + properties: + tickets: + allOf: + - $ref: "#/components/schemas/Ticket" + sold_out: + type: array + items: + type: object + properties: + event: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + count: + type: integer + --- + """ + + cart, _ = Cart.objects.prefetch_related("tickets").get_or_create( + owner=self.request.user + ) + + 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( { - "success": False, - "detail": "You do not have permission to perform this action.", + "tickets": TicketSerializer(cart.tickets.all(), many=True).data, + "sold_out": [], }, - status=status.HTTP_403_FORBIDDEN, ) - if event.url: - match = re.search(r"(\d+)", urlparse(event.url).path) - if "zoom.us" in event.url and match is not None: - zoom_id = int(match[1]) - zoom_api_call( - request.user, "DELETE", f"https://api.zoom.us/v2/meetings/{zoom_id}" + # Attempt to replace all tickets that have gone stale + replacement_tickets, sold_out_tickets = [], [] + + tickets_in_cart = cart.tickets.values_list("id", flat=True) + tickets_to_replace = tickets_to_replace.select_related("event") + + for ticket_class in tickets_to_replace.values( + "type", "event", "event__name" + ).annotate(count=Count("id")): + # we don't need to lock, since we aren't updating holder/owner + available_tickets = Ticket.objects.filter( + event=ticket_class["event"], + type=ticket_class["type"], + buyable=True, # should not be triggered as buyable is by ticket class + owner__isnull=True, + holder__isnull=True, + ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]] + + num_short = ticket_class["count"] - available_tickets.count() + if num_short > 0: + sold_out_tickets.append( + { + "type": ticket_class["type"], + "event": { + "id": ticket_class["event"], + "name": ticket_class["event__name"], + }, + "count": num_short, + } ) - event.url = None - event.save() - return Response( - { - "success": True, - "detail": "The Zoom meeting has been unlinked and deleted.", - } - ) - else: - return Response( - { - "success": True, - "detail": "There is no Zoom meeting configured for this event.", - } - ) + replacement_tickets.extend(list(available_tickets)) - def post(self, request): + 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_tickets, + }, + ) + + @action(detail=False, methods=["post"]) + @update_holds + @transaction.atomic + def initiate_checkout(self, request, *args, **kwargs): """ - Create a new Zoom meeting for this event - or try to fix the existing zoom meeting. + 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: + type: object + properties: + detail: + type: string success: type: boolean + sold_free_tickets: + type: boolean + "403": + content: + application/json: + schema: + type: object + properties: detail: type: string + success: + type: boolean + sold_free_tickets: + type: boolean --- """ - try: - event = Event.objects.get(id=request.query_params.get("event")) - except Event.DoesNotExist as e: - raise DRFValidationError( - "The event you are trying to modify does not exist." - ) from e + cart = get_object_or_404(Cart, owner=self.request.user) - eastern = pytz.timezone("America/New_York") + # Cart must have at least one ticket + if not cart.tickets.exists(): + return Response( + { + "success": False, + "detail": "No tickets selected for checkout.", + "sold_free_tickets": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) - # ensure user can do this - if not request.user.has_perm( - "clubs.manage_club" - ) and not event.club.membership_set.filter( - role__lte=Membership.ROLE_OFFICER, person=request.user - ): + # 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, + buyable=True, + ) + + # Assert that the filter succeeded in freezing all the tickets for checkout + if tickets.count() != cart.tickets.count(): return Response( { "success": False, - "detail": "You are not allowed to perform this action!", + "detail": ( + "Cart is stale or empty, invoke /api/tickets/cart to refresh" + ), + "sold_free_tickets": False, }, status=status.HTTP_403_FORBIDDEN, ) - # add all other officers as alternative hosts - alt_hosts = [] - for mship in event.club.membership_set.filter( - role__lte=Membership.ROLE_OFFICER - ): - social = mship.person.social_auth.filter(provider="zoom-oauth2").first() - if social is not None: - alt_hosts.append(social.extra_data["email"]) - - # recommended zoom meeting settings - recommended_settings = { - "audio": "both", - "join_before_host": True, - "mute_upon_entry": True, - "waiting_room": False, - "meeting_authentication": True, - "authentication_domains": "upenn.edu,*.upenn.edu", - } + cart_total = self._calculate_cart_total(cart) + + # If all tickets are free, we can skip the payment process + if not cart_total: + order_info = { + "amountDetails": {"totalAmount": "0.00"}, + "billTo": { + "reconciliationId": None, + "firstName": self.request.user.first_name, + "lastName": self.request.user.last_name, + "phoneNumber": None, + "email": self.request.user.email, + }, + } - if alt_hosts: - recommended_settings["alternative_hosts"] = ",".join(alt_hosts) + # Place hold on tickets for 10 mins + self._place_hold_on_tickets(tickets) + # Skip payment process and give tickets to user/buyer + self._give_tickets(self.request.user, order_info, cart, None) - if not event.url: - password = generate_zoom_password() - body = { - "topic": f"Virtual Activities Fair - {event.club.name}", - "type": 2, - "start_time": event.start_time.astimezone(eastern) - .replace(tzinfo=None, microsecond=0, second=0) - .isoformat(), - "duration": (event.end_time - event.start_time) - / datetime.timedelta(minutes=1), - "timezone": "America/New_York", - "agenda": f"Virtual Activities Fair Booth for {event.club.name}", - "password": password, - "settings": recommended_settings, - } - data = zoom_api_call( - request.user, - "POST", - "https://api.zoom.us/v2/users/{uid}/meetings", - json=body, - ) - out = data.json() - event.url = out.get("join_url", "") - event.save(update_fields=["url"]) return Response( { "success": True, - "detail": "Your Zoom meeting has been created! " - "The following Zoom accounts have been made hosts:" - f" {', '.join(alt_hosts)}", + "detail": "Free tickets sold.", + "sold_free_tickets": True, } ) - else: - parsed_url = urlparse(event.url) - - if "zoom.us" not in parsed_url.netloc: - return Response( - { - "success": False, - "detail": "The current meeting link is not a Zoom link. " - "If you would like to have your Zoom link automatically " - "generated, please clear the URL field and try again.", - } - ) - - if "upenn.zoom.us" not in parsed_url.netloc: - return Response( - { - "success": False, - "detail": "The current meeting link is not a Penn Zoom link. " - "If you would like to have your Penn Zoom link automatically " - "generated, login with your Penn Zoom account, clear the URL " - "from your event, and try this process again.", - } - ) - - match = re.search(r"(\d+)", parsed_url.path) - if match is None: - return Response( - { - "success": False, - "detail": "Failed to parse your URL, " - "are you sure this is a valid Zoom link?", - } - ) - - zoom_id = int(match[1]) - - data = zoom_api_call( - request.user, "GET", f"https://api.zoom.us/v2/meetings/{zoom_id}" - ) - out = data.json() - event.url = out.get("join_url", event.url) - event.save(update_fields=["url"]) - - start_time = ( - event.start_time.astimezone(eastern) - .replace(tzinfo=None, microsecond=0, second=0) - .isoformat() - ) - body = { - "start_time": start_time, - "duration": (event.end_time - event.start_time) - / datetime.timedelta(minutes=1), - "timezone": "America/New_York", - "settings": recommended_settings, - } + 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", + } + }, + } - out = zoom_api_call( - request.user, - "PATCH", - f"https://api.zoom.us/v2/meetings/{zoom_id}", - json=body, + 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 + self._place_hold_on_tickets(tickets) return Response( { - "success": out.ok, - "detail": ( - "Your Zoom meeting has been updated. " - "The following accounts have been made hosts:" - f" {', '.join(alt_hosts)}" - if out.ok - else "Your Zoom meeting has not been updated. " - "Are you the owner of the meeting?" - ), + "success": True, + "detail": context, + "sold_free_tickets": False, } ) + except ApiException as e: + return Response( + { + "success": False, + "detail": f"Unable to generate capture context: {e}", + "sold_free_tickets": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) -class UserZoomAPIView(APIView): - """ - get: Return information about the Zoom account associated with the logged in user. - - post: Update the Zoom account settings to be the recommended Penn Clubs settings. - """ - - def get(self, 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: + type: object + properties: + detail: + type: string success: type: boolean - settings: - type: object - additionalProperties: - type: string - email: - type: string --- """ - refresh = request.query_params.get("refresh", "false").lower() == "true" - no_cache = request.query_params.get("noCache", "false").lower() == "true" + tt = request.data.get("transient_token") + cart = get_object_or_404( + Cart.objects.prefetch_related("tickets"), owner=self.request.user + ) - if request.user.is_authenticated: - key = f"zoom:user:{request.user.username}" - res = cache.get(key) - if res is not None: - if not refresh: - if res.get("success") is True: - return Response(res) - else: - cache.delete(key) - if no_cache: - cache.delete(key) + 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: - response = zoom_api_call( - request.user, - "GET", - "https://api.zoom.us/v2/users/{uid}/settings", + _, 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, ) - except requests.exceptions.HTTPError as e: - raise DRFValidationError( - "An error occured while fetching user information. " - "Your authentication with the Zoom API might have expired. " - "Try reconnecting your account." - ) from e - social = request.user.social_auth.filter(provider="zoom-oauth2").first() - if social is None: - email = None - else: - email = social.extra_data.get("email") + create_payment_request = {"tokenInformation": {"transientTokenJwt": tt}} - settings = response.json() - res = { - "success": settings.get("code") is None, - "settings": settings, - "email": email, - } + try: + payment_response, http_status, _ = PaymentsApi( + settings.CYBERSOURCE_CONFIG + ).create_payment(json.dumps(create_payment_request)) - if res["success"]: - cache.set(key, res, 900) - return Response(res) + if payment_response.status != "AUTHORIZED": + raise ApiException(reason="Payment response status is not authorized") + reconciliation_id = payment_response.reconciliation_id - def post(self, request): + 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, + ) + + order_info = transaction_data["orderInformation"] + self._give_tickets(self.request.user, order_info, cart, reconciliation_id) + + 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=True, methods=["post"]) + @transaction.atomic + def transfer(self, request, *args, **kwargs): """ + Transfer a ticket to another user --- + requestBody: + content: + application/json: + schema: + type: object + properties: + username: + type: string + required: + - username responses: "200": content: application/json: schema: - type: object - properties: - success: - type: boolean + type: object + properties: + detail: + type: string + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + "404": + content: + application/json: + schema: + type: object + properties: detail: type: string --- """ - if request.user.is_authenticated: - key = f"zoom:user:{request.user.username}" - cache.delete(key) - - response = zoom_api_call( - request.user, - "PATCH", - "https://api.zoom.us/v2/users/{uid}/settings", - json={ - "in_meeting": { - "breakout_room": True, - "waiting_room": False, - "co_host": True, - "screen_sharing": True, - } - }, - ) - - return Response( - { - "success": response.ok, - "detail": ( - "Your user settings have been updated on Zoom." - if response.ok - else "Failed to update Zoom user settings." - ), - } + receiver = get_object_or_404( + get_user_model(), username=request.data.get("username") ) + # checking whether the request's user owns the ticket is handled by the queryset + ticket = self.get_object() + if not ticket.transferable: + return Response( + {"detail": "The ticket is non-transferable"}, + status=status.HTTP_403_FORBIDDEN, + ) -class UserUpdateAPIView(generics.RetrieveUpdateAPIView): - """ - get: Return information about the logged in user, including bookmarks, - subscriptions, memberships, and school/major/graduation year information. + if self.request.user == receiver: + return Response( + {"detail": "You cannot transfer a ticket to yourself"}, + status=status.HTTP_403_FORBIDDEN, + ) - put: Update information about the logged in user. - All fields are required. + ticket.owner = receiver + ticket.save() + TicketTransferRecord.objects.create( + ticket=ticket, sender=self.request.user, receiver=receiver + ).send_confirmation_email() + ticket.send_confirmation_email() # send event details to recipient - patch: Update information about the logged in user. - Only updates fields that are passed to the server. - """ + return Response({"detail": "Successfully transferred ownership of ticket"}) - permission_classes = [IsAuthenticated] - serializer_class = UserSerializer + def get_queryset(self): + if self.action == "partial_update": + officer_clubs = Membership.objects.filter( + person=self.request.user, role__lte=Membership.ROLE_OFFICER + ).values_list("club", flat=True) + return Ticket.objects.filter(event__club__in=officer_clubs).select_related( + "event__club" + ) + return Ticket.objects.filter(owner=self.request.user.id) - def get(self, request, *args, **kwargs): + def _give_tickets(self, user, order_info, cart, reconciliation_id): """ - Cache the settings endpoint for 5 minutes or until user data is updated. + Helper function that gives user/buyer their held tickets + and archives the transaction data """ - key = f"user:settings:{request.user.username}" - val = cache.get(key) - if val: - return Response(val) - resp = super().get(request, *args, **kwargs) - cache.set(key, resp.data, 5 * 60) - return resp - def put(self, request, *args, **kwargs): - """ - Clear the cache when putting user settings. - """ - key = f"user:settings:{request.user.username}" - cache.delete(key) - return super().put(request, *args, **kwargs) + # 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=user) - def patch(self, request, *args, **kwargs): - """ - Clear the cache when patching user settings. - """ - key = f"user:settings:{request.user.username}" - cache.delete(key) - return super().patch(request, *args, **kwargs) + # Archive transaction data for historical purposes. + # We're explicitly using the response data over what's in self.request.user + transaction_records = [] - def get_operation_id(self, **kwargs): - if kwargs["action"] == "get": - return "Retrieve Self User" - return None + for ticket in tickets: + transaction_records.append( + TicketTransactionRecord( + ticket=ticket, + reconciliation_id=str(reconciliation_id), + total_amount=float(order_info["amountDetails"]["totalAmount"]), + buyer_first_name=order_info["billTo"]["firstName"], + buyer_last_name=order_info["billTo"]["lastName"], + # TODO: investigate why phone numbers don't show in test API + buyer_phone=order_info["billTo"].get("phoneNumber", None), + buyer_email=order_info["billTo"]["email"], + ) + ) - def get_object(self): - user = self.request.user - prefetch_related_objects( - [user], - "profile__school", - "profile__major", - ) - return user + tickets.update(owner=self.request.user, holder=None) + TicketTransactionRecord.objects.bulk_create(transaction_records) + cart.tickets.clear() + for ticket in tickets: + ticket.send_confirmation_email() + + Ticket.objects.update_holds() + + cart.checkout_context = None + cart.save() + + def _place_hold_on_tickets(self, tickets): + """ + Helper function that places a 10 minute hold on tickets for a user + """ + holding_expiration = timezone.now() + datetime.timedelta(minutes=10) + tickets.update(holder=self.request.user, holding_expiration=holding_expiration) class MemberInviteViewSet(viewsets.ModelViewSet): 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..e6ac4db89 --- /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.

+ + + +

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

+{% endblock %} \ No newline at end of file diff --git a/backend/templates/emails/ticket_transfer.html b/backend/templates/emails/ticket_transfer.html new file mode 100644 index 000000000..d4b69f39f --- /dev/null +++ b/backend/templates/emails/ticket_transfer.html @@ -0,0 +1,27 @@ + + +{% extends 'emails/base.html' %} + +{% block content %} +

Ticket Transfer Confirmation

+ +

+ {{ sender_first_name }}, this is confirmation that you have transferred a ticket to {{ receiver_first_name }} ({{ receiver_username }}) for {{ event_name }} with ticket type {{ type }}. +

+ + +

+ If you believe that this was sent in error or 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..767d2eb4c --- /dev/null +++ b/backend/tests/clubs/test_ticketing.py @@ -0,0 +1,1520 @@ +import json +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import timedelta +from unittest.mock import patch + +import freezegun +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.db.models.deletion import ProtectedError +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, + Membership, + Ticket, + TicketTransactionRecord, + TicketTransferRecord, +) + + +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 ClubEventViewSet + 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_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + + tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(10)] + Ticket.objects.bulk_create(tickets) + + qts = { + "quantities": [ + {"type": "_free", "count": 10, "price": 0}, + ] + } + + 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.00) + 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_create_ticket_offerings_delay_drop(self): + self.client.login(username=self.user1.username, password="test") + + args = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ], + "drop_time": (timezone.now() + timezone.timedelta(hours=12)).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ), + } + _ = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + self.event1.refresh_from_db() + + # Drop time should be set + self.assertIsNotNone(self.event1.ticket_drop_time) + + # Drop time should be 12 hours from initial ticket creation + expected_drop_time = timezone.now() + timezone.timedelta(hours=12) + diff = abs(self.event1.ticket_drop_time - expected_drop_time) + self.assertTrue(diff < timezone.timedelta(minutes=5)) + + # Move Django's internal clock 13 hours forward + with freezegun.freeze_time(timezone.now() + timezone.timedelta(hours=13)): + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + # Tickets shouldn't be editable after drop time has elapsed + self.assertEqual(resp.status_code, 403, resp.content) + + def test_create_ticket_offerings_already_owned_or_held(self): + self.client.login(username=self.user1.username, password="test") + + # Create ticket offerings + args = { + "quantities": [ + {"type": "_normal", "count": 5, "price": 10}, + {"type": "_premium", "count": 3, "price": 20}, + ], + } + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Simulate checkout by applying holds + for ticket in Ticket.objects.filter(type="_normal"): + ticket.holder = self.user1 + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Simulate purchase by transferring ownership + for ticket in Ticket.objects.filter(type="_normal", holder=self.user1): + ticket.owner = self.user1 + ticket.holder = None + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + def test_issue_tickets(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user1.username, "ticket_type": "premium"}, + {"username": self.user2.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 200, resp.content) + + for item in args["tickets"]: + username, ticket_type = item["username"], item["ticket_type"] + user = get_user_model().objects.get(username=username) + + self.assertEqual( + Ticket.objects.filter(type=ticket_type, owner=user).count(), 1 + ) + self.assertTrue( + TicketTransactionRecord.objects.filter( + ticket__type=ticket_type, + ticket__owner=user, + total_amount=0.0, + ).exists() + ) + + def test_issue_tickets_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "normal"}, + ] + } + + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + + def test_issue_tickets_invalid_username_ticket_type(self): + # All usernames must be valid + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": "invalid_user_1", "ticket_type": "normal"}, + {"username": "invalid_user_2", "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_user_1", "invalid_user_2"]) + + # All requested ticket types must be valid + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "invalid_type_1"}, + {"username": self.user2.username, "ticket_type": "invalid_type_2"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_type_1", "invalid_type_2"]) + + def test_issue_tickets_insufficient_quantity(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "normal"} + for _ in range(100) + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn( + "Not enough tickets available for type: normal", str(resp.content) + ) + + # No tickets should be transferred + self.assertEqual(Ticket.objects.filter(owner=self.user2).count(), 0) + + # No holds should be given + self.assertEqual( + Ticket.objects.filter(type="normal", holder__isnull=False).count(), 0 + ) + + 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_before_drop_time(self): + self.event1.ticket_drop_time = timezone.now() + timedelta(days=1) + self.event1.save() + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + + # Tickets shouldn't be available before the drop time + self.assertEqual(data["totals"], []) + self.assertEqual(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_add_to_cart_before_ticket_drop(self): + self.client.login(username=self.user1.username, password="test") + + # Set drop time + self.event1.ticket_drop_time = timezone.now() + timedelta(hours=12) + self.event1.save() + + 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", + ) + + # Tickets should not be added to cart before drop time + self.assertEqual(resp.status_code, 403, resp.content) + + 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) + + def test_delete_event_with_claimed_tickets(self): + # Simulate checkout (hold ticket) + self.tickets1[0].holder = self.user1 + self.tickets1[0].save() + + self.client.login(username=self.user1.username, password="test") + resp_held = self.client.delete( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)) + ) + self.assertEqual(resp_held.status_code, 400, resp_held.content) + + # Simulate purchase (transfer ticket) + self.tickets1[0].holder = None + self.tickets1[0].owner = self.user1 + self.tickets1[0].save() + + resp_bought = self.client.delete( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)) + ) + self.assertEqual(resp_bought.status_code, 400, resp_bought.content) + + +@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(len(data["sold_out"]), 0, data) + + def test_calculate_cart_total(self): + # 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() + + expected_total = sum(t.price for t in tickets_to_add) + + from clubs.views import TicketViewSet + + actual_total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(actual_total, expected_total) + + def test_calculate_cart_total_with_group_discount(self): + # Create tickets with group discount + tickets = [ + Ticket.objects.create( + type="group", + event=self.event1, + price=10.0, + group_size=2, + group_discount=0.2, + ) + for _ in range(10) + ] + + cart, _ = Cart.objects.get_or_create(owner=self.user1) + from clubs.views import TicketViewSet + + # Add 1 ticket, shouldn't activate group discount + cart.tickets.add(tickets[0]) + cart.save() + + total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(total, 10.0) # 1 * price=10 = 10 + + # Add 4 more tickets, enough to activate group discount + tickets_to_add = tickets[1:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + self.assertEqual(cart.tickets.count(), 5) + + total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(total, 40.0) # 5 * price=10 * (1 - group_discount=0.2) = 40 + + 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(len(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) + + # Only 1 type of ticket should be sold out + self.assertEqual(len(data["sold_out"]), 1, data) + + # 2 normal tickets should be sold out + expected_sold_out = { + "type": self.tickets1[0].type, + "event": { + "id": self.tickets1[0].event.id, + "name": self.tickets1[0].event.name, + }, + "count": 2, + } + for key, val in expected_sold_out.items(): + self.assertEqual(data["sold_out"][0][key], val, data) + + # 0 tickets are the same (we sell all but last 3) + in_cart = set(map(lambda t: t["id"], data["tickets"])) + to_add = set(map(lambda t: str(t.id), tickets_to_add)) + self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add) + + def test_initiate_checkout_non_free_tickets(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) + # No free tickets should be sold + self.assertFalse(resp.data["sold_free_tickets"]) + + # 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_checkout_free_and_non_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + Ticket.objects.create(type="free", event=self.event1, price=0.0) + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "free", "count": 1}, + {"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) + # Free ticket should be sold with non-free tickets if purchased together + self.assertFalse(resp.data["sold_free_tickets"]) + + # 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(), 3, held_tickets) + self.assertEqual(held_tickets.filter(type="free").count(), 1, 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_checkout_only_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + + tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(3)] + Ticket.objects.bulk_create(tickets) + + # Add a few free tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "free", "count": 3}, + ] + } + 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) + # check that free tickets were sold + self.assertTrue(resp.data["sold_free_tickets"]) + + # Ownership transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 3, 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 + record_exists = TicketTransactionRecord.objects.filter( + reconciliation_id="None" + ).exists() + self.assertTrue(record_exists) + + 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) + + # Complete checkout + 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 transferred + 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 + record_exists = TicketTransactionRecord.objects.filter( + reconciliation_id=MockPaymentResponse().reconciliation_id + ).exists() + self.assertTrue(record_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 transferred + 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 transferred + 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 transferred + 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_transfer_ticket(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + + # fail to transfer when not owned + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + self.assertEqual(resp.status_code, 404, resp.content) + + ticket.owner = self.user1 + ticket.save() + + # successful transfer when owned + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + ticket.refresh_from_db() + + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(ticket.owner, self.user2, ticket.owner) + + def test_transfer_non_transferable_ticket(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.transferable = False + ticket.save() + + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + ticket.refresh_from_db() + + self.assertEqual(resp.status_code, 403, resp.content) + self.assertEqual(ticket.owner, self.user1, ticket.owner) + + def test_transfer_ticket_to_self(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user1.username}, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + def test_update_attendance(self): + self.client.login(username=self.user1.username, password="test") + Membership.objects.create( + person=self.user1, + club=self.club1, + title="Officer", + role=Membership.ROLE_OFFICER, + ) + ticket = self.tickets1[0] + ticket.owner = self.user2 + ticket.save() + + resp = self.client.patch( + reverse("tickets-detail", args=(ticket.id,)), + {"attended": True}, + format="json", + ) + ticket.refresh_from_db() + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue(ticket.attended) + + def test_update_attendance_non_officer(self): + # user1 is no longer an officer for the ticket's club + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + + resp = self.client.patch( + reverse("tickets-detail", args=(ticket.id,)), + {"attended": True}, + format="json", + ) + ticket.refresh_from_db() + self.assertEqual(resp.status_code, 404, resp.content) + self.assertFalse(ticket.attended) + + +class TicketModelTestCase(TestCase): + """ + Test cases related to the models that correspond to the ticketing project: + Ticket, TicketTransactionRecord, TicketTransferRecord + """ + + def setUp(self): + commonSetUp(self) + + def test_delete_ticket_after_purchase(self): + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + TicketTransactionRecord.objects.create( + ticket=ticket, + buyer_first_name=self.user1.first_name, + buyer_last_name=self.user2.last_name, + total_amount=ticket.price, + ) + + with self.assertRaises(ProtectedError): + ticket.delete() + + def test_delete_ticket_after_transfer(self): + ticket = self.tickets1[0] + ticket.owner = self.user2 + ticket.save() + TicketTransferRecord.objects.create( + ticket=ticket, sender=self.user1, receiver=self.user2 + ) + + with self.assertRaises(ProtectedError): + ticket.delete() 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}} +