diff --git a/.gitignore b/.gitignore
index 35a1877f2..79a6e3647 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,8 +23,9 @@ htmlcov/
# Test database
db.sqlite3
-# Mac
+# Misc
.DS_Store
+*.pem
# React
node_modules/
diff --git a/README.md b/README.md
index 9237fee70..542249db7 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ React/Next.js frontend and Django-based REST API.
## Installation
+You will need to start both the backend and the frontend to do Penn Clubs development.
+
You will need to start both the backend and the frontend to develop on Penn Clubs. Clubs supports Mac and Linux/WSL development.
Questions? Check out our [extended guide](https://github.com/pennlabs/penn-clubs/wiki/Development-Guide) for FAQs.
@@ -84,3 +86,13 @@ Use `$ yarn test` to run Cypress tests.
### Development
Click `Login` to log in as a test user. The `./manage.py populate` command creates a test user for you with username `bfranklin` and password `test`. Go to `/api/admin` to login to this account.
+
+#### Ticketing
+
+To test ticketing locally, you will need to [install](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation) `mkcert`, enter the `frontend` directory, and run the following commands:
+
+- `$ mkcert -install`
+- `$ mkcert localhost 127.0.0.1 ::1`
+- `$ export DOMAIN=https://localhost:3001 NODE_TLS_REJECT_UNAUTHORIZED=0`
+
+Then, after the frontend is running, run `yarn ssl-proxy` **in a new terminal window** and access the application at [https://localhost:3001](https://localhost:3001).
\ No newline at end of file
diff --git a/backend/Pipfile b/backend/Pipfile
index c97c74300..c54f42e0b 100644
--- a/backend/Pipfile
+++ b/backend/Pipfile
@@ -36,7 +36,7 @@ django-runtime-options = "*"
social-auth-app-django = "*"
django-redis = "*"
channels-redis = "*"
-uwsgi = {version = "*", sys_platform = "== 'linux'"}
+uwsgi = {version ="==2.0.24", sys_platform = "== 'linux'"}
uvloop = {version = "*", sys_platform = "== 'linux'"}
uvicorn = {extras = ["standard"], version = "*"}
gunicorn = "*"
@@ -54,6 +54,8 @@ pandas = "*"
drf-excel = "*"
numpy = "*"
inflection = "*"
+cybersource-rest-client-python = "*"
+pyjwt = "*"
[requires]
python_version = "3.11"
diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock
index cdecb8eda..9bac992aa 100644
--- a/backend/Pipfile.lock
+++ b/backend/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "4057df7b74f8c1e641c8f0fad1954492c21eac3651768db019f8d174749a018e"
+ "sha256": "745dba9015c559c2b445cbcdd35bee72beea730749e1838627c860946f971af9"
},
"pipfile-spec": 6,
"requires": {
@@ -321,41 +321,39 @@
},
"cryptography": {
"hashes": [
- "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee",
- "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576",
- "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d",
- "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30",
- "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413",
- "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb",
- "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da",
- "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4",
- "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd",
- "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc",
- "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8",
- "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1",
- "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc",
- "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e",
- "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8",
- "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940",
- "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400",
- "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7",
- "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16",
- "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278",
- "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74",
- "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec",
- "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1",
- "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2",
- "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c",
- "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922",
- "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a",
- "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6",
- "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1",
- "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e",
- "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac",
- "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"
+ "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960",
+ "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a",
+ "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc",
+ "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a",
+ "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf",
+ "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1",
+ "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39",
+ "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406",
+ "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a",
+ "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a",
+ "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c",
+ "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be",
+ "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15",
+ "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2",
+ "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d",
+ "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157",
+ "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003",
+ "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248",
+ "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a",
+ "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec",
+ "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309",
+ "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7",
+ "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"
],
"markers": "python_version >= '3.7'",
- "version": "==42.0.5"
+ "version": "==41.0.7"
+ },
+ "cybersource-rest-client-python": {
+ "hashes": [
+ "sha256:f1843572ddf5da67a0b70a29f0b6bc936e6bcdb8f1939912f0ea3b98b82d74f1"
+ ],
+ "index": "pypi",
+ "version": "==0.0.53"
},
"daphne": {
"hashes": [
@@ -365,6 +363,14 @@
"markers": "python_version >= '3.8'",
"version": "==4.1.2"
},
+ "datetime": {
+ "hashes": [
+ "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120",
+ "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==5.5"
+ },
"defusedxml": {
"hashes": [
"sha256:138c7d540a78775182206c7c97fe65b246a2f40b29471e1a2f1b0da76e7a3942",
@@ -1196,20 +1202,60 @@
"markers": "python_version >= '3.8'",
"version": "==2.22"
},
+ "pycryptodome": {
+ "hashes": [
+ "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690",
+ "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7",
+ "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4",
+ "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd",
+ "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5",
+ "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc",
+ "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818",
+ "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab",
+ "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d",
+ "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a",
+ "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25",
+ "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091",
+ "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea",
+ "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a",
+ "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c",
+ "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72",
+ "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9",
+ "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6",
+ "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044",
+ "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04",
+ "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c",
+ "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e",
+ "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f",
+ "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b",
+ "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4",
+ "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33",
+ "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f",
+ "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e",
+ "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a",
+ "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2",
+ "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3",
+ "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==3.20.0"
+ },
"pyjwt": {
"hashes": [
"sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de",
"sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"
],
+ "index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==2.8.0"
},
"pyopenssl": {
"hashes": [
- "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad",
- "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f"
+ "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2",
+ "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac"
],
- "version": "==24.1.0"
+ "markers": "python_version >= '3.6'",
+ "version": "==23.2.0"
},
"pypng": {
"hashes": [
@@ -1365,11 +1411,11 @@
},
"setuptools": {
"hashes": [
- "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e",
- "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"
+ "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987",
+ "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"
],
"markers": "python_version >= '3.8'",
- "version": "==69.2.0"
+ "version": "==69.5.1"
},
"six": {
"hashes": [
@@ -1414,11 +1460,11 @@
},
"sqlparse": {
"hashes": [
- "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3",
- "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"
+ "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93",
+ "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"
],
- "markers": "python_version >= '3.5'",
- "version": "==0.4.4"
+ "markers": "python_version >= '3.8'",
+ "version": "==0.5.0"
},
"tatsu": {
"hashes": [
@@ -1519,7 +1565,7 @@
"sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
],
- "markers": "python_version >= '3.6'",
+ "markers": "python_version >= '3.8'",
"version": "==2.2.1"
},
"uvicorn": {
@@ -1749,7 +1795,7 @@
],
"version": "==12.0"
},
- "zope-interface": {
+ "zope.interface": {
"hashes": [
"sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e",
"sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf",
@@ -2074,11 +2120,11 @@
},
"sqlparse": {
"hashes": [
- "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3",
- "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"
+ "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93",
+ "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"
],
- "markers": "python_version >= '3.5'",
- "version": "==0.4.4"
+ "markers": "python_version >= '3.8'",
+ "version": "==0.5.0"
},
"unittest-xml-reporting": {
"hashes": [
diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py
index d594e38c9..0535fc7b3 100644
--- a/backend/clubs/admin.py
+++ b/backend/clubs/admin.py
@@ -22,6 +22,7 @@
ApplicationSubmission,
Asset,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -50,6 +51,8 @@
TargetStudentType,
TargetYear,
Testimonial,
+ Ticket,
+ TicketTransactionRecord,
Year,
ZoomMeetingVisit,
)
@@ -451,4 +454,7 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin):
admin.site.register(Year, YearAdmin)
admin.site.register(ZoomMeetingVisit, ZoomMeetingVisitAdmin)
admin.site.register(AdminNote)
+admin.site.register(Ticket)
+admin.site.register(TicketTransactionRecord)
+admin.site.register(Cart)
admin.site.register(ApplicationCycle)
diff --git a/backend/clubs/management/commands/populate.py b/backend/clubs/management/commands/populate.py
index 5c8dd77c5..892865ce5 100644
--- a/backend/clubs/management/commands/populate.py
+++ b/backend/clubs/management/commands/populate.py
@@ -15,6 +15,7 @@
ApplicationQuestion,
ApplicationSubmission,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -28,6 +29,7 @@
StudentType,
Tag,
Testimonial,
+ Ticket,
Year,
)
@@ -759,4 +761,35 @@ def get_image(url):
first_mship.save()
count += 1
+ # Add tickets
+
+ hr = Club.objects.get(code="harvard-rejects")
+
+ hr_events = Event.objects.filter(club=hr)
+
+ for idx, e in enumerate(hr_events[:3]):
+ # Switch up person every so often
+ person = ben if idx < 2 else user_objs[1]
+
+ # Create some unowned tickets
+ Ticket.objects.bulk_create(
+ [Ticket(event=e, type="Regular", price=10.10) for _ in range(10)]
+ )
+
+ Ticket.objects.bulk_create(
+ [Ticket(event=e, type="Premium", price=100.10) for _ in range(5)]
+ )
+
+ # Create some owned tickets and tickets in cart
+ for i in range((idx + 1) * 10):
+ if i % 5:
+ Ticket.objects.create(
+ event=e, owner=person, type="Regular", price=i
+ )
+ else:
+ c, _ = Cart.objects.get_or_create(owner=person)
+ c.tickets.add(
+ Ticket.objects.create(event=e, type="Premium", price=i)
+ )
+
self.stdout.write("Finished populating database!")
diff --git a/backend/clubs/migrations/0091_cart_ticket.py b/backend/clubs/migrations/0091_cart_ticket.py
new file mode 100644
index 000000000..7441013da
--- /dev/null
+++ b/backend/clubs/migrations/0091_cart_ticket.py
@@ -0,0 +1,90 @@
+# Generated by Django 3.2.8 on 2022-11-12 20:05
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("clubs", "0090_auto_20230106_1443"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Cart",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "owner",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="cart",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Ticket",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("type", models.CharField(max_length=100)),
+ ("holding_expiration", models.DateTimeField(blank=True, null=True)),
+ (
+ "carts",
+ models.ManyToManyField(
+ blank=True, related_name="tickets", to="clubs.Cart"
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="tickets",
+ to="clubs.event",
+ ),
+ ),
+ (
+ "holder",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="held_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "owner",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="owned_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/backend/clubs/migrations/0092_auto_20221118_1424.py b/backend/clubs/migrations/0092_auto_20221118_1424.py
new file mode 100644
index 000000000..3a799d29d
--- /dev/null
+++ b/backend/clubs/migrations/0092_auto_20221118_1424.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.15 on 2022-11-18 19:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0091_cart_ticket"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="historicalclub",
+ options={
+ "get_latest_by": ("history_date", "history_id"),
+ "ordering": ("-history_date", "-history_id"),
+ "verbose_name": "historical club",
+ "verbose_name_plural": "historical clubs",
+ },
+ ),
+ migrations.AlterField(
+ model_name="historicalclub",
+ name="history_date",
+ field=models.DateTimeField(db_index=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0095_merge_20240128_1321.py b/backend/clubs/migrations/0095_merge_20240128_1321.py
new file mode 100644
index 000000000..7254d57ce
--- /dev/null
+++ b/backend/clubs/migrations/0095_merge_20240128_1321.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.18 on 2024-01-28 18:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0092_auto_20221118_1424"),
+ ("clubs", "0094_applicationcycle_release_date"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0096_merge_20240304_1450.py b/backend/clubs/migrations/0096_merge_20240304_1450.py
new file mode 100644
index 000000000..f3b45a75f
--- /dev/null
+++ b/backend/clubs/migrations/0096_merge_20240304_1450.py
@@ -0,0 +1,14 @@
+# Generated by Django 5.0.3 on 2024-03-04 19:50
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0095_merge_20240128_1321"),
+ ("clubs", "0095_rm_field_add_count"),
+ ]
+
+ operations = [
+ ]
diff --git a/backend/clubs/migrations/0097_ticket_price.py b/backend/clubs/migrations/0097_ticket_price.py
new file mode 100644
index 000000000..5c6c5fa97
--- /dev/null
+++ b/backend/clubs/migrations/0097_ticket_price.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.3 on 2024-04-03 04:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0096_merge_20240304_1450"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="price",
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=5),
+ preserve_default=False,
+ ),
+ ]
diff --git a/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py
new file mode 100644
index 000000000..4e6ea6e7e
--- /dev/null
+++ b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.0.3 on 2024-04-14 20:25
+
+import django.db.models.deletion
+import phonenumber_field.modelfields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0097_ticket_price"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TicketTransactionRecord",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "reconciliation_id",
+ models.CharField(blank=True, max_length=100, null=True),
+ ),
+ ("total_amount", models.DecimalField(decimal_places=2, max_digits=5)),
+ (
+ "buyer_phone",
+ phonenumber_field.modelfields.PhoneNumberField(
+ blank=True, max_length=128, null=True, region=None
+ ),
+ ),
+ ("buyer_first_name", models.CharField(max_length=100)),
+ ("buyer_last_name", models.CharField(max_length=100)),
+ (
+ "buyer_email",
+ models.EmailField(blank=True, max_length=254, null=True),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="ticket",
+ name="transaction_record",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="tickets",
+ to="clubs.tickettransactionrecord",
+ ),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0100_merge_20240412_2206.py b/backend/clubs/migrations/0100_merge_20240412_2206.py
new file mode 100644
index 000000000..090b09a4a
--- /dev/null
+++ b/backend/clubs/migrations/0100_merge_20240412_2206.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.0.4 on 2024-04-13 02:06
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0096_merge_20240304_1450"),
+ ("clubs", "0099_remove_club_elo_remove_historicalclub_elo_and_more"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0101_merge_20240414_1708.py b/backend/clubs/migrations/0101_merge_20240414_1708.py
new file mode 100644
index 000000000..b694a9b16
--- /dev/null
+++ b/backend/clubs/migrations/0101_merge_20240414_1708.py
@@ -0,0 +1,12 @@
+# Generated by Django 5.0.3 on 2024-04-14 21:08
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0098_tickettransactionrecord_ticket_transaction_record"),
+ ("clubs", "0100_merge_20240412_2206"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0102_event_ticket_order_limit.py b/backend/clubs/migrations/0102_event_ticket_order_limit.py
new file mode 100644
index 000000000..79515fad4
--- /dev/null
+++ b/backend/clubs/migrations/0102_event_ticket_order_limit.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-14 22:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0101_merge_20240414_1708"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="event",
+ name="ticket_order_limit",
+ field=models.IntegerField(default=10),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py
new file mode 100644
index 000000000..a22e2a541
--- /dev/null
+++ b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.4 on 2024-04-16 16:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0102_event_ticket_order_limit"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="group_discount",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ max_digits=3,
+ default=0,
+ ),
+ ),
+ migrations.AddField(
+ model_name="ticket",
+ name="group_size",
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0104_cart_checkout_context.py b/backend/clubs/migrations/0104_cart_checkout_context.py
new file mode 100644
index 000000000..3f7c3f220
--- /dev/null
+++ b/backend/clubs/migrations/0104_cart_checkout_context.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-21 00:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0103_ticket_group_discount_ticket_group_size"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="cart",
+ name="checkout_context",
+ field=models.CharField(blank=True, max_length=8297, null=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0105_ticket_attended.py b/backend/clubs/migrations/0105_ticket_attended.py
new file mode 100644
index 000000000..28ce3cc12
--- /dev/null
+++ b/backend/clubs/migrations/0105_ticket_attended.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0.4 on 2024-04-22 03:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0104_cart_checkout_context"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="attended",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/backend/clubs/models.py b/backend/clubs/models.py
index b59439629..d13c87ade 100644
--- a/backend/clubs/models.py
+++ b/backend/clubs/models.py
@@ -1,17 +1,21 @@
+import base64
import datetime
import os
import re
import uuid
import warnings
+from io import BytesIO
from urllib.parse import urlparse
import pytz
+import qrcode
import requests
import yaml
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
+from django.core.signing import Signer
from django.core.validators import validate_email
from django.db import models, transaction
from django.db.models import Sum
@@ -325,7 +329,6 @@ class Club(models.Model):
# cache club aggregation counts
favorite_count = models.IntegerField(default=0)
membership_count = models.IntegerField(default=0)
-
# cache club rankings
rank = models.IntegerField(default=0)
@@ -922,6 +925,7 @@ class Event(models.Model):
parent_recurring_event = models.ForeignKey(
RecurringEvent, on_delete=models.CASCADE, blank=True, null=True
)
+ ticket_order_limit = models.IntegerField(default=10)
OTHER = 0
RECRUITMENT = 1
@@ -952,6 +956,10 @@ def create_thumbnail(self, request=None):
def __str__(self):
return self.name
+ @property
+ def tickets_count(self):
+ return Ticket.objects.count(event=self)
+
class Favorite(models.Model):
"""
@@ -1774,6 +1782,136 @@ class Meta:
unique_together = (("question", "submission"),)
+class Cart(models.Model):
+ """
+ Represents an instance of a ticket cart for a user
+ """
+
+ owner = models.OneToOneField(
+ get_user_model(), related_name="cart", on_delete=models.CASCADE
+ )
+ # Capture context from Cybersource should be 8297 chars
+ checkout_context = models.CharField(max_length=8297, blank=True, null=True)
+
+
+class TicketManager(models.Manager):
+ # Update holds for all tickets
+ def update_holds(self):
+ expired_tickets = self.select_for_update().filter(
+ holder__isnull=False, holding_expiration__lte=timezone.now()
+ )
+
+ if not expired_tickets:
+ return
+
+ with transaction.atomic():
+ for ticket in expired_tickets:
+ ticket.holder = None
+ self.bulk_update(expired_tickets, ["holder"])
+
+
+class TicketTransactionRecord(models.Model):
+ """
+ Represents an instance of a transaction record for an ticket, used for bookkeeping
+ """
+
+ reconciliation_id = models.CharField(max_length=100, null=True, blank=True)
+ total_amount = models.DecimalField(max_digits=5, decimal_places=2)
+ buyer_phone = PhoneNumberField(null=True, blank=True)
+ buyer_first_name = models.CharField(max_length=100)
+ buyer_last_name = models.CharField(max_length=100)
+ buyer_email = models.EmailField(blank=True, null=True)
+
+
+class Ticket(models.Model):
+ """
+ Represents an instance of a ticket for an event
+ """
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ event = models.ForeignKey(
+ Event, related_name="tickets", on_delete=models.DO_NOTHING
+ )
+ type = models.CharField(max_length=100)
+ owner = models.ForeignKey(
+ get_user_model(),
+ related_name="owned_tickets",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ holder = models.ForeignKey(
+ get_user_model(),
+ related_name="held_tickets",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ holding_expiration = models.DateTimeField(null=True, blank=True)
+ carts = models.ManyToManyField(Cart, related_name="tickets", blank=True)
+ price = models.DecimalField(max_digits=5, decimal_places=2)
+ group_discount = models.DecimalField(
+ max_digits=3,
+ decimal_places=2,
+ default=0,
+ blank=True,
+ )
+ group_size = models.PositiveIntegerField(null=True, blank=True)
+ transaction_record = models.ForeignKey(
+ TicketTransactionRecord,
+ related_name="tickets",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ attended = models.BooleanField(default=False)
+ objects = TicketManager()
+
+ def get_qr(self):
+ """
+ Return a QR code image linking to the ticket page
+ """
+ if not self.owner:
+ return None
+
+ signer = Signer()
+ token = signer.sign_object({"owner": self.owner.id, "ticket_id": str(self.id)})
+ qr_image = qrcode.make(
+ f"https://{settings.DOMAINS[0]}/api/tickets/validate/{token}/",
+ box_size=20,
+ border=0,
+ )
+ return qr_image
+
+ def send_confirmation_email(self):
+ """
+ Send a confirmation email to the ticket owner after purchase
+ """
+ owner = self.owner
+
+ output = BytesIO()
+ qr_image = self.get_qr()
+ qr_image.save(output, format="PNG")
+ decoded_image = base64.b64encode(output.getvalue()).decode("ascii")
+
+ context = {
+ "first_name": self.owner.first_name,
+ "name": self.event.name,
+ "type": self.type,
+ "start_time": self.event.start_time,
+ "end_time": self.event.end_time,
+ "qr": decoded_image,
+ }
+
+ if self.owner.email:
+ send_mail_helper(
+ name="ticket_confirmation",
+ subject=f"Ticket confirmation for {owner.get_full_name()}",
+ emails=[owner.email],
+ context=context,
+ )
+
+
@receiver(models.signals.pre_delete, sender=Asset)
def asset_delete_cleanup(sender, instance, **kwargs):
if instance.file:
diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py
index 266d3686e..d03a05573 100644
--- a/backend/clubs/permissions.py
+++ b/backend/clubs/permissions.py
@@ -187,7 +187,7 @@ def has_permission(self, request, view):
class EventPermission(permissions.BasePermission):
"""
- Officers and above can create/update/delete events.
+ Officers and above can create/update/delete events and view ticket buyers.
Everyone else can view and list events.
"""
@@ -223,7 +223,13 @@ def has_object_permission(self, request, view, obj):
if not old_type == FAIR_TYPE and new_type == FAIR_TYPE:
return False
-
+ elif view.action in ["buyers", "create_tickets"]:
+ if not request.user.is_authenticated:
+ return False
+ membership = find_membership_helper(request.user, obj.club)
+ return membership is not None and membership.role <= Membership.ROLE_OFFICER
+ elif view.action in ["add_to_cart", "remove_from_cart"]:
+ return request.user.is_authenticated
return True
diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py
index f6d5a80eb..04d1d70ab 100644
--- a/backend/clubs/serializers.py
+++ b/backend/clubs/serializers.py
@@ -55,6 +55,7 @@
TargetStudentType,
TargetYear,
Testimonial,
+ Ticket,
Year,
)
from clubs.utils import clean
@@ -362,8 +363,12 @@ class ClubEventSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField("get_image_url")
large_image_url = serializers.SerializerMethodField("get_large_image_url")
url = serializers.SerializerMethodField("get_event_url")
+ ticketed = serializers.SerializerMethodField("get_ticketed")
creator = serializers.HiddenField(default=serializers.CurrentUserDefault())
+ def get_ticketed(self, obj) -> bool:
+ return obj.tickets.count() > 0
+
def get_event_url(self, obj):
# if no url, return that
if not obj.url:
@@ -501,6 +506,7 @@ class Meta:
"location",
"name",
"start_time",
+ "ticketed",
"type",
"url",
]
@@ -1745,6 +1751,22 @@ class Meta:
fields = ("club", "role", "title", "active", "public")
+class TicketSerializer(serializers.ModelSerializer):
+ """
+ Used to return a ticket object
+ """
+
+ owner = serializers.SerializerMethodField("get_owner_name")
+ event = EventSerializer()
+
+ def get_owner_name(self, obj):
+ return obj.owner.get_full_name() if obj.owner else "None"
+
+ class Meta:
+ model = Ticket
+ fields = ("id", "event", "type", "owner", "price")
+
+
class UserUUIDSerializer(serializers.ModelSerializer):
"""
Used to get the uuid of a user (for ICS Calendar export)
@@ -2807,6 +2829,7 @@ def save(self):
"You cannot edit committees once the application is open"
)
# nasty hack for idempotency
+ prev_committee_names = prev_committees.values("name")
for prev_committee in prev_committees:
if prev_committee.name not in committees:
prev_committee.delete()
diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py
index 69d517551..4395aa838 100644
--- a/backend/clubs/urls.py
+++ b/backend/clubs/urls.py
@@ -43,6 +43,7 @@
SubscribeViewSet,
TagViewSet,
TestimonialViewSet,
+ TicketViewSet,
UserGroupAPIView,
UserPermissionAPIView,
UserUpdateAPIView,
@@ -69,6 +70,7 @@
router.register(r"searches", SearchQueryViewSet, basename="searches")
router.register(r"memberships", MembershipViewSet, basename="members")
router.register(r"requests", MembershipRequestViewSet, basename="requests")
+router.register(r"tickets", TicketViewSet, basename="tickets")
router.register(r"schools", SchoolViewSet, basename="schools")
router.register(r"majors", MajorViewSet, basename="majors")
diff --git a/backend/clubs/views.py b/backend/clubs/views.py
index 6ded99725..3f0d04f6b 100644
--- a/backend/clubs/views.py
+++ b/backend/clubs/views.py
@@ -1,4 +1,5 @@
import argparse
+import base64
import collections
import datetime
import functools
@@ -8,14 +9,23 @@
import re
import secrets
import string
+from functools import wraps
+from typing import Tuple
from urllib.parse import urlparse
+import jwt
import pandas as pd
import pytz
import qrcode
import requests
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
+from CyberSource import (
+ PaymentsApi,
+ TransientTokenDataApi,
+ UnifiedCheckoutCaptureContextApi,
+)
+from CyberSource.rest import ApiException
from dateutil.parser import parse
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -27,7 +37,9 @@
from django.core.mail import EmailMultiAlternatives
from django.core.management import call_command, get_commands, load_command_class
from django.core.serializers.json import DjangoJSONEncoder
+from django.core.signing import BadSignature, Signer
from django.core.validators import validate_email
+from django.db import transaction
from django.db.models import (
Case,
CharField,
@@ -35,6 +47,7 @@
DurationField,
ExpressionWrapper,
F,
+ Max,
Prefetch,
Q,
TextField,
@@ -80,6 +93,7 @@
ApplicationSubmission,
Asset,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -102,6 +116,8 @@
Subscribe,
Tag,
Testimonial,
+ Ticket,
+ TicketTransactionRecord,
Year,
ZoomMeetingVisit,
get_mail_type_annotation,
@@ -172,6 +188,7 @@
SubscribeSerializer,
TagSerializer,
TestimonialSerializer,
+ TicketSerializer,
UserClubVisitSerializer,
UserClubVisitWriteSerializer,
UserMembershipInviteSerializer,
@@ -190,6 +207,19 @@
from clubs.utils import fuzzy_lookup_club, html_to_text
+def update_holds(func):
+ """
+ Decorator to update ticket holds
+ """
+
+ @wraps(func)
+ def wrap(self, request, *args, **kwargs):
+ Ticket.objects.update_holds()
+ return func(self, request, *args, **kwargs)
+
+ return wrap
+
+
def file_upload_endpoint_helper(request, code):
obj = get_object_or_404(Club, code=code)
if "file" in request.data and isinstance(request.data["file"], UploadedFile):
@@ -303,6 +333,24 @@ def hour_to_string_helper(hour):
return hour_string
+def validate_transient_token(cc: str, tt: str) -> Tuple[bool, str]:
+ """Validate the integrity of the transient token using
+ the public key (JWK) obtained from the capture context"""
+
+ try:
+ _, body, _ = cc.split(".")
+ decoded_body = json.loads(base64.b64decode(body + "==="))
+ jwk = decoded_body["flx"]["jwk"]
+
+ public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
+ # This will throw if the key is invalid
+ jwt.decode(tt, key=public_key, algorithms=["RS256"])
+ return (True, "Successfully decoded the JWT")
+
+ except Exception as e:
+ return (False, str(e))
+
+
class ReportViewSet(viewsets.ModelViewSet):
"""
retrieve:
@@ -1576,7 +1624,6 @@ def booths(self, request, *args, **kwargs):
"""
club = self.get_object()
res = ClubFairBooth.objects.filter(club=club).select_related("club").all()
-
return Response(ClubBoothSerializer(res, many=True).data)
def get_operation_id(self, **kwargs):
@@ -2264,6 +2311,19 @@ class ClubEventViewSet(viewsets.ModelViewSet):
destroy:
Delete an event.
+
+ tickets:
+ Get or create tickets for particular event
+
+ buyers:
+ Get information about the buyers of an event's ticket
+
+ remove_from_cart:
+ Remove a ticket for this event from cart
+
+ add_to_cart:
+ Add a ticket for this event to cart
+
"""
permission_classes = [EventPermission | IsSuperuser]
@@ -2284,6 +2344,368 @@ def get_serializer_class(self):
return EventWriteSerializer
return EventSerializer
+ @action(detail=True, methods=["post"])
+ @transaction.atomic
+ @update_holds
+ def add_to_cart(self, request, *args, **kwargs):
+ """
+ Add a certain number of tickets to the cart
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ success:
+ type: boolean
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ success:
+ type: boolean
+ ---
+ """
+ event = self.get_object()
+ cart, _ = Cart.objects.get_or_create(owner=self.request.user)
+
+ quantities = request.data.get("quantities")
+ if not quantities:
+ return Response(
+ {"detail": "Quantities must be specified"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ num_requested = sum(item["count"] for item in quantities)
+ num_carted = cart.tickets.filter(event=event).count()
+
+ if num_requested + num_carted > event.ticket_order_limit:
+ return Response(
+ {
+ "detail": f"Order exceeds the maximum ticket limit of "
+ f"{event.ticket_order_limit}."
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ for item in quantities:
+ type = item["type"]
+ count = item["count"]
+
+ # Count unowned/unheld tickets of requested type
+ # We don't need a lock since we aren't changing the holder or owner
+ tickets = (
+ Ticket.objects.filter(
+ event=event, type=type, owner__isnull=True, holder__isnull=True
+ )
+ .prefetch_related("carts")
+ .exclude(carts__owner=self.request.user)
+ )
+
+ if tickets.count() < count:
+ return Response(
+ {"detail": f"Not enough tickets of type {type} left!"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+ cart.tickets.add(*tickets[:count])
+
+ cart.save()
+ return Response({"detail": "Successfully added to cart"})
+
+ @action(detail=True, methods=["post"])
+ @transaction.atomic
+ @update_holds
+ def remove_from_cart(self, request, *args, **kwargs):
+ """
+ Remove a certain type/number of tickets from the cart
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ ---
+ """
+ event = self.get_object()
+ quantities = request.data.get("quantities")
+ if not quantities:
+ return Response(
+ {"detail": "Quantities must be specified"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ cart = get_object_or_404(Cart, owner=self.request.user)
+
+ for item in quantities:
+ type = item["type"]
+ count = item["count"]
+ tickets_to_remove = cart.tickets.filter(type=type, event=event)
+
+ # Ensure we don't try to remove more tickets than we can
+ count = min(count, tickets_to_remove.count())
+ cart.tickets.remove(*tickets_to_remove[:count])
+
+ cart.save()
+ return Response({"detail": "Successfully removed from cart"})
+
+ @action(detail=True, methods=["get"])
+ def buyers(self, request, *args, **kwargs):
+ """
+ Get information about ticket buyers
+ ---
+ requestBody: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ buyers:
+ type: array
+ items:
+ type: object
+ properties:
+ fullname:
+ type: string
+ id:
+ type: string
+ owner_id:
+ type: integer
+ type:
+ type: string
+ ---
+ """
+ tickets = Ticket.objects.filter(event=self.get_object()).annotate(
+ fullname=Concat("owner__first_name", Value(" "), "owner__last_name")
+ )
+
+ buyers = tickets.filter(owner__isnull=False).values(
+ "fullname", "id", "owner_id", "type", "owner__email"
+ )
+
+ return Response({"buyers": buyers})
+
+ @action(detail=True, methods=["get"])
+ def tickets(self, request, *args, **kwargs):
+ """
+ Get information about tickets for particular event
+ ---
+ requestBody: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ totals:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ available:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ ---
+ """
+ event = self.get_object()
+ tickets = Ticket.objects.filter(event=event)
+
+ # Take price of first ticket of given type for now
+ totals = (
+ tickets.values("type")
+ .annotate(price=Max("price"))
+ .annotate(count=Count("type"))
+ .order_by("type")
+ )
+ available = (
+ tickets.filter(owner__isnull=True, holder__isnull=True)
+ .values("type")
+ .annotate(price=Max("price"))
+ .annotate(count=Count("type"))
+ .order_by("type")
+ )
+ return Response({"totals": list(totals), "available": list(available)})
+
+ @tickets.mapping.put
+ @transaction.atomic
+ def create_tickets(self, request, *args, **kwargs):
+ """
+ Create ticket offerings for event
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ group_size:
+ type: number
+ required: false
+ group_discount:
+ type: number
+ required: false
+ order_limit:
+ type: int
+ required: false
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ "400":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ ---
+ """
+ event = self.get_object()
+
+ quantities = request.data.get("quantities", [])
+ if not quantities:
+ return Response(
+ {"detail": "Quantities must be specified"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ for item in quantities:
+ if not item.get("type") or not item.get("count"):
+ return Response(
+ {"detail": "Specify type and count to create some tickets."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Ticket prices must be non-negative
+ if item.get("price", 0) < 0:
+ return Response(
+ {"detail": "Ticket price cannot be negative"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Group discounts must be between 0 and 1
+ if item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1:
+ return Response(
+ {"detail": "Group discount must be between 0 and 1"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Min group sizes must be greater than 1
+ if item.get("group_size", 2) <= 1:
+ return Response(
+ {"detail": "Min group size must be greater than 1"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Tickets must specify both group_discount and group_size or neither
+ if ("group_discount" in item) != ("group_size" in item):
+ return Response(
+ {
+ "detail": (
+ "Ticket must specify either both group_discount "
+ "and group_size or neither"
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Atomicity ensures idempotency
+ Ticket.objects.filter(event=event).delete() # Idempotency
+ tickets = [
+ Ticket(
+ event=event,
+ type=item["type"],
+ price=item.get("price", 0),
+ group_discount=item.get("group_discount", 0),
+ group_size=item.get("group_size", None),
+ )
+ for item in quantities
+ for _ in range(item["count"])
+ ]
+
+ Ticket.objects.bulk_create(tickets)
+
+ order_limit = request.data.get("order_limit", None)
+ if order_limit is not None:
+ event.ticket_order_limit = order_limit
+ event.save()
+
+ return Response({"detail": "success"})
+
@action(detail=True, methods=["post"])
def upload(self, request, *args, **kwargs):
"""
@@ -2471,6 +2893,12 @@ class EventViewSet(ClubEventViewSet):
destroy:
Delete an event.
+
+ fair:
+ Get information about a fair listing
+
+ owned:
+ Return all events that the user has officer permissions over.
"""
def get_operation_id(self, **kwargs):
@@ -2653,6 +3081,28 @@ def owned(self, request, *args, **kwargs):
return Response(EventSerializer(events, many=True).data)
+ def destroy(self, request, *args, **kwargs):
+ """
+ Checks if there are tickets linked to this event before deletion.
+ """
+ event = self.get_object()
+ now = timezone.now()
+
+ if (
+ now <= event.end_time
+ and Ticket.objects.filter(event=event, owner__isnull=False).exists()
+ ):
+ return Response(
+ {
+ "detail": "Cannot delete event with active tickets \
+ associated with it."
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Proceed with deletion if no active tickets are linked
+ return super().destroy(request, *args, **kwargs)
+
class TestimonialViewSet(viewsets.ModelViewSet):
"""
@@ -4285,6 +4735,523 @@ def get_object(self):
return user
+class TicketViewSet(viewsets.ModelViewSet):
+ """
+ get:
+ Get a specific ticket owned by a user
+
+ list:
+ List all tickets owned by a user
+
+ cart:
+ List all unowned/unheld tickets currently in a user's cart
+
+ initiate_checkout:
+ Initiate a hold on the tickets in a user's cart and create a capture context
+
+ complete_checkout:
+ Complete the checkout process after we have obtained an auth on a user's card
+
+ qr:
+ Get a ticket's QR code
+
+ validate:
+ Validate a ticket's QR code and mark attendance
+ """
+
+ permission_classes = [IsAuthenticated]
+ serializer_class = TicketSerializer
+ http_method_names = ["get", "post"]
+ lookup_field = "id"
+
+ @transaction.atomic
+ @update_holds
+ @action(detail=False, methods=["get"])
+ def cart(self, request, *args, **kwargs):
+ """
+ Validate tickets in a cart and return them
+ ---
+ requestBody:
+ content: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tickets:
+ allOf:
+ - $ref: "#/components/schemas/Ticket"
+ sold_out:
+ type: integer
+ ---
+ """
+
+ cart, _ = Cart.objects.prefetch_related("tickets").get_or_create(
+ owner=self.request.user
+ )
+
+ # Replace in-cart tickets that have been bought/held by someone else
+ tickets_to_replace = cart.tickets.filter(
+ Q(owner__isnull=False) | Q(holder__isnull=False)
+ ).exclude(holder=self.request.user)
+
+ # In most cases, we won't need to replace, so exit early
+ if not tickets_to_replace.exists():
+ return Response(
+ {
+ "tickets": TicketSerializer(cart.tickets.all(), many=True).data,
+ "sold_out": 0,
+ },
+ )
+
+ sold_out_count = 0
+
+ replacement_tickets = []
+ tickets_in_cart = cart.tickets.values_list("id", flat=True)
+ for ticket_class in tickets_to_replace.values("type", "event").annotate(
+ count=Count("id")
+ ):
+ # We don't need to lock since we aren't updating holder/owner
+ tickets = Ticket.objects.filter(
+ event=ticket_class["event"],
+ type=ticket_class["type"],
+ owner__isnull=True,
+ holder__isnull=True,
+ ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]]
+
+ sold_out_count += ticket_class["count"] - tickets.count()
+ replacement_tickets.extend(list(tickets))
+
+ cart.tickets.remove(*tickets_to_replace)
+ if replacement_tickets:
+ cart.tickets.add(*replacement_tickets)
+ cart.save()
+
+ return Response(
+ {
+ "tickets": TicketSerializer(cart.tickets.all(), many=True).data,
+ "sold_out": sold_out_count,
+ },
+ )
+
+ @action(detail=False, methods=["post"])
+ @update_holds
+ @transaction.atomic
+ def initiate_checkout(self, request, *args, **kwargs):
+ """
+ Checkout all tickets in cart and create a Cybersource capture context
+
+ NOTE: this does NOT buy tickets, it simply initiates a checkout process
+ which includes a 10-minute ticket hold
+
+ Once the user has entered their payment details and submitted the form
+ the request will be routed to complete_checkout
+
+ 403 implies a stale cart.
+ ---
+ requestBody: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ success:
+ type: boolean
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ success:
+ type: boolean
+ ---
+ """
+ cart = get_object_or_404(Cart, owner=self.request.user)
+
+ # Cart must have at least one ticket
+ if not cart.tickets.exists():
+ return Response(
+ {
+ "success": False,
+ "detail": "No tickets selected for checkout.",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # skip_locked is important here because if any of the tickets in cart
+ # are locked, we shouldn't block.
+ tickets = cart.tickets.select_for_update(skip_locked=True).filter(
+ Q(holder__isnull=True) | Q(holder=self.request.user), owner__isnull=True
+ )
+
+ # Assert that the filter succeeded in freezing all the tickets for checkout
+ if tickets.count() != cart.tickets.count():
+ return Response(
+ {
+ "success": False,
+ "detail": "Cart is stale, invoke /api/tickets/cart to refresh",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # Calculate cart total, applying group discounts where appropriate
+ ticket_type_counts = {
+ item["type"]: item["count"]
+ for item in cart.tickets.values("type").annotate(count=Count("type"))
+ }
+
+ cart_total = sum(
+ ticket.price * (1 - ticket.group_discount)
+ if ticket.group_size
+ and ticket_type_counts[ticket.type] >= ticket.group_size
+ else ticket.price
+ for ticket in tickets
+ )
+
+ if not cart_total:
+ return Response(
+ {
+ "success": False,
+ "detail": "Cart total must be nonzero to generate capture context.",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ capture_context_request = {
+ "_target_origins": [settings.CYBERSOURCE_TARGET_ORIGIN],
+ "_client_version": settings.CYBERSOURCE_CLIENT_VERSION,
+ "_allowed_card_networks": [
+ "VISA",
+ "MASTERCARD",
+ "AMEX",
+ "DISCOVER",
+ ],
+ "_allowed_payment_types": ["PANENTRY", "SRC"],
+ "_country": "US",
+ "_locale": "en_US",
+ "_capture_mandate": {
+ "_billing_type": "FULL",
+ "_request_email": True,
+ "_request_phone": True,
+ "_request_shipping": True,
+ "_show_accepted_network_icons": True,
+ },
+ "_order_information": {
+ "_amount_details": {
+ "_total_amount": f"{cart_total:.2f}",
+ "_currency": "USD",
+ }
+ },
+ }
+
+ try:
+ context, http_status, _ = UnifiedCheckoutCaptureContextApi(
+ settings.CYBERSOURCE_CONFIG
+ ).generate_unified_checkout_capture_context_with_http_info(
+ json.dumps(capture_context_request)
+ )
+ if not context or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {context} with HTTP status {http_status}",
+ )
+
+ # Tie generated capture context to user cart
+ if cart.checkout_context != context:
+ cart.checkout_context = context
+ cart.save()
+
+ # Place hold on tickets for 10 mins
+ holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
+ tickets.update(
+ holder=self.request.user, holding_expiration=holding_expiration
+ )
+
+ return Response({"success": True, "detail": context})
+ except ApiException as e:
+ return Response(
+ {
+ "success": False,
+ "detail": f"Unable to generate capture context: {e}",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ @action(detail=False, methods=["post"])
+ @update_holds
+ @transaction.atomic
+ def complete_checkout(self, request, *args, **kwargs):
+ """
+ Complete the checkout after the user has entered their payment details
+ and obtained a transient token on the frontend.
+
+ 403 implies a stale cart.
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ transient_token:
+ type: string
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ success:
+ type: boolean
+ ---
+ """
+ tt = request.data.get("transient_token")
+ cart = get_object_or_404(
+ Cart.objects.prefetch_related("tickets"), owner=self.request.user
+ )
+
+ cc = cart.checkout_context
+ if cc is None:
+ return Response(
+ {"success": False, "detail": "Associated capture context not found"},
+ status=status.HTTP_500_BAD_REQUEST,
+ )
+
+ ok, message = validate_transient_token(cc, tt)
+ if not ok:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+ return Response(
+ {"success": False, "detail": message},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ # Guard against holds expiring before the capture context
+ tickets = cart.tickets.filter(holder=self.request.user, owner__isnull=True)
+ if tickets.count() != cart.tickets.count():
+ return Response(
+ {
+ "success": False,
+ "detail": "Cart is stale, invoke /api/tickets/cart to refresh",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ try:
+ _, http_status, transaction_data = TransientTokenDataApi(
+ settings.CYBERSOURCE_CONFIG
+ ).get_transaction_for_transient_token(tt)
+
+ if not transaction_data or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {transaction_data} with HTTP status {http_status}"
+ )
+ transaction_data = json.loads(transaction_data)
+ except ApiException as e:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+
+ return Response(
+ {
+ "success": False,
+ "detail": f"Transaction failed: {e}",
+ },
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ create_payment_request = {"tokenInformation": {"transientTokenJwt": tt}}
+
+ try:
+ payment_response, http_status, _ = PaymentsApi(
+ settings.CYBERSOURCE_CONFIG
+ ).create_payment(json.dumps(create_payment_request))
+
+ if payment_response.status != "AUTHORIZED":
+ raise ApiException(reason="Payment response status is not authorized")
+ reconciliation_id = payment_response.reconciliation_id
+
+ if not payment_response or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {payment_response} with HTTP status {http_status}"
+ )
+ except ApiException as e:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+
+ return Response(
+ {
+ "success": False,
+ "detail": f"Transaction failed: {e}",
+ },
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ # Archive transaction data for historical purposes.
+ # We're explicitly using the response data over what's in self.request.user
+ orderInfo = transaction_data["orderInformation"]
+ transaction_record = TicketTransactionRecord.objects.create(
+ reconciliation_id=str(reconciliation_id),
+ total_amount=float(orderInfo["amountDetails"]["totalAmount"]),
+ buyer_first_name=orderInfo["billTo"]["firstName"],
+ buyer_last_name=orderInfo["billTo"]["lastName"],
+ buyer_phone=orderInfo["billTo"]["phoneNumber"],
+ buyer_email=orderInfo["billTo"]["email"],
+ )
+
+ # At this point, we have validated that the payment was authorized
+ # Give the tickets to the user
+ tickets = (
+ cart.tickets.select_for_update()
+ .filter(holder=self.request.user)
+ .prefetch_related("carts")
+ )
+ tickets.update(
+ owner=request.user, holder=None, transaction_record=transaction_record
+ )
+ cart.tickets.clear()
+ for ticket in tickets:
+ ticket.send_confirmation_email()
+
+ Ticket.objects.update_holds()
+
+ cart.checkout_context = None
+ cart.save()
+
+ return Response(
+ {
+ "success": True,
+ "detail": "Payment successful.",
+ }
+ )
+
+ @action(detail=True, methods=["get"])
+ def qr(self, request, *args, **kwargs):
+ """
+ Return a QR code png image representing a link to the ticket.
+ ---
+ operationId: Generate QR Code for ticket
+ responses:
+ "200":
+ description: Return a png image representing a QR code to the ticket.
+ content:
+ image/png:
+ schema:
+ type: binary
+ ---
+ """
+ ticket = self.get_object()
+ qr_image = ticket.get_qr()
+ response = HttpResponse(content_type="image/png")
+ qr_image.save(response, "PNG")
+ return response
+
+ @action(detail=False, methods=["get"], url_path="validate/(?P[^/.]+)")
+ def validate(self, request, *args, **kwargs):
+ """
+ Validate a ticket's QR code and mark attendance.
+ ---
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ticket:
+ $ref: '#/components/schemas/Ticket'
+ previously_scanned:
+ type: boolean
+ "400":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ ---
+ """
+ token = kwargs.get("token")
+ signer = Signer()
+ try:
+ obj = signer.unsign_object(token)
+ except BadSignature:
+ return Response(
+ {"detail": "Invalid token"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ ticket = (
+ Ticket.objects.filter(id=obj.get("ticket_id"))
+ .select_related("event__club")
+ .first()
+ )
+ if not ticket:
+ return Response(
+ {"detail": "Ticket not found"}, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ is_owner = request.user == ticket.owner
+ is_officer = Membership.objects.filter(
+ person=request.user,
+ club=ticket.event.club,
+ role__lte=Membership.ROLE_OFFICER,
+ active=True,
+ ).exists()
+
+ if not (is_owner or is_officer):
+ return Response(
+ {"detail": "You do not have permission to scan this ticket!"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ if ticket.owner.id != obj.get("owner"):
+ return Response(
+ {"detail": "Stale token"}, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ previously_scanned = ticket.attended
+ if not previously_scanned and is_officer:
+ ticket.attended = True
+ ticket.transferable = False
+ ticket.save()
+
+ return Response(
+ {
+ "ticket": TicketSerializer(ticket).data,
+ "previously_scanned": previously_scanned,
+ }
+ )
+
+ def get_queryset(self):
+ return Ticket.objects.filter(owner=self.request.user.id)
+
+
class MemberInviteViewSet(viewsets.ModelViewSet):
"""
update:
diff --git a/backend/pennclubs/settings/base.py b/backend/pennclubs/settings/base.py
index 2b71a8e24..cbdac66cd 100644
--- a/backend/pennclubs/settings/base.py
+++ b/backend/pennclubs/settings/base.py
@@ -255,3 +255,6 @@
PHONENUMBER_DB_FORMAT = "NATIONAL"
PHONENUMBER_DEFAULT_REGION = "US"
+
+# Cybersource settings
+CYBERSOURCE_CLIENT_VERSION = "0.15"
diff --git a/backend/pennclubs/settings/ci.py b/backend/pennclubs/settings/ci.py
index 417929077..0f2e707ed 100644
--- a/backend/pennclubs/settings/ci.py
+++ b/backend/pennclubs/settings/ci.py
@@ -20,3 +20,13 @@
# Allow http callback for DLA
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": "testrest",
+ "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda",
+ "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=",
+ "run_environment": "apitest.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001"
diff --git a/backend/pennclubs/settings/development.py b/backend/pennclubs/settings/development.py
index e578f4cfd..90c2d1504 100644
--- a/backend/pennclubs/settings/development.py
+++ b/backend/pennclubs/settings/development.py
@@ -17,7 +17,7 @@
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
# Allow requests from frontend
-CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"]
+CSRF_TRUSTED_ORIGINS = ["https://localhost:3001", "http://localhost:3000"]
# Use console email backend during development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
@@ -32,3 +32,13 @@
# Caching settings
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": "testrest",
+ "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda",
+ "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=",
+ "run_environment": "apitest.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001"
diff --git a/backend/pennclubs/settings/production.py b/backend/pennclubs/settings/production.py
index 2be4f552d..dcaf37299 100644
--- a/backend/pennclubs/settings/production.py
+++ b/backend/pennclubs/settings/production.py
@@ -74,3 +74,13 @@
"KEY_PREFIX": "django",
}
}
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": os.getenv("MERCHANT_ID"),
+ "merchant_keyid": os.getenv("MERCHANT_KEYID"),
+ "merchant_secretkey": os.getenv("MERCHANT_SECRETKEY"),
+ "run_environment": "api.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://pennclubs.com"
diff --git a/backend/templates/emails/ticket_confirmation.html b/backend/templates/emails/ticket_confirmation.html
new file mode 100644
index 000000000..7b2991e64
--- /dev/null
+++ b/backend/templates/emails/ticket_confirmation.html
@@ -0,0 +1,49 @@
+
+
+{% extends 'emails/base.html' %}
+
+{% block content %}
+Thanks for using Penn Clubs!
+
+
+ {{ first_name }}, thank you for your recent purchase of a ticket to {{ name }} with ticket type {{type }}.
+
+
+
+ As a reminder, the event starts at {{ start_time }} and ends at {{ end_time }}.
+
+
+
+
+
+ Please be 10 minutes early for a smooth seating experience.
+
+
+ Below is a
+ QR code for
+ your confirmation.
+
+
+
+
+ Note: all tickets issued by us are non-refundable and non-transferable.
+
+
+
+
+ If you have any questions, feel free to respond to this email.
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/tests/clubs/test_documentation.py b/backend/tests/clubs/test_documentation.py
index 01865073a..67bfe8170 100644
--- a/backend/tests/clubs/test_documentation.py
+++ b/backend/tests/clubs/test_documentation.py
@@ -204,8 +204,8 @@ def test_openapi_docs(self):
)
if "application/json" in content["content"]:
json_content = content["content"]["application/json"]
- self.assertTrue("schema" in json_content)
try:
+ self.assertTrue("schema" in json_content)
self.verify_schema(json_content["schema"])
except AssertionError as e:
raise AssertionError(
diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py
new file mode 100644
index 000000000..c91266de9
--- /dev/null
+++ b/backend/tests/clubs/test_ticketing.py
@@ -0,0 +1,924 @@
+import json
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import timedelta
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.db.models import (
+ Count,
+)
+from django.test import TestCase
+from django.urls import reverse
+from django.utils import timezone
+from rest_framework.test import APIClient
+
+from clubs.models import Cart, Club, Event, Ticket, TicketTransactionRecord
+
+
+def commonSetUp(self):
+ self.client = APIClient()
+
+ self.user1 = get_user_model().objects.create_user(
+ "jadams", "jadams@sas.upenn.edu", "test"
+ )
+ self.user1.first_name = "John"
+ self.user1.last_name = "Adams"
+ self.user1.is_staff = True
+ self.user1.is_superuser = True
+ self.user1.save()
+
+ self.user2 = get_user_model().objects.create_user(
+ "bfranklin", "bfranklin@seas.upenn.edu", "test"
+ )
+ self.user2.first_name = "Benjamin"
+ self.user2.last_name = "Franklin"
+ self.user2.save()
+
+ self.club1 = Club.objects.create(
+ code="test-club",
+ name="Test Club",
+ approved=True,
+ email="example@example.com",
+ )
+
+ self.event1 = Event.objects.create(
+ code="test-event",
+ club=self.club1,
+ name="Test Event",
+ start_time=timezone.now() + timezone.timedelta(days=2),
+ end_time=timezone.now() + timezone.timedelta(days=3),
+ )
+
+ self.ticket_totals = [
+ {"type": "normal", "count": 20, "price": 15.0},
+ {"type": "premium", "count": 10, "price": 30.0},
+ ]
+
+ self.tickets1 = [
+ Ticket.objects.create(type="normal", event=self.event1, price=15.0)
+ for _ in range(20)
+ ]
+ self.tickets2 = [
+ Ticket.objects.create(type="premium", event=self.event1, price=30.0)
+ for _ in range(10)
+ ]
+
+
+class TicketEventTestCase(TestCase):
+ """
+ Test cases related to the methods on the EventViewSet
+ that correspond to the ticketing project:
+
+ tickets (get), tickets (put), buyers, add_to_cart, remove_from_cart
+ """
+
+ def setUp(self):
+ commonSetUp(self)
+
+ def test_create_ticket_offerings(self):
+ self.client.login(username=self.user1.username, password="test")
+ qts = {
+ "quantities": [
+ {"type": "_normal", "count": 20, "price": 10},
+ {"type": "_premium", "count": 10, "price": 20},
+ ]
+ }
+
+ resp = self.client.put(
+ reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
+ qts,
+ format="json",
+ )
+
+ aggregated_tickets = list(
+ Ticket.objects.filter(event=self.event1, type__contains="_")
+ .values("type", "price")
+ .annotate(count=Count("id"))
+ )
+ for t1, t2 in zip(qts["quantities"], aggregated_tickets):
+ self.assertEqual(t1["type"], t2["type"])
+ self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.02)
+ self.assertEqual(t1["count"], t2["count"])
+
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ def test_create_ticket_offerings_bad_perms(self):
+ # user2 is not a superuser or club officer+
+ self.client.login(username=self.user2.username, password="test")
+ qts = {
+ "quantities": [
+ {"type": "_normal", "count": 20, "price": 10},
+ {"type": "_premium", "count": 10, "price": 20},
+ ]
+ }
+
+ resp = self.client.put(
+ reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
+ qts,
+ format="json",
+ )
+
+ self.assertEqual(resp.status_code, 403, resp.content)
+
+ def test_create_ticket_offerings_bad_data(self):
+ self.client.login(username=self.user1.username, password="test")
+ bad_data = [
+ {
+ # Bad toplevel field
+ "quant1t13s": [
+ {"type": "_normal", "count": 20, "price": 10},
+ {"type": "_premium", "count": 10, "price": 20},
+ ]
+ },
+ {
+ "quantities": [
+ # Negative price
+ {"type": "_normal", "count": 20, "price": -10},
+ {"type": "_premium", "count": 10, "price": -20},
+ ]
+ },
+ {
+ "quantities": [
+ # Bad field members
+ {"abcd": "_normal", "abcde": 20, "price": -10},
+ ]
+ },
+ ]
+
+ for data in bad_data:
+ resp = self.client.put(
+ reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
+ data,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [400], resp.content)
+ self.assertEqual(Ticket.objects.filter(type__contains="_").count(), 0, data)
+
+ def test_get_tickets_information_no_tickets(self):
+ # Delete all the tickets
+ Ticket.objects.all().delete()
+
+ resp = self.client.get(
+ reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ data = resp.json()
+ self.assertEqual(data["totals"], [], data["totals"])
+ self.assertEqual(data["available"], [], data["available"])
+
+ def test_get_tickets_information(self):
+ # Buy all normal tickets
+ for ticket in self.tickets1:
+ ticket.owner = self.user1
+ ticket.save()
+
+ resp = self.client.get(
+ reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ data = resp.json()
+ self.assertEqual(data["totals"], self.ticket_totals, data["totals"])
+ self.assertEqual(
+ data["available"],
+ # Only premium tickets available
+ [t for t in self.ticket_totals if t["type"] == "premium"],
+ data["available"],
+ )
+
+ def test_get_tickets_buyers(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Buy all normal tickets
+ for ticket in self.tickets1:
+ ticket.owner = self.user1
+ ticket.save()
+
+ resp = self.client.get(
+ reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)),
+ )
+
+ data = resp.json()
+ # Assert ownership correctly determined
+ for owned_ticket in data["buyers"]:
+ self.assertEqual(owned_ticket["owner_id"], self.user1.id)
+
+ def test_get_tickets_buyers_bad_perms(self):
+ # user2 is not a superuser or club officer+
+ self.client.login(username=self.user2.username, password="test")
+ for ticket in self.tickets1:
+ ticket.owner = self.user1
+ ticket.save()
+
+ resp = self.client.get(
+ reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)),
+ )
+
+ self.assertEqual(resp.status_code, 403, resp)
+
+ def test_add_to_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 2},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.count(), 3, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets)
+
+ def test_add_to_cart_twice_accumulates(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Adding 3 tickets twice
+ for _ in range(2):
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 2},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse(
+ "club-events-add-to-cart", args=(self.club1.code, self.event1.pk)
+ ),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.count(), 6, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="normal").count(), 4, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="premium").count(), 2, cart.tickets)
+
+ def test_add_to_cart_order_limit_exceeded(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 200},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertEqual(resp.status_code, 403, resp.content)
+ self.assertIn(
+ "Order exceeds the maximum ticket limit of", resp.data["detail"], resp.data
+ )
+
+ def test_add_to_cart_tickets_unavailable(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Delete all but 1 ticket
+ for t in list(Ticket.objects.all())[:-1]:
+ t.delete()
+
+ # Try to add two tickets
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 2},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertEqual(resp.status_code, 403, resp.data)
+ self.assertIn(
+ "Not enough tickets of type normal left!", resp.data["detail"], resp.data
+ )
+
+ def test_remove_from_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 2},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.count(), 3, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets)
+
+ # Remove all but one from normal
+ tickets_to_remove = {
+ "quantities": [
+ {"type": "normal", "count": 1},
+ {"type": "premium", "count": 1},
+ ]
+ }
+
+ resp = self.client.post(
+ reverse(
+ "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk)
+ ),
+ tickets_to_remove,
+ format="json",
+ )
+
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.filter(type="normal").count(), 1, cart.tickets)
+
+ def test_remove_from_cart_extra(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 2},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.count(), 3, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets)
+ self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets)
+
+ # Remove more than what exists...still ok.
+ tickets_to_remove = {
+ "quantities": [
+ {"type": "normal", "count": 200},
+ {"type": "premium", "count": 100},
+ ]
+ }
+ resp = self.client.post(
+ reverse(
+ "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk)
+ ),
+ tickets_to_remove,
+ format="json",
+ )
+
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(cart.tickets.count(), 0, cart.tickets)
+
+
+@dataclass
+class MockPaymentResponse:
+ status: str = "AUTHORIZED"
+ reconciliation_id: str = "abced"
+
+
+@contextmanager
+def mock_cybersource_apis():
+ """Mock cybersource APIs and validate_transient_token"""
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context, patch(
+ ".".join(
+ [
+ "CyberSource",
+ "TransientTokenDataApi",
+ "get_transaction_for_transient_token",
+ ]
+ )
+ ) as fake_get_transaction, patch(
+ ".".join(
+ [
+ "CyberSource",
+ "PaymentsApi",
+ "create_payment",
+ ]
+ )
+ ) as fake_create_payment, patch(
+ "clubs.views.validate_transient_token"
+ ) as fake_validate_tt:
+ fake_validate_tt.return_value = (True, "")
+ fake_cap_context.return_value = "abcde", 200, None
+ fake_get_transaction.return_value = (
+ "",
+ 200,
+ json.dumps(
+ {
+ "orderInformation": {
+ "amountDetails": {
+ "totalAmount": 20,
+ },
+ "billTo": {
+ "firstName": "Rohan",
+ "lastName": "Gupta",
+ "phoneNumber": "3021239234",
+ "email": "r@g.com",
+ },
+ }
+ }
+ ),
+ )
+ fake_create_payment.return_value = MockPaymentResponse(), 200, ""
+ yield (
+ fake_cap_context,
+ fake_get_transaction,
+ fake_create_payment,
+ fake_validate_tt,
+ )
+
+
+class TicketTestCase(TestCase):
+ """
+ Test cases related to the methods on the TicketViewSet
+ that correspond to the ticketing project:
+
+ get, list, initiate_checkout, complete_checkout
+ """
+
+ def setUp(self):
+ commonSetUp(self)
+
+ def test_get_ticket_owned_by_me(self):
+ self.client.login(username=self.user1.username, password="test")
+ ticket = self.tickets1[0]
+
+ # Fail to get when not owned
+ resp = self.client.get(
+ reverse("tickets-detail", args=(ticket.id,)), format="json"
+ )
+ self.assertEqual(resp.status_code, 404, resp.content)
+
+ # Succeed when owned
+ ticket.owner = self.user1
+ ticket.save()
+ resp = self.client.get(
+ reverse("tickets-detail", args=(ticket.id,)), format="json"
+ )
+ data = resp.json()
+ self.assertEqual(resp.status_code, 200, resp.content)
+
+ # Test the serializer API
+ for field in ["price", "id", "type", "owner", "event"]:
+ self.assertIn(field, data, data)
+
+ def test_list_tickets_owned_by_me(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Fail to get when not owned
+ resp = self.client.get(reverse("tickets-list"), format="json")
+ self.assertEqual(resp.status_code, 200, resp.content)
+ self.assertEqual(resp.json(), [], resp)
+
+ # List all 5 tickets when owned
+ for ticket in self.tickets1[:5]:
+ ticket.owner = self.user1
+ ticket.save()
+
+ resp = self.client.get(reverse("tickets-list"), format="json")
+ data = resp.json()
+ self.assertEqual(resp.status_code, 200, resp.content)
+ self.assertEqual(len(data), 5, data)
+
+ def test_get_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:5]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ resp = self.client.get(reverse("tickets-cart"), format="json")
+ data = resp.json()
+
+ # None are sold out
+ self.assertEqual(len(data["tickets"]), 5, data)
+ for t1, t2 in zip(data["tickets"], tickets_to_add):
+ self.assertEqual(t1["id"], str(t2.id))
+ self.assertEqual(data["sold_out"], 0, data)
+
+ def test_get_cart_replacement_required(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:5]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ # Sell the first two
+ for selling_ticket in tickets_to_add[:2]:
+ selling_ticket.owner = self.user2
+ selling_ticket.save()
+
+ resp = self.client.get(reverse("tickets-cart"), format="json")
+ data = resp.json()
+
+ # The cart still has 5 tickets: just replaced with available ones
+ self.assertEqual(len(data["tickets"]), 5, data)
+ self.assertEqual(data["sold_out"], 0, data)
+
+ in_cart = set(map(lambda t: t["id"], data["tickets"]))
+ to_add = set(map(lambda t: str(t.id), tickets_to_add))
+
+ # 3 tickets are the same
+ self.assertEqual(len(in_cart & to_add), 3, in_cart | to_add)
+
+ def test_get_cart_replacement_required_sold_out(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:5]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ # There are 5 tickets in the cart. We will sell
+ # all but 3 tickets of this type to someone
+ # This should force 2 tickets reporting as sold out
+ for selling_ticket in self.tickets1[:-3]:
+ selling_ticket.owner = self.user2
+ selling_ticket.save()
+
+ resp = self.client.get(reverse("tickets-cart"), format="json")
+ data = resp.json()
+
+ # The cart now has 3 tickets
+ self.assertEqual(len(data["tickets"]), 3, data)
+
+ # 2 tickets have been sold out
+ self.assertEqual(data["sold_out"], 2, data)
+
+ in_cart = set(map(lambda t: t["id"], data["tickets"]))
+ to_add = set(map(lambda t: str(t.id), tickets_to_add))
+
+ # 0 tickets are the same (we sell all but last 3)
+ self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add)
+
+ def test_initiate_checkout(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 1},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ # Initiate checkout
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context:
+ cap_context_data = "abcde"
+ fake_cap_context.return_value = cap_context_data, 200, None
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ # Capture context should be tied to cart
+ cart = Cart.objects.filter(owner=self.user1).first()
+ self.assertIsNotNone(cart.checkout_context)
+ self.assertEqual(cart.checkout_context, cap_context_data)
+
+ # Tickets should be held
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 2, held_tickets)
+ self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets)
+ self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets)
+
+ def test_initiate_concurrent_checkouts(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add tickets to cart
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 1},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ # Initiate first checkout
+ cap_context_data = "abc"
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context:
+ fake_cap_context.return_value = cap_context_data, 200, None
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.filter(owner=self.user1).first()
+ cap_context_1 = cart.checkout_context
+
+ # Initiate second checkout
+ cap_context_data = "def" # simulate capture context changing between checkouts
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context:
+ fake_cap_context.return_value = cap_context_data, 200, None
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ cart = Cart.objects.filter(owner=self.user1).first()
+ cap_context_2 = cart.checkout_context
+
+ # Stored capture context should change between checkouts
+ self.assertNotEqual(cap_context_1, cap_context_2)
+
+ def test_initiate_checkout_fails_with_empty_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Assert non existent cart
+ cart, created = Cart.objects.get_or_create(owner=self.user1)
+ self.assertTrue(created)
+
+ # Initiate checkout, fail with 400
+ # NOTE: If the cart does not exist, we will have a 404
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context:
+ fake_cap_context.return_value = "abcde", 200, None
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertEquals(resp.status_code, 400, resp.content)
+
+ # Tickets are not held
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertFalse(held_tickets.exists())
+
+ def test_initiate_checkout_stale_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:5]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ # In the meantime, someone snipes a ticket we added by holding
+ sniped_ticket = self.tickets1[0]
+ sniped_ticket.holder = self.user2
+ sniped_ticket.save()
+
+ # Initiate checkout for the first time
+ with patch(
+ ".".join(
+ [
+ "CyberSource",
+ "UnifiedCheckoutCaptureContextApi",
+ "generate_unified_checkout_capture_context_with_http_info",
+ ]
+ )
+ ) as fake_cap_context:
+ fake_cap_context.return_value = "abcde", 200, None
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertEqual(resp.status_code, 403, resp.content)
+ self.assertIn("Cart is stale", resp.data["detail"], resp.data)
+
+ # Tickets are not held
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 0, held_tickets)
+
+ # Ok, so now we call /api/tickets/cart to refresh
+ resp = self.client.get(reverse("tickets-cart"), format="json")
+
+ # Initiate checkout again...this should work
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ # Tickets are held
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertNotIn(sniped_ticket, held_tickets, held_tickets)
+ self.assertEqual(held_tickets.count(), 5, held_tickets)
+
+ def test_complete_checkout(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ tickets_to_add = {
+ "quantities": [
+ {"type": "normal", "count": 1},
+ {"type": "premium", "count": 1},
+ ]
+ }
+ resp = self.client.post(
+ reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
+ tickets_to_add,
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+
+ with mock_cybersource_apis():
+ # Initiate checkout
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 2, held_tickets)
+
+ resp = self.client.post(
+ reverse("tickets-complete-checkout"),
+ {"transient_token": "abcdefg"},
+ format="json",
+ )
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ self.assertIn("Payment successful", resp.data["detail"], resp.data)
+
+ # Ownership transfered
+ owned_tickets = Ticket.objects.filter(owner=self.user1)
+ self.assertEqual(owned_tickets.count(), 2, owned_tickets)
+
+ # Cart empty
+ user_cart = Cart.objects.get(owner=self.user1)
+ self.assertEqual(user_cart.tickets.count(), 0, user_cart)
+
+ # Tickets held is 0
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 0, held_tickets)
+
+ # Transaction record created
+ TicketTransactionRecord.objects.filter(
+ reconciliation_id=MockPaymentResponse().reconciliation_id
+ ).exists()
+
+ def test_complete_checkout_stale_cart(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:2]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ with mock_cybersource_apis():
+ # Initiate checkout
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 2, held_tickets)
+
+ # Make holds expire prematurely, creating a stale cart
+ for ticket in held_tickets:
+ ticket.holding_expiration = timezone.now() - timedelta(minutes=1)
+ ticket.save()
+
+ # Invoking this API endpoint causes all holds to be expired
+ resp = self.client.post(
+ reverse("tickets-complete-checkout"),
+ {"transient_token": "abcdefg"},
+ format="json",
+ )
+ self.assertEqual(resp.status_code, 403, resp.content)
+ self.assertIn("Cart is stale", resp.data["detail"], resp.content)
+
+ # Ownership not transfered
+ owned_tickets = Ticket.objects.filter(owner=self.user1)
+ self.assertEqual(owned_tickets.count(), 0, owned_tickets)
+
+ def test_complete_checkout_validate_token_fails(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:2]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ with mock_cybersource_apis() as (_, _, _, fake_validate_token):
+ # Initiate checkout
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 2, held_tickets)
+
+ fake_validate_token.return_value = (False, "Validation failed")
+
+ # Try to complete
+ resp = self.client.post(
+ reverse("tickets-complete-checkout"),
+ {"transient_token": "abcdefg"},
+ format="json",
+ )
+
+ # Fails because token validation failed
+ self.assertEqual(resp.status_code, 500, resp.content)
+ self.assertIn("Validation failed", resp.data["detail"], resp.content)
+
+ # Ownership not transfered
+ owned_tickets = Ticket.objects.filter(owner=self.user1)
+ self.assertEqual(owned_tickets.count(), 0, owned_tickets)
+
+ # Hold cancelled
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 0, held_tickets)
+
+ def test_complete_checkout_cybersource_fails(self):
+ self.client.login(username=self.user1.username, password="test")
+
+ # Add a few tickets to cart
+ cart, _ = Cart.objects.get_or_create(owner=self.user1)
+ tickets_to_add = self.tickets1[:2]
+ for ticket in tickets_to_add:
+ cart.tickets.add(ticket)
+ cart.save()
+
+ with mock_cybersource_apis() as (_, fake_create_payment, _, _):
+ # Initiate checkout
+ resp = self.client.post(reverse("tickets-initiate-checkout"))
+ self.assertIn(resp.status_code, [200, 201], resp.content)
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 2, held_tickets)
+
+ fake_create_payment.return_value = (
+ MockPaymentResponse(status="UNAUTHORIZED"),
+ 400,
+ "",
+ )
+
+ # Try to complete
+ resp = self.client.post(
+ reverse("tickets-complete-checkout"),
+ {"transient_token": "abcdefg"},
+ format="json",
+ )
+
+ # Fails because cybersource fails
+ self.assertEqual(resp.status_code, 500, resp.content)
+ self.assertIn("Transaction failed", resp.data["detail"], resp.content)
+ self.assertIn("HTTP status 400", resp.data["detail"], resp.content)
+
+ # Ownership not transfered
+ owned_tickets = Ticket.objects.filter(owner=self.user1)
+ self.assertEqual(owned_tickets.count(), 0, owned_tickets)
+
+ # Hold cancelled
+ held_tickets = Ticket.objects.filter(holder=self.user1)
+ self.assertEqual(held_tickets.count(), 0, held_tickets)
diff --git a/frontend/components/BaseLayout.tsx b/frontend/components/BaseLayout.tsx
new file mode 100644
index 000000000..f7862138a
--- /dev/null
+++ b/frontend/components/BaseLayout.tsx
@@ -0,0 +1,51 @@
+import { PropsWithChildren } from 'react'
+import { ToastContainer } from 'react-toastify'
+import styled from 'styled-components'
+
+import AuthPrompt from '~/components/common/AuthPrompt'
+import Footer from '~/components/Footer'
+import Header from '~/components/Header'
+import { NAV_HEIGHT, SNOW } from '~/constants'
+import { GlobalStyle, RenderPageWrapper, ToastStyle } from '~/renderPage'
+import { PermissionsContext } from '~/utils'
+import { createBasePropFetcher } from '~/utils/getBaseProps'
+
+type BaseLayoutProps = Awaited<
+ ReturnType>
+> & {
+ authRequired?: boolean
+}
+
+export const Wrapper = styled.div`
+ min-height: calc(100vh - ${NAV_HEIGHT});
+ background: ${SNOW};
+`
+
+export const BaseLayout: React.FC> = ({
+ auth,
+ permissions,
+ children,
+ authRequired,
+}) => {
+ const authError = !!(authRequired && !auth.authenticated)
+ return (
+ <>
+
+
+
+ {authError ? : {children}}
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/frontend/components/ClubEditPage.tsx b/frontend/components/ClubEditPage.tsx
index 231fe3cd8..ba527f411 100644
--- a/frontend/components/ClubEditPage.tsx
+++ b/frontend/components/ClubEditPage.tsx
@@ -11,7 +11,8 @@ import FilesCard from '../components/ClubEditPage/FilesCard'
import InviteCard from '../components/ClubEditPage/InviteCard'
import MemberExperiencesCard from '../components/ClubEditPage/MemberExperiencesCard'
import MembersCard from '../components/ClubEditPage/MembersCard'
-import QRCodeCard from '../components/ClubEditPage/QRCodeCard'
+import QRCodeCard, { QRCodeType } from '../components/ClubEditPage/QRCodeCard'
+import TicketsViewCard from '../components/ClubEditPage/TicketsViewCard'
import {
CLUB_EDIT_ROUTE,
CLUB_RENEW_ROUTE,
@@ -287,13 +288,22 @@ const ClubForm = ({
>
),
},
+ {
+ name: 'tickets',
+ label: 'Tickets',
+ content: (
+ <>
+
+ >
+ ),
+ },
{
name: 'recruitment',
label: OBJECT_TAB_RECRUITMENT_LABEL,
content: (
<>
{SHOW_APPLICATIONS && }
-
+
`
text-align: left;
@@ -394,8 +395,52 @@ const EventPreview = ({ event }: { event: ClubEvent }) => (
)
+const CreateContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`
+
+const CreateTickets = ({ event }: { event: ClubEvent }) => {
+ const [show, setShow] = useState(false)
+ const showModal = () => setShow(true)
+ const hideModal = () => setShow(false)
+
+ return (
+
+
+
+ Create ticket offerings for this event
+
+
+
+
+
+ {show && (
+
+
+
+ )}
+
+ )
+}
+
export default function EventsCard({ club }: EventsCardProps): ReactElement {
const [deviceContents, setDeviceContents] = useState({})
+ const eventDetailsRef = useRef(null)
const event = {
...deviceContents,
@@ -421,10 +466,19 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement {
noun="Event"
currentTitle={(obj) => (obj != null ? obj.name : 'Deleted Event')}
onChange={(obj) => {
+ eventDetailsRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ })
setDeviceContents(obj)
}}
/>
-
+
+
+
+
+
+
)
}
diff --git a/frontend/components/ClubEditPage/QRCodeCard.tsx b/frontend/components/ClubEditPage/QRCodeCard.tsx
index af738eeba..3c600e2e5 100644
--- a/frontend/components/ClubEditPage/QRCodeCard.tsx
+++ b/frontend/components/ClubEditPage/QRCodeCard.tsx
@@ -1,9 +1,8 @@
import Link from 'next/link'
-import { ReactElement } from 'react'
+import { PropsWithChildren } from 'react'
import styled from 'styled-components'
import { CLUB_FLYER_ROUTE } from '../../constants/routes'
-import { Club } from '../../types'
import { getApiUrl } from '../../utils'
import { OBJECT_NAME_SINGULAR } from '../../utils/branding'
import { Icon, Text } from '../common'
@@ -16,37 +15,49 @@ const QRCode = styled.img`
margin-bottom: 15px;
`
-type QRCodeCardProps = {
- club: Club
+export enum QRCodeType {
+ CLUB = 'club',
+ TICKET = 'tickets',
}
-export default function QRCodeCard({ club }: QRCodeCardProps): ReactElement {
+const QRCodeCard: React.FC<
+ PropsWithChildren<{
+ id: string
+ type: QRCodeType
+ }>
+> = ({ id, type, children }) => {
return (
-
- When scanned, gives mobile-friendly access to your{' '}
- {OBJECT_NAME_SINGULAR} page and bookmark/subscribe actions.
-
-
+ {type === QRCodeType.CLUB && (
+
+ When scanned, gives mobile-friendly access to your{' '}
+ {OBJECT_NAME_SINGULAR} page and bookmark/subscribe actions.
+
+ )}
+
+ {type === QRCodeType.CLUB && (
View Flyer
-
+ )}
+ {children}
)
}
+export default QRCodeCard
diff --git a/frontend/components/ClubEditPage/TicketsModal.tsx b/frontend/components/ClubEditPage/TicketsModal.tsx
new file mode 100644
index 000000000..e966fc38b
--- /dev/null
+++ b/frontend/components/ClubEditPage/TicketsModal.tsx
@@ -0,0 +1,372 @@
+import React, { ReactElement, useState } from 'react'
+import { toast, TypeOptions } from 'react-toastify'
+import styled from 'styled-components'
+
+import { Icon, Line, Text, Title } from '../../components/common'
+import {
+ ALLBIRDS_GRAY,
+ CLUBS_GREY,
+ FOCUS_GRAY,
+ WHITE,
+} from '../../constants/colors'
+import { BORDER_RADIUS } from '../../constants/measurements'
+import { BODY_FONT } from '../../constants/styles'
+import { ClubEvent } from '../../types'
+import { doApiRequest } from '../../utils'
+import CoverPhoto from '../EventPage/CoverPhoto'
+
+const ModalContainer = styled.div`
+ text-align: left;
+ position: relative;
+`
+const ModalBody = styled.div`
+ padding: 2rem;
+`
+const SectionContainer = styled.div`
+ margin-bottom: 1.5rem;
+`
+
+const Input = styled.input`
+ border: 1px solid ${ALLBIRDS_GRAY};
+ outline: none;
+ color: ${CLUBS_GREY};
+ width: 100%;
+ font-size: 1em;
+ padding: 8px 10px;
+ margin: 0px 5px 0px 0px;
+ background: ${WHITE};
+ border-radius: ${BORDER_RADIUS};
+ font-family: ${BODY_FONT};
+ &:hover,
+ &:active,
+ &:focus {
+ background: ${FOCUS_GRAY};
+ }
+`
+
+const notify = (
+ msg: ReactElement | string,
+ type: TypeOptions = 'info',
+): void => {
+ toast[type](msg)
+}
+
+type TicketItemProps = {
+ ticket: Ticket
+ onChange?: (ticket: Ticket) => void
+ onDelete?: () => void
+ deletable: boolean
+}
+
+const TicketItem: React.FC = ({
+ ticket: propTicket,
+ onChange,
+ onDelete,
+ deletable,
+}) => {
+ const [ticket, setTicket] = useState(propTicket)
+ const [openGroupDiscount, setOpenGroupDiscount] = useState(false)
+
+ const resetGroupDiscount = () => {
+ setTicket({
+ ...ticket,
+ groupDiscount: null,
+ groupNumber: null,
+ })
+ onChange?.({
+ ...ticket,
+ groupDiscount: null,
+ groupNumber: null,
+ })
+ setOpenGroupDiscount(!openGroupDiscount)
+ }
+
+ return (
+
+
+ {
+ setTicket({ ...ticket, name: e.target.value })
+ onChange?.({ ...ticket, name: e.target.value })
+ }}
+ />
+ {
+ const count = e.target.value
+ setTicket({ ...ticket, count })
+ onChange?.({ ...ticket, count })
+ }}
+ />
+ {
+ const price = e.target.value
+ setTicket({ ...ticket, price })
+ onChange?.({ ...ticket, price })
+ }}
+ />
+
+
+
+
+
+
+ {openGroupDiscount ? (
+ <>
+
+ {
+ const groupDiscount = e.target.value
+ setTicket({ ...ticket, groupDiscount })
+ onChange?.({ ...ticket, groupDiscount })
+ }}
+ />
+
+
% Discount for
+
+ {
+ const groupNumber = e.target.value
+ setTicket({ ...ticket, groupNumber })
+ onChange?.({ ...ticket, groupNumber })
+ }}
+ />
+
+
+ >
+ ) : (
+ <>
+
+ >
+ )}
+
+
+
+ )
+}
+
+type Ticket = {
+ name: string
+ count: string | null
+ price: string | null // Free if null
+ groupDiscount: string | null // If null, no group discount
+ groupNumber: string | null // If null, no group discount
+}
+
+const TicketsModal = ({
+ event,
+ onSuccessfulSubmit,
+}: {
+ event: ClubEvent
+ onSuccessfulSubmit: () => void
+}): ReactElement => {
+ const { large_image_url, image_url, club_name, name, id } = event
+
+ const [submitting, setSubmitting] = useState(false)
+
+ const [tickets, setTickets] = useState([
+ {
+ name: 'Regular Ticket',
+ count: null,
+ price: null,
+ groupDiscount: null,
+ groupNumber: null,
+ },
+ ])
+
+ const addNewTicket = () => {
+ const ticks = [...tickets]
+ setTickets([
+ ...ticks,
+ {
+ name: '',
+ count: null,
+ price: null,
+ groupDiscount: null,
+ groupNumber: null,
+ },
+ ])
+ }
+
+ const submit = () => {
+ if (typeof name === 'string' && tickets.length > 0) {
+ const quantities = tickets
+ .filter((ticket) => ticket.count != null)
+ .map((ticket) => {
+ const usingGroupPricing = ticket.groupDiscount && ticket.groupNumber
+ return {
+ type: ticket.name,
+ count: parseInt(ticket.count ?? '0'),
+ price: parseFloat(ticket.price ?? '0'),
+ groupDiscount: usingGroupPricing
+ ? parseFloat(ticket.groupDiscount!)
+ : null,
+ groupNumber: usingGroupPricing
+ ? parseFloat(ticket.groupNumber!)
+ : null,
+ }
+ })
+ doApiRequest(`/events/${id}/tickets/?format=json`, {
+ method: 'PUT',
+ body: {
+ quantities,
+ },
+ }).then((res) => {
+ if (res.ok) {
+ notify(<>Tickets Created!>, 'success')
+ setSubmitting(false)
+ onSuccessfulSubmit()
+ } else {
+ notify(<>Error creating tickets>, 'error')
+ setSubmitting(false)
+ }
+ })
+ }
+ }
+
+ const disableSubmit = tickets.some(
+ (ticket) =>
+ typeof ticket.name !== 'string' ||
+ ticket.count === null ||
+ !Number.isInteger(parseInt(ticket.count || '0')) ||
+ parseInt(ticket.count || '0') < 0 ||
+ ticket.price === null ||
+ !Number.isFinite(parseFloat(ticket.price || '0')) ||
+ parseFloat(ticket.price || '0') < 0 ||
+ (ticket.groupNumber != null && parseFloat(ticket.price || '0') < 0),
+ )
+
+ return (
+
+ {club_name != null ? club_name.toLocaleUpperCase() : 'Event'}
+ }
+ />
+
+ {name}
+ Create new tickets for this event.
+
+
+ Tickets
+
+
+ {tickets.map((ticket, index) => (
+ 1}
+ onChange={(newTicket) => {
+ setTickets((t) =>
+ t.map((t, i) => (i === index ? newTicket : t)),
+ )
+ }}
+ onDelete={() => {
+ setTickets((t) => t.filter((_, i) => i !== index))
+ }}
+ />
+ ))}
+
+
+
+
+
+ {submitting ? (
+ <>
+
+ Are you sure you want to create these tickets? Ticket classes
+ and quantities are final and you will not be able to change them
+ moving forward.
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default TicketsModal
diff --git a/frontend/components/ClubEditPage/TicketsViewCard.tsx b/frontend/components/ClubEditPage/TicketsViewCard.tsx
new file mode 100644
index 000000000..d0a805bd5
--- /dev/null
+++ b/frontend/components/ClubEditPage/TicketsViewCard.tsx
@@ -0,0 +1,62 @@
+import Link from 'next/link'
+import React, { ReactElement } from 'react'
+
+import { doApiRequest } from '~/utils'
+
+import Table from '../common/Table'
+import BaseCard from './BaseCard'
+
+export default function TicketsViewCard({ club }): ReactElement {
+ const GetTicketsHolders = (id) => {
+ doApiRequest(`/events/${id}/tickets?format=json`, {
+ method: 'GET',
+ })
+ .then((resp) => resp.json())
+ .then((res) => {
+ // console.log(res)
+ })
+ }
+
+ const eventsTableFields = [
+ { label: 'Event Name', name: 'name' },
+ {
+ label: '',
+ name: 'view',
+ render: (id) => (
+
+ ),
+ },
+ ]
+
+ // console.log(club.events)
+ const ticketEvents = club.events.filter((event) => event.ticketed)
+
+ return (
+
+ {ticketEvents.length > 0 ? (
+
+ item.id ? { ...item, id: item.id } : { ...item, id: index },
+ )}
+ columns={eventsTableFields}
+ searchableColumns={['name']}
+ filterOptions={[]}
+ hideSearch={true}
+ focusable={true}
+ />
+ ) : (
+ <>
+ You don't have any ticketed events, to add create ticketed events or
+ add ticket offerings, to existing events, go to the events, click
+ create on the tickets section below the event details.
+ >
+ )}
+
+ )
+}
diff --git a/frontend/components/EventPage/EventCard.tsx b/frontend/components/EventPage/EventCard.tsx
index d9aa723bd..8870e1270 100644
--- a/frontend/components/EventPage/EventCard.tsx
+++ b/frontend/components/EventPage/EventCard.tsx
@@ -4,12 +4,12 @@ import TimeAgo from 'react-timeago'
import styled from 'styled-components'
import { Icon } from '../../components/common'
-import { MEDIUM_GRAY, WHITE } from '../../constants/colors'
import {
- mediaMaxWidth,
- mediaMinWidth,
- PHONE,
-} from '../../constants/measurements'
+ CLUBS_BLUE,
+ CLUBS_LIGHT_BLUE,
+ MEDIUM_GRAY,
+ WHITE,
+} from '../../constants/colors'
import { ClubEvent } from '../../types'
import { Card } from '../common/Card'
import { ClubName, EventLink, EventName } from './common'
@@ -18,28 +18,28 @@ import DateInterval from './DateInterval'
import { MEETING_REGEX } from './EventModal'
import HappeningNow from './HappeningNow'
-const EventCardContainer = styled.div`
- cursor: pointer;
-
- ${mediaMinWidth(PHONE)} {
- max-width: 18em;
- margin: 1rem;
- }
- ${mediaMaxWidth(PHONE)} {
- margin: 1rem 0;
- }
-`
const TimeLeft = styled(TimeAgo)<{ date: Date }>`
color: ${MEDIUM_GRAY};
font-size: 12px;
`
+const TicketsPill = styled.div`
+ display: inline-flex;
+ background-color: ${CLUBS_LIGHT_BLUE};
+ border-radius: 20px;
+ font-size: 12px;
+ padding: 4px 12px;
+ align-items: center;
+ justify-content: center;
+ color: ${CLUBS_BLUE};
+
+ & > span {
+ margin-right: 4px;
+ }
+`
+
const clipLink = (s: string) => (s.length > 32 ? `${s.slice(0, 35)}...` : s)
-const EventCard = (props: {
- event: ClubEvent
- onClick: () => void
- onLinkClicked: () => void
-}): ReactElement => {
+const EventCard = (props: { event: ClubEvent }): ReactElement => {
const {
image_url: imageUrl,
club_name: clubName,
@@ -47,67 +47,54 @@ const EventCard = (props: {
end_time,
name,
url,
- club,
- pinned,
- badges,
+ ticketed,
} = props.event
const now = new Date()
const startDate = new Date(start_time)
const endDate = new Date(end_time)
const isHappening = now >= startDate && now <= endDate
-
const hoursBetween =
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60)
return (
-
-
-
-
-
- {clubName != null ? clubName.toLocaleUpperCase() : 'Event'}
-
-
- }
- />
-
-
- {isHappening ? (
-
+
+
+
+ {clubName != null ? clubName.toLocaleUpperCase() : 'Event'}
+
+ }
+ />
+
+
+ {isHappening ? (
+
+ ) : (
+
+ )}
+ {clubName}
+ {name}
+ {ticketed && (
+
+
+ Tickets
+
+ )}
+ {url && MEETING_REGEX.test(url) && }{' '}
+ {url &&
+ (/^\(.*\)$/.test(url) ? (
+ url
) : (
-
- )}
- {clubName}
- {name}
- {url && MEETING_REGEX.test(url) && }{' '}
- {url &&
- (/^\(.*\)$/.test(url) ? (
- url
- ) : (
-
- {clipLink(url)}
-
- ))}
- {(badges.length > 0 || pinned) && (
-
- {pinned && (
-
- Pinned
-
- )}
- {badges.map(({ id, label }) => (
-
- {label}
-
- ))}
-
- )}
-
-
+ {clipLink(url)}
+ ))}
+
)
}
diff --git a/frontend/components/EventPage/EventModal.tsx b/frontend/components/EventPage/EventModal.tsx
index 1b98e4fdc..34ffd7ad1 100644
--- a/frontend/components/EventPage/EventModal.tsx
+++ b/frontend/components/EventPage/EventModal.tsx
@@ -7,12 +7,8 @@ import styled from 'styled-components'
import { Icon } from '../../components/common'
import { CLUB_ROUTE, ZOOM_BLUE } from '../../constants'
import { MEDIUM_GRAY } from '../../constants/colors'
-import { Club, ClubEvent } from '../../types'
-import {
- apiSetFavoriteStatus,
- apiSetSubscribeStatus,
- doApiRequest,
-} from '../../utils'
+import { ClubEvent } from '../../types'
+import { doApiRequest } from '../../utils'
import {
OBJECT_NAME_SINGULAR,
OBJECT_NAME_TITLE_SINGULAR,
@@ -128,43 +124,32 @@ const LiveEventUpdater = ({
/**
* Buttons that allow you to bookmark and subscribe to a club.
*/
-const ActionButtons = ({ club: code }): ReactElement | null => {
- const [isBookmarked, setBookmarked] = useState(null)
- const [isSubscribed, setSubscribed] = useState(null)
-
- useEffect(() => {
- doApiRequest(`/clubs/${code}/?format=json`)
- .then((resp) => resp.json())
- .then((data: Club) => {
- setSubscribed(data.is_subscribe)
- setBookmarked(data.is_favorite)
- })
- }, [code])
-
- if (isSubscribed == null || isBookmarked == null) {
- return null
- }
-
+const ActionButtons = ({
+ club: code,
+ isTicketEvent,
+ setDisplayTicketModal,
+ ticketCount,
+ userHasTickets,
+}): ReactElement | null => {
return (
<>
-
-
+ {isTicketEvent && (
+ <>
+
+
+
+ >
+ )}
>
)
}
@@ -222,11 +207,19 @@ const EventModal = (props: {
club_name,
start_time,
end_time,
+ // ticketed,
name,
url,
description,
} = event
+ const ticketed = true
const [userCount, setUserCount] = useState(null)
+ const [ticketCount, setTicketCount] = useState(null)
+ const [userHasTickets, setUserHasTickets] = useState(null)
+ const [displayTicketModal, setDisplayTicketModal] = useState(false)
+ const [availableTickets, setAvailableTickets] = useState | null>(
+ null,
+ )
const now = new Date()
const startDate = new Date(start_time)
@@ -242,11 +235,36 @@ const EventModal = (props: {
setUserCount(resp)
})
}
+ // TODO: CHANGE TO event.ticketed instead of true when that is added
+ if (ticketed) {
+ setTicketCount(0) // TODO: CHANGE BACK TO 0
+ doApiRequest(`/events/${event.id}/tickets/`)
+ .then((resp) => resp.json())
+ .then((resp) => {
+ if (resp.available) {
+ setAvailableTickets(resp.available)
+ for (let i = 0; i < resp.available.length; i++) {
+ setTicketCount(ticketCount + resp.available[i].count)
+ }
+ }
+ })
+ setUserHasTickets(false)
+ doApiRequest(`/tickets/`)
+ .then((resp) => resp.json())
+ .then((resp) => {
+ for (let i = 0; i < resp.length; i++) {
+ if (resp[i].event.id === event.id) {
+ setUserHasTickets(true)
+ break
+ }
+ }
+ })
+ }
}
useEffect(refreshLiveData, [])
- return (
+ return !displayTicketModal ? (
- {club != null &&
}
+ {club != null && (
+
+ )}
+ ) : (
+ // TODO: THIS IS NOTHING RN, SHOULD DISPLAY ALL AVAILABLE TICKETS
+
""
)
}
diff --git a/frontend/components/FormComponents.tsx b/frontend/components/FormComponents.tsx
index 03f11564c..c606c7e65 100644
--- a/frontend/components/FormComponents.tsx
+++ b/frontend/components/FormComponents.tsx
@@ -511,7 +511,7 @@ export const DynamicQuestionField = useFieldWrapper(
values.push({
name: uuidv4(),
label: '',
- type: type,
+ type,
choices: [],
})
setFieldValue(name, JSON.stringify(values))
diff --git a/frontend/components/Header/Links.tsx b/frontend/components/Header/Links.tsx
index 4e20d6296..4eb38f118 100644
--- a/frontend/components/Header/Links.tsx
+++ b/frontend/components/Header/Links.tsx
@@ -18,7 +18,7 @@ import {
MD,
mediaMaxWidth,
} from '../../constants/measurements'
-import { SETTINGS_ROUTE } from '../../constants/routes'
+import { CART_ROUTE, SETTINGS_ROUTE } from '../../constants/routes'
import { UserInfo } from '../../types'
import { LOGIN_URL } from '../../utils'
import { logEvent } from '../../utils/analytics'
@@ -26,7 +26,7 @@ import { Icon } from '../common'
const StyledIcon = styled(Icon)`
opacity: 0.5;
- margin-right: 4px;
+ display: inline-block;
`
const LoginButton = styled.a`
@@ -58,7 +58,10 @@ const LoginButton = styled.a`
const StyledLinkAnchor = styled.a`
padding: ${LINK_MARGIN} 20px;
color: ${BANNER_TEXT} !important;
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+
cursor: pointer;
${mediaMaxWidth(MD)} {
@@ -121,12 +124,30 @@ const Links = ({ userInfo, authenticated, show }: Props): ReactElement => {
Login
)}
- {userInfo && (
+ {authenticated && userInfo && (
-
+
{userInfo.name || userInfo.username}
)}
+ {authenticated === true && (
+
+
+ Cart
+
+ )}
)
diff --git a/frontend/components/Header/index.tsx b/frontend/components/Header/index.tsx
index 79e893b6c..f38c68f8f 100644
--- a/frontend/components/Header/index.tsx
+++ b/frontend/components/Header/index.tsx
@@ -170,9 +170,7 @@ const Header = ({ authenticated, userInfo }: HeaderProps): ReactElement => {
return (
<>
-
-
{isHub && }
-
{SHOW_FEEDBACK && }
>
)
diff --git a/frontend/components/SearchBar.tsx b/frontend/components/SearchBar.tsx
index 56ba502c8..540714d21 100644
--- a/frontend/components/SearchBar.tsx
+++ b/frontend/components/SearchBar.tsx
@@ -166,7 +166,7 @@ type CollapsibleProps = React.PropsWithChildren<{
name: string
}>
-const Collapsible = ({
+export const Collapsible = ({
children,
active,
name,
diff --git a/frontend/components/Settings/BulkEditTab.tsx b/frontend/components/Settings/BulkEditTab.tsx
index 4376af282..53542fa19 100644
--- a/frontend/components/Settings/BulkEditTab.tsx
+++ b/frontend/components/Settings/BulkEditTab.tsx
@@ -121,9 +121,9 @@ const BulkEditTab = ({ tags, clubfairs, badges }: BulkEditTabProps) => {
choices={badges}
deserialize={({ id, label, description, purpose }) => ({
value: id,
- label: label,
- description: description,
- purpose: purpose,
+ label,
+ description,
+ purpose,
})}
formatOptionLabel={({ label, description, purpose }) => (
<>
diff --git a/frontend/components/Settings/TicketTransferModal.tsx b/frontend/components/Settings/TicketTransferModal.tsx
new file mode 100644
index 000000000..719d334d0
--- /dev/null
+++ b/frontend/components/Settings/TicketTransferModal.tsx
@@ -0,0 +1,85 @@
+import React, { ReactElement, useEffect, useState } from 'react'
+import { toast, TypeOptions } from 'react-toastify'
+import styled from 'styled-components'
+
+import {
+ ALLBIRDS_GRAY,
+ CLUBS_GREY,
+ FOCUS_GRAY,
+ WHITE,
+} from '../../constants/colors'
+import { BORDER_RADIUS } from '../../constants/measurements'
+import { BODY_FONT } from '../../constants/styles'
+import { ClubEvent } from '../../types'
+import { SearchInput } from '../SearchBar'
+
+const ModalContainer = styled.div`
+ text-align: left;
+ position: relative;
+`
+const ModalBody = styled.div`
+ padding: 2rem;
+`
+const SectionContainer = styled.div`
+ margin-bottom: 1.5rem;
+`
+
+const Input = styled.input`
+ border: 1px solid ${ALLBIRDS_GRAY};
+ outline: none;
+ color: ${CLUBS_GREY};
+ width: 100%;
+ font-size: 1em;
+ padding: 8px 10px;
+ margin: 0px 5px 0px 0px;
+ background: ${WHITE};
+ border-radius: ${BORDER_RADIUS};
+ font-family: ${BODY_FONT};
+ &:hover,
+ &:active,
+ &:focus {
+ background: ${FOCUS_GRAY};
+ }
+`
+
+const notify = (
+ msg: ReactElement | string,
+ type: TypeOptions = 'info',
+): void => {
+ toast[type](msg)
+}
+
+const TicketTransferModal = (props: {
+ event: ClubEvent | null
+}): ReactElement => {
+ const [searchInput, setSearchInput] = useState({})
+
+ const search = () => {
+ /*
+ return doApiRequest('/users?search=bfranklin')
+ .then((resp) => resp.json())
+ .then(() => {})
+ */
+ }
+ useEffect(() => {
+ search()
+ }, [])
+ /*
+ {club_name != null ? club_name.toLocaleUpperCase() : 'Event'}
+ }
+ />
+
+ {name}
+ */
+
+ return (
+
+
+
+ )
+}
+
+export default TicketTransferModal
diff --git a/frontend/components/Settings/TicketsTab/index.tsx b/frontend/components/Settings/TicketsTab/index.tsx
new file mode 100644
index 000000000..223ff0d2a
--- /dev/null
+++ b/frontend/components/Settings/TicketsTab/index.tsx
@@ -0,0 +1,431 @@
+import moment from 'moment-timezone'
+import Link from 'next/link'
+import { ReactElement, useEffect, useState } from 'react'
+import styled from 'styled-components'
+
+import {
+ ALLBIRDS_GRAY,
+ CLUBS_BLUE,
+ CLUBS_GREY_LIGHT,
+ H1_TEXT,
+ HOVER_GRAY,
+ HUB_SNOW,
+ WHITE,
+} from '~/constants'
+
+import {
+ ANIMATION_DURATION,
+ BORDER_RADIUS,
+ CARD_HEADING,
+ mediaMaxWidth,
+ SM,
+} from '../../../constants/measurements'
+import { UserInfo } from '../../../types'
+import { doApiRequest } from '../../../utils'
+import QRCodeCard, { QRCodeType } from '../../ClubEditPage/QRCodeCard'
+import {
+ Center,
+ EmptyState,
+ Icon,
+ Loading,
+ Modal,
+ Text,
+ Title,
+} from '../../common'
+import TicketTransferModal from '../TicketTransferModal'
+
+const CardHeader = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+`
+
+const CardTitle = styled.strong`
+ line-height: 1.2;
+ color: ${H1_TEXT};
+ font-weight: ${CARD_HEADING};
+`
+
+const ActionWrapper = styled.div`
+ width: 100%;
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1.5px solid rgba(0, 0, 0, 0.05);
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+
+ & > :not(:first-child) {
+ margin-left: 5px;
+ }
+`
+
+type CardProps = {
+ readonly hovering?: boolean
+ className?: string
+}
+
+const Card = styled.div`
+ padding: 10px;
+ margin-top: 1rem;
+ box-shadow: 0 0 0 transparent;
+ transition: all ${ANIMATION_DURATION}ms ease;
+ border-radius: ${BORDER_RADIUS};
+ box-shadow: 0 0 0 ${WHITE};
+ background-color: ${({ hovering }) => (hovering ? HOVER_GRAY : WHITE)};
+ border: 1px solid ${ALLBIRDS_GRAY};
+ justify-content: space-between;
+ height: auto;
+
+ &:hover,
+ &:active,
+ &:focus {
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
+ }
+
+ ${mediaMaxWidth(SM)} {
+ width: calc(100%);
+ padding: 8px;
+ }
+`
+
+const ModalContainer = styled.div`
+ text-align: left;
+ position: relative;
+`
+const ModalBody = styled.div`
+ padding: 2rem;
+`
+const SectionContainer = styled.div`
+ margin-bottom: 1.5rem;
+`
+
+const Description = styled.p`
+ margin-bottom: 0.5rem;
+ color: ${CLUBS_GREY_LIGHT};
+ width: 100%;
+`
+const TitleWrapper = styled.div`
+ margin-top: 1.5rem;
+ color: ${CLUBS_GREY_LIGHT};
+ width: 100%;
+`
+
+type TicketsTabProps = {
+ className?: string
+ userInfo: UserInfo
+}
+
+const formatTime = (startTime: string, endTime: string) => {
+ const date = new Date(startTime)
+ // return the month and date
+ const dayDuration = new Date(endTime).getDate() - date.getDate()
+ const timezone = moment.tz.guess()
+ const startFormatted = moment(startTime)
+ .tz(timezone)
+ .format(dayDuration === 0 ? 'h:mmA' : 'MMM D, h:mmA')
+ const endFormatted = moment(endTime)
+ .tz(timezone)
+ .format(dayDuration === 0 ? 'h:mmA z' : 'MMM D, h:mmA z')
+
+ return {
+ month: date.toLocaleString('default', { month: 'short' }),
+ day: date.getDate(),
+ timeRange: `${startFormatted} — ${endFormatted}`,
+ dayDuration,
+ }
+}
+
+const TicketCard = ({
+ collapsed = 0,
+ ticket,
+ showModal,
+ props,
+ onClick,
+ viewQRCode,
+}: {
+ collapsed?: number
+ ticket: any
+ showModal: () => void
+ props?: any
+ onClick?: () => void
+ viewQRCode?: () => void
+}) => {
+ const datetimeData = formatTime(
+ ticket.event.start_time,
+ ticket.event.end_time,
+ )
+ function generateBoxShadow(collapsed) {
+ let boxShadow = ''
+ boxShadow += '0 1px 6px rgba(0, 0, 0, 0.2),\n'
+ for (let i = 1; i <= collapsed; i++) {
+ boxShadow += `${i * 10}px -${i * 10}px 0 -1px ${HUB_SNOW}, ${i * 10}px -${i * 10}px rgba(0, 0, 0, 0.1)${
+ i !== collapsed ? ',\n' : ''
+ }`
+ }
+ return boxShadow
+ }
+ return (
+ {
+ onClick?.()
+ }}
+ >
+
+
+ {datetimeData.day}
+ {datetimeData.dayDuration !== 0 && (
+
+ {datetimeData.dayDuration < 0 ? '-' : '+'}{' '}
+ {Math.abs(datetimeData.dayDuration)}
+
+ )}
+
+
+ {datetimeData.month}
+
+
+
+
+
+ {ticket.event.name}
+
+ {ticket.event.club_name}
+
+ {ticket.type} | {datetimeData.timeRange}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const TicketsTab = ({ className, userInfo }: TicketsTabProps): ReactElement => {
+ const [tickets, setTickets] = useState(null)
+ const [show, setShow] = useState(false)
+ const [expandedEvents, setExpandedEvents] = useState(new Set())
+ const [selectedTicket, setSelectedTicket] = useState()
+
+ const showModal = () => setShow(true)
+ const hideModal = () => setShow(false)
+
+ const getTickets = () => {
+ return doApiRequest('/tickets?format=json')
+ .then((resp) => resp.json())
+ .then(setTickets)
+ }
+ useEffect(() => {
+ getTickets()
+ }, [])
+
+ if (tickets === null) {
+ return
+ }
+
+ // Group by event
+ const groupedTickets = tickets.reduce((acc, ticket) => {
+ if (!acc[ticket.event.id]) {
+ acc[ticket.event.id] = []
+ }
+ acc[ticket.event.id].push(ticket)
+ return acc
+ }, {})
+
+ const viewQRCode = (id: string) => {
+ showModal()
+ setSelectedTicket(id)
+ }
+
+ const toggleGroup = (key) => {
+ const newExpandedEvents = new Set(expandedEvents)
+ if (expandedEvents.has(key)) {
+ newExpandedEvents.delete(key)
+ } else {
+ newExpandedEvents.add(key)
+ }
+ setExpandedEvents(newExpandedEvents)
+ }
+
+ return tickets.length ? (
+
+ {show && (
+
+
+ {selectedTicket && (
+
+
+
+
+
+ )}
+
+ )}
+
+ Browse Your Tickets
+
+ {Object.entries(groupedTickets).map((group: [string, any[]], i) => (
+
+ {expandedEvents.has(group[0] || group[1].length === 1) && (
+
toggleGroup(group[0])}
+ >
+ Uncollapse
+
+ )}
+
+ {expandedEvents.has(group[0]) || group[1].length === 1 ? (
+ group[1].map((ticket, i) => (
+
{
+ if (group[1].length !== 1) {
+ toggleGroup(group[0])
+ }
+ }}
+ viewQRCode={() => viewQRCode(ticket.id)}
+ />
+ ))
+ ) : (
+ {
+ if (group[1].length !== 1) {
+ toggleGroup(group[0])
+ }
+ }}
+ viewQRCode={() => viewQRCode(group[1][0].id)}
+ />
+ )}
+
+ ))}
+
+ ) : (
+ <>
+
+
+
+ No tickets yet! Browse events to find tickets{' '}
+ here.
+
+
+ >
+ )
+}
+
+export default TicketsTab
diff --git a/frontend/components/Settings/WhartonApplicationTab.tsx b/frontend/components/Settings/WhartonApplicationTab.tsx
index 39de371b1..45df0b7e2 100644
--- a/frontend/components/Settings/WhartonApplicationTab.tsx
+++ b/frontend/components/Settings/WhartonApplicationTab.tsx
@@ -76,7 +76,7 @@ const WhartonApplicationTab = ({
startTime: formatDateTime(application.application_start_time),
endTime: formatDateTime(application.application_end_time),
updatedTime: formatDateTime(application.updated_at),
- wordCount: wordCount,
+ wordCount,
}
})
}
diff --git a/frontend/components/TicketsPage/CartTickets.tsx b/frontend/components/TicketsPage/CartTickets.tsx
new file mode 100644
index 000000000..874614286
--- /dev/null
+++ b/frontend/components/TicketsPage/CartTickets.tsx
@@ -0,0 +1,107 @@
+import { ReactElement, useMemo } from 'react'
+import styled from 'styled-components'
+
+import { CARD_HEADING, CLUBS_GREY, H1_TEXT } from '~/constants'
+import { EventTicket } from '~/types'
+
+import { Card } from '../common'
+
+export interface CountedEventTicket extends EventTicket {
+ count: number
+}
+
+export interface CartTicketsProps {
+ tickets: EventTicket[]
+}
+
+const TicketCard = styled(Card)`
+ display: flex;
+ flex-direction: row;
+`
+
+const Body = styled.div`
+ margin-left: 10px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+`
+
+const XButton = styled.button`
+ width: 14px;
+ height: 14px;
+`
+
+const EventTitle = styled.strong`
+ font-size: 1.3rem;
+ line-height: 1.2;
+ color: ${H1_TEXT};
+ margin-bottom: 0.5rem;
+ font-weight: ${CARD_HEADING};
+`
+
+const ClubName = styled.div`
+ color: ${CLUBS_GREY};
+`
+
+interface TicketImageProps {
+ url: string | null
+}
+
+const THUMBNAIL_SIZE = '40px'
+
+const Thumbnail = styled.div`
+ width: ${THUMBNAIL_SIZE};
+ height: ${THUMBNAIL_SIZE};
+ margin: 8px;
+
+ background-image: url(${({ url }) => url || '/static/img/tickets.png'});
+ background-size: contain;
+ border-radius: 3px;
+`
+
+// TODO: use ticket type as well
+const ticketKey = (t1: EventTicket) => t1.event.id
+
+/**
+ * Combines an array of tickets into a list of unique ticket types with counts
+ * @param tickets - Original array of tickets
+ * @returns Array of tickets condensed into unique types
+ */
+const combineTickets = (tickets: EventTicket[]): CountedEventTicket[] => {
+ const tix = [...tickets]
+ const countedTickets: { [key: string]: CountedEventTicket } = {}
+
+ while (tix.length > 0) {
+ const currentTicket = tix.pop() as EventTicket
+ const key = ticketKey(currentTicket)
+ if (countedTickets[key] === undefined) {
+ countedTickets[key] = { ...currentTicket, count: 1 }
+ } else {
+ countedTickets[key].count += 1
+ }
+ }
+
+ return Object.values(countedTickets)
+}
+
+const CartTickets = ({ tickets }: CartTicketsProps): ReactElement => {
+ const condensedTickets = useMemo(() => combineTickets(tickets), [tickets])
+ // console.log(condensedTickets)
+ return (
+
+ {condensedTickets.map(({ event, id }) => {
+ return (
+
+
+
+ {event.name}
+ {event.club_name}
+
+
+ )
+ })}
+
+ )
+}
+
+export default CartTickets
diff --git a/frontend/components/common/Table.tsx b/frontend/components/common/Table.tsx
index 434469167..ab8cf46bc 100644
--- a/frontend/components/common/Table.tsx
+++ b/frontend/components/common/Table.tsx
@@ -71,6 +71,7 @@ type tableProps = {
searchableColumns: string[]
filterOptions?: FilterOption[]
focusable?: boolean
+ hideSearch?: boolean
onClick?: (row: any, event: any) => void
draggable?: boolean
onDragEnd?: (result: any) => void | null | undefined
@@ -122,6 +123,7 @@ const Table = ({
filterOptions,
focusable,
onClick,
+ hideSearch = false,
draggable = false,
onDragEnd,
initialPage = 0,
@@ -234,57 +236,61 @@ const Table = ({
}
return (
-
-
-
-
-
-
-
-
- {filterOptions &&
- filterOptions.map((filterOption) => (
-
-
- ))}
+ {(!hideSearch || (filterOptions && filterOptions.length > 0)) && (
+
+ {!hideSearch && (
+
+
+
+
+
+ )}
+
+
+ {filterOptions &&
+ filterOptions.map((filterOption) => (
+
+
+ ))}
+
-
-
+
+ )}
{tableData.length > 0 ? (
diff --git a/frontend/constants/routes.ts b/frontend/constants/routes.ts
index 627e7c842..93249e590 100644
--- a/frontend/constants/routes.ts
+++ b/frontend/constants/routes.ts
@@ -28,6 +28,7 @@ export const CLUB_ALUMNI_ROUTE = (slug?: string): string =>
slug
? `/${OBJECT_URL_SLUG}/${slug}/alumni`
: `/${OBJECT_URL_SLUG}/[club]/alumni`
+export const CART_ROUTE = '/tickets/checkout'
export const SETTINGS_ROUTE = '/settings'
export const USER_RENEWAL = '/renew'
export const CREATE_ROUTE = '/create'
diff --git a/frontend/package.json b/frontend/package.json
index 270f3f9c4..a3807549c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,6 +8,7 @@
"@sentry/browser": "^7.101.1",
"@sentry/node": "^7.101.1",
"@svgr/webpack": "^8.1.0",
+ "@types/moment-timezone": "^0.5.30",
"@zeit/next-source-maps": "0.0.4-canary.1",
"babel-loader": "^9.1.3",
"babel-plugin-istanbul": "^6.1.1",
@@ -32,6 +33,7 @@
"http-proxy-middleware": "^2.0.6",
"isomorphic-unfetch": "^4.0.2",
"lru-cache": "^10.2.0",
+ "luxon": "^3.4.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"next": "^14.1.0",
@@ -62,6 +64,7 @@
"test": "tsc --noemit",
"integration": "./test.sh",
"dev": "node server.js",
+ "ssl-proxy": "yarn local-ssl-proxy --source 3001 --target 3000 --key localhost+2-key.pem --cert localhost+2.pem",
"build": "env NODE_ENV=production next build",
"start": "env NODE_ENV=production node server.js",
"storybook": "start-storybook -s ./public -p 6006",
@@ -76,6 +79,7 @@
"@storybook/addons": "^7.6.16",
"@storybook/react": "^7.6.16",
"@types/cheerio": "^0.22.35",
+ "@types/luxon": "^3.4.2",
"@types/node": "^20.11.19",
"@types/react": "^18.2.57",
"@types/showdown": "^2.0.6",
@@ -101,6 +105,7 @@
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-unused-imports": "^3.1.0",
"istanbul-lib-coverage": "^3.2.2",
+ "local-ssl-proxy": "^2.0.5",
"nyc": "^15.0.0",
"prettier": "^3.2.5",
"typescript": "^5.3.3",
diff --git a/frontend/pages/events.tsx b/frontend/pages/_events.tsx
similarity index 99%
rename from frontend/pages/events.tsx
rename to frontend/pages/_events.tsx
index bbabfb32f..a22a9a7b8 100644
--- a/frontend/pages/events.tsx
+++ b/frontend/pages/_events.tsx
@@ -1,3 +1,5 @@
+// TODO: remove this page
+
import Color from 'color'
import { EVENT_TYPES } from 'components/ClubEditPage/EventsCard'
import {
diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx
new file mode 100644
index 000000000..b0032b563
--- /dev/null
+++ b/frontend/pages/events/[id].tsx
@@ -0,0 +1,373 @@
+import { DateTime, Settings } from 'luxon'
+import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
+import Link from 'next/link'
+import React, { useState } from 'react'
+import { toast } from 'react-toastify'
+import styled from 'styled-components'
+
+import { BaseLayout } from '~/components/BaseLayout'
+import {
+ Metadata,
+ Modal,
+ StrongText,
+ Subtitle,
+ Text,
+ Title,
+} from '~/components/common'
+import {
+ ALLBIRDS_GRAY,
+ BODY_FONT,
+ BORDER,
+ BORDER_RADIUS,
+ CLUBS_BLUE,
+ CLUBS_GREY,
+ CLUBS_LIGHT_BLUE,
+ FOCUS_GRAY,
+ mediaMaxWidth,
+ mediaMinWidth,
+ PHONE,
+ WHITE,
+} from '~/constants'
+import { Club, ClubEvent, TicketAvailability } from '~/types'
+import { doApiRequest } from '~/utils'
+import { createBasePropFetcher } from '~/utils/getBaseProps'
+
+Settings.defaultZone = 'America/New_York'
+
+const getBaseProps = createBasePropFetcher()
+
+export const getServerSideProps = (async (ctx) => {
+ const id = ctx.params?.id
+ if (typeof id !== 'string') {
+ return {
+ notFound: true,
+ }
+ }
+ const data = {
+ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined,
+ }
+ // TODO: Add caching
+ const [baseProps, event] = await Promise.all([
+ getBaseProps(ctx),
+ doApiRequest(`/events/${id}`, data).then(
+ (resp) => resp.json() as Promise,
+ ),
+ ])
+ const [club, tickets] = await Promise.all([
+ doApiRequest(`/clubs/${event.club}/`, data).then(
+ (resp) => resp.json() as Promise,
+ ),
+ doApiRequest(`/clubs/${event.club}/events/${id}/tickets/`, data).then(
+ (resp) => resp.json() as Promise,
+ ),
+ ])
+ return {
+ props: {
+ baseProps,
+ club,
+ tickets,
+ event,
+ },
+ }
+}) satisfies GetServerSideProps
+
+type EventPageProps = InferGetServerSidePropsType
+
+const MainWrapper = styled.main`
+ margin: 0 auto;
+ width: 100vw;
+ max-width: 1400px;
+ padding: 20px;
+`
+
+const GridWrapper = styled.div`
+ display: grid;
+ ${mediaMinWidth(PHONE)} {
+ grid-template-columns: 2fr 1fr;
+ }
+ ${mediaMaxWidth(PHONE)} {
+ grid-template-columns: 1fr;
+ }
+ gap: 24px;
+`
+
+const Tag = styled.div`
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ border-radius: 12px;
+ background-color: ${CLUBS_LIGHT_BLUE};
+ color: ${CLUBS_BLUE};
+ font-size: 0.8rem;
+ font-weight: 600;
+`
+
+const Right = styled.div`
+ img {
+ width: 100%;
+ border-radius: 12px;
+ box-shadow: 0 0 12px #00000033;
+ }
+`
+
+const Card = styled.div`
+ margin-top: 20px;
+ border-radius: 8px;
+ border: 1px solid ${BORDER};
+ background-color: #fff;
+ padding: 20px;
+ p {
+ margin: 0.2rem;
+ }
+`
+
+const Divider = styled.hr`
+ width: 100%;
+ margin: 20px 0;
+`
+
+const Input = styled.input`
+ border: 1px solid ${ALLBIRDS_GRAY};
+ outline: none;
+ color: ${CLUBS_GREY};
+ flex: 0 0 auto;
+ font-size: 1em;
+ padding: 8px 10px;
+ margin: 0px 5px 0px 0px;
+ background: ${WHITE};
+ border-radius: ${BORDER_RADIUS};
+ min-width: 50px;
+ font-family: ${BODY_FONT};
+ &:hover,
+ &:active,
+ &:focus {
+ background: ${FOCUS_GRAY};
+ }
+`
+
+type Ticket = {
+ type: string
+ price: string
+ max: string
+ count: number | null
+}
+
+type TicketItemProps = {
+ ticket: Ticket
+ name: string
+ price: string
+ max: string
+ onCountChange: (newCount: number) => void
+}
+
+const TicketItem: React.FC = ({
+ ticket,
+ name,
+ price,
+ max,
+ onCountChange,
+}) => {
+ const [count, setCount] = useState(ticket.count)
+ const handleCountChange = (e: React.ChangeEvent) => {
+ // Round to nearest integer and clamp to min/max
+ const value = Math.max(
+ 0,
+ Math.min(Math.round(parseFloat(e.target.value)), parseInt(max, 10)),
+ )
+ setCount(value)
+ onCountChange(value)
+ }
+
+ return (
+
+
+
+ {name} - ${price}
+
+
+
+
+ )
+}
+
+const EventPage: React.FC = ({
+ baseProps,
+ club,
+ event,
+ tickets,
+}) => {
+ const [showTicketModal, setShowTicketModal] = useState(false)
+
+ const startTime = DateTime.fromISO(event.start_time)
+ const endTime = DateTime.fromISO(event.end_time)
+
+ const ticketMap = tickets.totals.reduce(
+ (acc, cur) => ({
+ ...acc,
+ [cur.type]: {
+ total: cur.count,
+ available:
+ tickets.available.find((t) => t.type === cur.type)?.count ?? 0,
+ price: cur.price,
+ },
+ }),
+ {},
+ ) as Record
+
+ const [order, setOrder] = useState(
+ Object.entries(ticketMap).map(([type, counts]) => ({
+ type,
+ price: counts.price.toString(),
+ max: counts.total.toString(),
+ count: 0,
+ })),
+ )
+
+ const totalAvailableTickets = Object.values(ticketMap)
+ .map((k) => k.available)
+ .reduce((a, b) => a + b, 0)
+
+ return (
+ <>
+ setShowTicketModal(false)}
+ marginBottom={false}
+ >
+ Get Tickets
+ {order.map((ticket, index) => (
+ {
+ const ticks = [...order]
+ ticks[index].count = count
+ setOrder(ticks)
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+
{event.name}
+
+ Hosted by {club.name}
+
+
+ {event.badges.map((badge) => (
+ {badge.label}
+ ))}
+
+
+ {event.description}
+
+ {club.description}
+
+
+
+
+ {event.ticketed && (
+
+ Tickets
+
+ {totalAvailableTickets > 0
+ ? `${totalAvailableTickets} tickets available`
+ : 'Sold out'}
+
+ {Object.entries(ticketMap).map(([type, counts]) => (
+
+ {type}: {counts.available} tickets available /{' '}
+ {counts.total} total
+
+ ))}
+
+
+ )}
+
+ Date
+
+ {startTime.hasSame(endTime, 'day')
+ ? startTime.toFormat('cccc, LLLL d, yyyy')
+ : startTime.toFormat('cccc, LLLL d, yyyy t') +
+ ' - ' +
+ endTime.toFormat('cccc, LLLL d, yyyy t')}
+
+ {startTime.hasSame(endTime, 'day') && (
+ <>
+ Time
+
+ {startTime.toFormat('t')} - {endTime.toFormat('t')}
+
+ >
+ )}
+ {event.location && (
+ <>
+ Location
+ {event.location}
+ >
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+export default EventPage
diff --git a/frontend/pages/events/index.tsx b/frontend/pages/events/index.tsx
new file mode 100644
index 000000000..cb2a2a075
--- /dev/null
+++ b/frontend/pages/events/index.tsx
@@ -0,0 +1,149 @@
+import { DateTime } from 'luxon'
+import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
+import Link from 'next/link'
+import React, { useMemo } from 'react'
+import styled from 'styled-components'
+
+import { BaseLayout } from '~/components/BaseLayout'
+import { Metadata, Title } from '~/components/common'
+import EventCard from '~/components/EventPage/EventCard'
+import { Club, ClubEvent } from '~/types'
+import { doApiRequest } from '~/utils'
+import { createBasePropFetcher } from '~/utils/getBaseProps'
+
+/**
+ * Return the default date range for events in the calendar view.
+ * Returns the events for a month with a little bit of padding on both edges (6 days).
+ */
+const getDefaultDateRange = () => ({
+ start: DateTime.local().startOf('day').minus({ days: 6 }),
+ end: DateTime.local().startOf('day').plus({ month: 1, days: 6 }),
+})
+
+const getBaseProps = createBasePropFetcher()
+
+export const getServerSideProps = (async (ctx) => {
+ const data = {
+ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined,
+ }
+ const dateRange = getDefaultDateRange()
+ const params = new URLSearchParams({
+ // eslint-disable-next-line camelcase
+ start_time__gte: dateRange.start.toISO(),
+ // eslint-disable-next-line camelcase
+ end_time__lte: dateRange.end.toISO(),
+ format: 'json',
+ })
+ // TODO: Add caching
+ const [baseProps, clubs, events] = await Promise.all([
+ getBaseProps(ctx),
+ doApiRequest('/clubs/directory/?format=json', data)
+ .then((resp) => resp.json() as Promise)
+ .then((resp) => resp.filter(({ approved }) => approved)),
+ doApiRequest(`/events/?${params.toString()}`, data).then(
+ (resp) => resp.json() as Promise,
+ ),
+ ])
+ const clubMap = new Map(clubs.map((club) => [club.code, club]))
+ const eventsWithClubs = events.map((event) => ({
+ ...event,
+ club: event.club ? clubMap.get(event.club) : null,
+ }))
+ return {
+ props: {
+ baseProps,
+ events: eventsWithClubs,
+ },
+ }
+}) satisfies GetServerSideProps
+
+type EventsPageProps = InferGetServerSidePropsType
+
+const classify = (arr: T[], predicate: (item: T) => K): Map => {
+ const map = new Map()
+ for (const item of arr) {
+ const key = predicate(item)
+ const list = map.get(key) || []
+ list.push(item)
+ map.set(key, list)
+ }
+ return map
+}
+
+const MainWrapper = styled.main`
+ margin: 0 auto;
+ width: 100vw;
+ max-width: 1400px;
+ padding: 20px;
+`
+const EventsListWrapper = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+ place-content: center;
+`
+const ListSeparator = styled.hr`
+ width: 100%;
+ margin: 20px 0;
+`
+
+const EventsPage: React.FC = ({ baseProps, events }) => {
+ const { pastEvents, liveEvents, upcomingEvents } = useMemo(() => {
+ const map = classify(events, (event) => {
+ const now = DateTime.local()
+ const startDate = DateTime.fromISO(event.start_time)
+ const endDate = DateTime.fromISO(event.end_time)
+ if (endDate < now) return 'past'
+ if (startDate <= now && now <= endDate) return 'live'
+ return 'upcoming'
+ })
+ return {
+ pastEvents: map.get('past') || [],
+ liveEvents: map.get('live') || [],
+ upcomingEvents: map.get('upcoming') || [],
+ }
+ }, [events])
+
+ return (
+
+
+
+ Live Events
+
+ {liveEvents.length === 0 && No live events right now.
}
+ {liveEvents.map((event) => (
+
+
+
+
+
+ ))}
+
+
+ Upcoming Events
+
+ {upcomingEvents.map((event) => (
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+export default EventsPage
diff --git a/frontend/pages/settings.tsx b/frontend/pages/settings.tsx
index febd4ecc8..58c32304c 100644
--- a/frontend/pages/settings.tsx
+++ b/frontend/pages/settings.tsx
@@ -12,6 +12,7 @@ import styled from 'styled-components'
import { UserInfo } from 'types'
import { OBJECT_NAME_TITLE, SHOW_MEMBERSHIP_REQUEST } from 'utils/branding'
+import TicketsTab from '~/components/Settings/TicketsTab'
import { BG_GRADIENT, CLUBS_BLUE, WHITE } from '~/constants/colors'
import { BORDER_RADIUS } from '~/constants/measurements'
@@ -73,6 +74,11 @@ const Settings = ({ userInfo, authenticated }: SettingsProps) => {
content: ,
disabled: !SHOW_MEMBERSHIP_REQUEST,
},
+ {
+ name: 'Tickets',
+ icon: 'empty_cart',
+ content: ,
+ },
{
name: 'Profile',
icon: 'user',
diff --git a/frontend/pages/tickets/[[...slug]].tsx b/frontend/pages/tickets/[[...slug]].tsx
new file mode 100644
index 000000000..73784f408
--- /dev/null
+++ b/frontend/pages/tickets/[[...slug]].tsx
@@ -0,0 +1,207 @@
+import { Center, Container, Icon } from 'components/common'
+import { NextPageContext } from 'next'
+import { ReactElement, useState } from 'react'
+import renderPage from 'renderPage'
+import styled from 'styled-components'
+import { doApiRequest } from 'utils'
+
+import { EventTicket } from '~/types'
+
+import { ALLBIRDS_GRAY, HOVER_GRAY, WHITE } from '../../constants/colors'
+import {
+ ANIMATION_DURATION,
+ BORDER_RADIUS,
+ mediaMaxWidth,
+ SM,
+} from '../../constants/measurements'
+
+/*
+Ticket.getInitialProps = async ({ query }): Promise => {
+ const id = query.slug[0]
+ return doApiRequest(`/events/${id}/tickets?format=json`, {
+ method: 'GET',
+ })
+ .then((resp) => resp.json())
+ .then((res) => {
+ console.log(res)
+ })
+}
+*/
+
+type CardProps = {
+ readonly hovering?: boolean
+ className?: string
+}
+
+type TicketsResponse = {
+ totals: EventTicket[]
+ available: EventTicket[]
+}
+
+type Buyer = {
+ fullname: string
+ id: string
+ ownerId: string
+ type: string
+}
+
+type BuyerResponse = {
+ buyers: Buyer[]
+}
+
+const Card = styled.div`
+ padding: 10px;
+ margin-top: 1rem;
+ box-shadow: 0 0 0 transparent;
+ transition: all ${ANIMATION_DURATION}ms ease;
+ border-radius: ${BORDER_RADIUS};
+ box-shadow: 0 0 0 ${WHITE};
+ background-color: ${({ hovering }) => (hovering ? HOVER_GRAY : WHITE)};
+ border: 1px solid ${ALLBIRDS_GRAY};
+ justify-content: space-between;
+ height: auto;
+ cursor: pointer;
+
+ &:hover,
+ &:active,
+ &:focus {
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
+ }
+
+ ${mediaMaxWidth(SM)} {
+ width: calc(100%);
+ padding: 8px;
+ }
+`
+
+const Title = styled.h1`
+ font-weight: 600;
+ font-size: 2rem;
+ margin: 1rem 0rem;
+`
+
+const Text = styled.h1`
+ font-weight: 400;
+ font-size: 1rem;
+ margin: 0.5rem 0rem;
+`
+// TODO: Add auth handling gracefully.
+
+const Ticket = ({ tickets, buyers, event, home }): ReactElement => {
+ // const Ticket = ({ event }): ReactElement => {
+
+ if (home) {
+ return (
+
+ Welcome to Ticketing! Please browse events with available tickets{' '}
+ here.
+
+ )
+ } else if (!tickets.totals) {
+ return No tickets found with given user permissions.
+ }
+ const { totals, available } = tickets
+ const ticks = {}
+
+ // totals.forEach((tick) => {
+ // if (ticks[tick.type] == null) {
+ // ticks[tick.type] = { type: tick.type }
+ // }
+ // ticks[tick.type].total = tick.count
+ // })
+
+ // available.forEach((tick) => {
+ // if (ticks[tick.type] == null) {
+ // ticks[tick.type] = { type: tick.type }
+ // }
+ // ticks[tick.type].available = tick.count
+ // })
+
+ // buyers.forEach((tick) => {
+ // if (ticks[tick.type] == null) {
+ // ticks[tick.type] = { type: tick.type }
+ // }
+ // if (ticks[tick.type].buyers == null) {
+ // ticks[tick.type].buyers = []
+ // }
+
+ // ticks[tick.type].buyers.push(tick.fullname)
+ // })
+
+ // tickets = []
+ // for (const [_, value] of Object.entries(ticks)) {
+ // tickets.push(value)
+ // }
+
+ return (
+ <>
+
+ All Tickets for {event.name}
+ {tickets.map((ticket, i) => (
+
+ ))}
+
+ >
+ )
+}
+
+const TicketCard = ({ ticket }) => {
+ const [viewBuyers, setViewBuyers] = useState(false)
+ return (
+
+
+
+ {ticket.type}
+
+ Total Tickets: {ticket.total}
+ Currently available: {ticket.available}
+ {
+ setViewBuyers(!viewBuyers)
+ }}
+ >
+ View Buyers {ticket.total && `(${ticket.total - ticket.available})`}{' '}
+ {ticket.buyers && (
+
+
+
+ )}
+
+
+ {viewBuyers &&
+ ticket.buyers &&
+ ticket.buyers.map((buyer) => {buyer})}
+
+
+ )
+}
+
+Ticket.getInitialProps = async ({ query, req }: NextPageContext) => {
+ const data = {
+ headers: req ? { cookie: req.headers.cookie } : undefined,
+ }
+ try {
+ if (!query || !query.slug) {
+ return { home: true, tickets: {}, event: {}, buyers: [] }
+ }
+ const id = query && query.slug ? query.slug[0] : -1
+ const [ticketsReq, eventReq] = await Promise.all([
+ doApiRequest(`/events/${id}/tickets?format=json`, data),
+ doApiRequest(`/events/${id}/?format=json`, data),
+ ])
+
+ const ticketsRes = await ticketsReq.json()
+ const eventRes = await eventReq.json()
+
+ return {
+ home: false,
+ tickets: ticketsRes,
+ event: eventRes,
+ buyers: buyersRes.buyers,
+ }
+ } catch (err) {
+ // console.log(err)
+ }
+}
+
+export default renderPage(Ticket)
diff --git a/frontend/pages/tickets/checkout.tsx b/frontend/pages/tickets/checkout.tsx
new file mode 100644
index 000000000..230637325
--- /dev/null
+++ b/frontend/pages/tickets/checkout.tsx
@@ -0,0 +1,58 @@
+import { NextPageContext } from 'next'
+
+import { Container, Metadata, Title } from '~/components/common'
+import CartTickets from '~/components/TicketsPage/CartTickets'
+import { SNOW } from '~/constants'
+import renderPage from '~/renderPage'
+import { ClubEventType, EventTicket } from '~/types'
+
+interface Props {
+ initialCart: EventTicket[]
+}
+
+function TicketsCheckoutPage({ initialCart }: Props) {
+ return (
+ <>
+
+
+ Checkout Tickets
+
+
+ >
+ )
+}
+
+const testCart: EventTicket[] = [
+ {
+ event: {
+ id: 12345,
+ name: 'Awesome Event Name',
+ club_name: 'UPenn Natalist Society',
+ badges: [],
+ image_url: null,
+ club: null,
+ description: 'This is a description of the event',
+ start_time: '2020-04-20T12:00:00Z',
+ end_time: '2020-04-20T14:00:00Z',
+ is_ics_event: false,
+ large_image_url: null,
+ ticketed: 'true',
+ location: 'Houston Hall',
+ pinned: false,
+ type: ClubEventType.OTHER,
+ url: 'https://pennclubs.com',
+ },
+ id: '497f6eca-6276-4993-bfeb-53cbbbba6f08',
+ type: ClubEventType.OTHER,
+ owner: 'James Adams',
+ },
+]
+
+TicketsCheckoutPage.getInitialProps = async (ctx: NextPageContext) => {
+ // const initialCart = await doApiRequest('/cart/')
+ return {
+ initialCart: testCart,
+ }
+}
+
+export default renderPage(TicketsCheckoutPage)
diff --git a/frontend/public/static/img/empty_cart.svg b/frontend/public/static/img/empty_cart.svg
new file mode 100644
index 000000000..046ff4fd0
--- /dev/null
+++ b/frontend/public/static/img/empty_cart.svg
@@ -0,0 +1,55 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/public/static/img/icons/credit-card.svg b/frontend/public/static/img/icons/credit-card.svg
new file mode 100644
index 000000000..1b7fd029d
--- /dev/null
+++ b/frontend/public/static/img/icons/credit-card.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/static/img/icons/qr-code.svg b/frontend/public/static/img/icons/qr-code.svg
new file mode 100644
index 000000000..0cf5f2435
--- /dev/null
+++ b/frontend/public/static/img/icons/qr-code.svg
@@ -0,0 +1,20 @@
+
diff --git a/frontend/public/static/img/icons/send.svg b/frontend/public/static/img/icons/send.svg
new file mode 100644
index 000000000..42ef2a243
--- /dev/null
+++ b/frontend/public/static/img/icons/send.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/static/img/icons/shopping-cart.svg b/frontend/public/static/img/icons/shopping-cart.svg
new file mode 100644
index 000000000..17a40bf47
--- /dev/null
+++ b/frontend/public/static/img/icons/shopping-cart.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/static/img/icons/swap-horiz.svg b/frontend/public/static/img/icons/swap-horiz.svg
new file mode 100644
index 000000000..b2c52ef43
--- /dev/null
+++ b/frontend/public/static/img/icons/swap-horiz.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/public/static/img/icons/ticket.svg b/frontend/public/static/img/icons/ticket.svg
new file mode 100644
index 000000000..850ecde96
--- /dev/null
+++ b/frontend/public/static/img/icons/ticket.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/public/static/img/tickets.png b/frontend/public/static/img/tickets.png
new file mode 100644
index 000000000..86161ac7c
Binary files /dev/null and b/frontend/public/static/img/tickets.png differ
diff --git a/frontend/renderPage.tsx b/frontend/renderPage.tsx
index adda8c1e6..69fba0672 100644
--- a/frontend/renderPage.tsx
+++ b/frontend/renderPage.tsx
@@ -34,7 +34,7 @@ import {
import { SITE_ID } from './utils/branding'
import { logException } from './utils/sentry'
-const ToastStyle = styled.div`
+export const ToastStyle = styled.div`
& .Toastify__toast-container {
font-family: inherit;
}
@@ -67,12 +67,12 @@ const ToastStyle = styled.div`
}
`
-const Wrapper = styled.div`
+export const Wrapper = styled.div`
min-height: calc(100vh - ${NAV_HEIGHT});
background: ${SNOW};
`
-const GlobalStyle = createGlobalStyle`
+export const GlobalStyle = createGlobalStyle`
a {
color: ${BULMA_A};
}
@@ -86,7 +86,7 @@ const GlobalStyle = createGlobalStyle`
* Override the Bulma styles here if the site is not Penn Clubs.
* Otherwise, don't do any overrides to interfere with Bulma as little as possible.
*/
-const RenderPageWrapper = styled.div`
+export const RenderPageWrapper = styled.div`
display: flex;
flex-direction: column;
background-color: ${WHITE};
@@ -280,7 +280,7 @@ function renderPage(Page: PageComponent): React.ComponentType & {
resp.json().then((userInfo) => {
this.setState(
{
- userInfo: userInfo,
+ userInfo,
},
this.checkRedirect,
)
diff --git a/frontend/types.ts b/frontend/types.ts
index ffd921cbd..d994a6836 100644
--- a/frontend/types.ts
+++ b/frontend/types.ts
@@ -61,12 +61,20 @@ export interface ClubEvent {
large_image_url: string | null
location: string | null
name: string
+ ticketed: string
pinned: boolean
start_time: string
type: ClubEventType
url: string | null
}
+export interface EventTicket {
+ id: string
+ event: ClubEvent
+ type: ClubEventType
+ owner: string
+}
+
export interface ClubApplication {
id: number
name: string
@@ -394,3 +402,14 @@ export type ApplicationResponse = {
question_type: string
question: ApplicationQuestion
}
+
+export type TicketEntry = {
+ type: string
+ count: number
+ price: number
+}
+
+export type TicketAvailability = {
+ totals: TicketEntry[]
+ available: TicketEntry[]
+}
diff --git a/frontend/utils.tsx b/frontend/utils.tsx
index e76e604f5..e32bfb0ba 100644
--- a/frontend/utils.tsx
+++ b/frontend/utils.tsx
@@ -263,7 +263,7 @@ export function apiSetFavoriteStatus(
if (favorited) {
return doApiRequest('/favorites/?format=json', {
method: 'POST',
- body: { club: club },
+ body: { club },
}).then(() => undefined)
} else {
return doApiRequest(`/favorites/${club}/?format=json`, {
@@ -280,7 +280,7 @@ export function apiSetSubscribeStatus(
return doApiRequest('/subscriptions/?format=json', {
method: 'POST',
body: {
- club: club,
+ club,
},
}).then(() => undefined)
} else {
diff --git a/frontend/utils/getBaseProps.ts b/frontend/utils/getBaseProps.ts
new file mode 100644
index 000000000..db4fd6b18
--- /dev/null
+++ b/frontend/utils/getBaseProps.ts
@@ -0,0 +1,68 @@
+import { GetServerSidePropsContext } from 'next'
+
+import { doApiRequest } from '~/utils'
+
+type FromNextContextOrPureValue = T | ((ctx: GetServerSidePropsContext) => T)
+
+const isFunction = (
+ value: FromNextContextOrPureValue,
+): value is (ctx: GetServerSidePropsContext) => T => typeof value === 'function'
+
+const resolveNextContextValue = (
+ value: FromNextContextOrPureValue,
+ ctx: GetServerSidePropsContext,
+): T => (isFunction(value) ? value(ctx) : value)
+
+export const createBasePropFetcher =
+ (args?: {
+ permissions?: FromNextContextOrPureValue
+ additionalPermissions?: FromNextContextOrPureValue
+ }) =>
+ async (ctx: GetServerSidePropsContext) => {
+ const permissions = resolveNextContextValue(args?.permissions, ctx)
+ const additionalPermissions = resolveNextContextValue(
+ args?.additionalPermissions,
+ ctx,
+ )
+ const fetchData = {
+ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined,
+ }
+ const fetchSettings = async () => {
+ try {
+ const resp = await doApiRequest('/settings/?format=json', fetchData)
+ if (resp.ok) {
+ return resp.json()
+ }
+ return null
+ } catch (e) {
+ return null
+ }
+ }
+ const fetchPermissions = async (): Promise> => {
+ if ((permissions?.length ?? 0) <= 0) return {}
+ const resp = await doApiRequest(
+ `/settings/permissions/?perm=${[
+ ...(permissions ?? []),
+ ...(additionalPermissions ?? []),
+ ].join(',')}&format=json`,
+ fetchData,
+ )
+ if (!resp.ok) {
+ return permissions!.reduce((acc, perm) => {
+ acc[perm] = false
+ return acc
+ }, {})
+ }
+ const data = await resp.json()
+ return data.permissions
+ }
+ const [userInfo, userPermissions] = await Promise.all([
+ fetchSettings(),
+ fetchPermissions(),
+ ])
+ const auth = {
+ authenticated: !!userInfo,
+ userInfo,
+ }
+ return { auth, permissions: userPermissions }
+ }
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index ad2d5ec1a..e30c5b022 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -3525,6 +3525,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8"
integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==
+"@types/luxon@^3.4.2":
+ version "3.4.2"
+ resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7"
+ integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==
+
"@types/mdx@^2.0.0":
version "2.0.11"
resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.11.tgz#21f4c166ed0e0a3a733869ba04cd8daea9834b8e"
@@ -3540,6 +3545,13 @@
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
+"@types/moment-timezone@^0.5.30":
+ version "0.5.30"
+ resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.30.tgz#340ed45fe3e715f4a011f5cfceb7cb52aad46fc7"
+ integrity sha512-aDVfCsjYnAQaV/E9Qc24C5Njx1CoDjXsEgkxtp9NyXDpYu4CCbmclb6QhWloS9UTU/8YROUEEdEkWI0D7DxnKg==
+ dependencies:
+ moment-timezone "*"
+
"@types/node-fetch@^2.6.4":
version "2.6.11"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24"
@@ -3909,6 +3921,11 @@ ansi-colors@^4.1.1:
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
+ansi-colors@^4.1.3:
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
+ integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==
+
ansi-escapes@^4.3.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -4834,6 +4851,11 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
+commander@^10.0.0:
+ version "10.0.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
+ integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
+
commander@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
@@ -7734,6 +7756,15 @@ listr2@^3.8.3:
through "^2.3.8"
wrap-ansi "^7.0.0"
+local-ssl-proxy@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/local-ssl-proxy/-/local-ssl-proxy-2.0.5.tgz#792ad902a61c36a650f8c45b790e19765fd48a11"
+ integrity sha512-/oWxo8IX12HTVNWza+Ui1HNz7TfrNsVyD0+ZSyf5UZocJXdR70wfNb/xHgNfEPFyjgbsaRFdqFdfxg2d11aANQ==
+ dependencies:
+ ansi-colors "^4.1.3"
+ commander "^10.0.0"
+ http-proxy "^1.18.1"
+
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
@@ -7835,7 +7866,7 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
-luxon@^3.2.1:
+luxon@^3.2.1, luxon@^3.4.4:
version "3.4.4"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af"
integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==
@@ -8033,7 +8064,7 @@ minimist@^1.2.6, minimist@^1.2.8:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
-moment-timezone@^0.5.40, moment-timezone@^0.5.45:
+moment-timezone@*, moment-timezone@^0.5.40, moment-timezone@^0.5.45:
version "0.5.45"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c"
integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==
diff --git a/k8s/yarn.lock b/k8s/yarn.lock
index d0746bd49..36332da46 100644
--- a/k8s/yarn.lock
+++ b/k8s/yarn.lock
@@ -1694,6 +1694,8 @@ dotenv@^16.0.1:
version "16.3.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
+<<<<<<< HEAD
+=======
downlevel-dts@^0.11.0:
version "0.11.0"
@@ -1708,7 +1710,21 @@ electron-to-chromium@^1.4.477:
version "1.4.522"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.522.tgz#ef29e5508571d52cf45806536dba5d397492667d"
integrity sha512-KGKjcafTpOxda0kqwQ72M0tDmX6RsGhUJTy0Hr7slt0+CgHh9Oex8JdjY9Og68dUkTLUlBOJC0A5W5Mw3QSGCg==
+>>>>>>> master
+downlevel-dts@^0.11.0:
+ version "0.11.0"
+ resolved "https://registry.yarnpkg.com/downlevel-dts/-/downlevel-dts-0.11.0.tgz#514a2d723009c5845730c1db6c994484c596ed9c"
+ integrity sha512-vo835pntK7kzYStk7xUHDifiYJvXxVhUapt85uk2AI94gUUAQX9HNRtrcMHNSc3YHJUEHGbYIGsM99uIbgAtxw==
+ dependencies:
+ semver "^7.3.2"
+ shelljs "^0.8.3"
+ typescript next
+
+electron-to-chromium@^1.4.477:
+ version "1.4.522"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.522.tgz#ef29e5508571d52cf45806536dba5d397492667d"
+ integrity sha512-KGKjcafTpOxda0kqwQ72M0tDmX6RsGhUJTy0Hr7slt0+CgHh9Oex8JdjY9Og68dUkTLUlBOJC0A5W5Mw3QSGCg==
emittery@^0.7.1:
version "0.7.2"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82"