From d2e5758f1498b17cd3f20d08c37969d3e8c9c7bd Mon Sep 17 00:00:00 2001
From: Rohan Gupta
Date: Sun, 28 Apr 2024 23:52:42 -0400
Subject: [PATCH] Ticketing (#501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Ticket model
* some basic untested functionality
* tested routes, added view ticket count route
* lint
* email confirmation, qrcode, retrieve ticket
* remove commented stuff
* created cart model and add to cart, validate cart, and checkout from cart views
* added holding expiration functionality, holding is initiated at checkout and updated when any cart is validated before checkout
* lint and tests
* added cart creation for a user upon adding to a cart if a cart does not yet exist
* slightly changed holding updates, added update to holding status when adding to cart
* atomic transactions, cleanup, and some views
* holding, validation improvements, perms
* some docstring stuff
* rebase + pipfile lock
* use master pipfile.lock
* lint and pre-commit fixes
* minor bugs
* add to populate script
* lint
* rebase, fix some documentation
* Update ticketing (backend) branch (#612)
* Merge master into ticketing
* Move ticketing migration to end
* Revert "Update ticketing (backend) branch (#612)"
This reverts commit 03214afe42d3574029b38b47947611a622ce4fe6.
* Merge ticketing branches (#615)
Merge frontend ticketing branch into main ticketing feature branch.
---------
Co-authored-by: Rohan Gupta
Co-authored-by: dfeng678
Co-authored-by: alnasir7
Co-authored-by: Mohamed Abaker
Co-authored-by: DiiZyy
Co-authored-by: printer83mph
Co-authored-by: Avi Upadhyayula <69180850+aviupadhyayula@users.noreply.github.com>
Co-authored-by: cphalen
Co-authored-by: Alexander Kyimpopkin <39439486+alxkp@users.noreply.github.com>
Co-authored-by: Joy Liu
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
Co-authored-by: joel8019 <46795321+joel8019@users.noreply.github.com>
Co-authored-by: Eunsoo Shin
* Merge master into ticketing
* Fix TS errors
* Update Links to new Next.js behavior
Make TicketsTab not crash
* missed merge conflict
* migrations
* Ticketing integrate cybersource -> ticketing (#652)
* Add check on event deletion
* add cybersource package
* Capture context generation + local dev setup instructions (#645)
* capture context view
* fix populate
* move capture context generation to checkout view
* Optimize Django ops in cart validation
* Use Q objects in cart validation
* switch out nginx for local-ssl-proxy
---------
Co-authored-by: aviupadhyayula
* fix target origin url
* Closes #632 (#648)
* This commit resolves #632:
- Add logic to interact with the CyberSource API to validate
transaction data and also confirm the payment.
- Add appropriate error handling for API invocation failures causing
transaction failure.
- Store the transaction data in a new model `TicketTransactionRecord`
for bookkeeping purposes. Each ticket is also associated with an
instance of this class.
- On transaction success, assign the ticket to the user, remove holds
and from cart, and send out confirmation email.
* Address PR comments, query opt, and others
- More judicious use of `select_for_update`: only lock when updating
holder/owner.
- Better prefetching/bulk updating throughout the query logic
- Return HTTP status codes
- Refactor as per PR comments
* Validate the transient token's signature
- I tested the workflow from `initiate_checkout` to `complete_checkout`
and was able to get it working.
- Ironed out a few bugs
- Add the `reconciliation_id` as a field on the transaction record;
could be useful to generate reports. We'll need to figure out what else
to store to interact with their reporting API.
* Make reconciliation_id nullable to support free tickets
* Address nit, refactor ticket count logic to SQL
* merge migrations...
* pipenv lock again
* Pin uwsgi...2.0.25 breaks CI
---------
Co-authored-by: aviupadhyayula
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
* Set and enforce order limit on ticket purchases (#654)
* Set & enforce order limit on ticket purchases
* Add migration
* Default tix order limit to 10
* Consolidate migrations
* Check each carted event's order limit
* Move limit validation to `add_to_cart`
* Fix typo :pensive:
* Address nits with validation logic
* Minor refactor
* Check ticket holds before completing checkout (#657)
* Ticketing price integration (#659)
* Integrate ticket price field into ticket creation/list views, as well into ticket creation frontend.
* Enforce non-negative ticket prices at creation
* Add frontend checks for fractional/negative ticket count and cost.
* Prevent users from entering negative/fractional ticket counts/price for now.
---------
Co-authored-by: aviupadhyayula
* feat(events): new events page
* todo remove
* Add support for group discounts (#661)
* Add group discount fields to ticket model
* Ingest group discount info at ticket creation
* Add validator for group size
* Add comments
* Apply discounts when checking out
* Remove model-level validators
* Remove validators from migration
* Improve comments
* Minor refactor
* Default group_discount to 0
* Remove check for discount in cart calculation
* Consolidate validation checks upon ticket creation
* Fix typo in validation upon ticket creation
* Owned Tickets Tab in Settings (#663)
* Owned tickets tab skeleton code.
* :tada: Functional but suspicious code
* :broom: Fix some good practice
---------
Co-authored-by: Julian Weng
* Ticketing backend tests (#666)
* Add test cases for backend ticketing APIs
Long overdue addition of tests to the ticketing backend.
Tests and fixes all the APIs under the Event and Ticket models.
There are more complex workflows with race conditions etc that are not
tested, but should be at some point. Unmerged functionality is also
not tested yet.
* Don't use locked rows to groupby
* Set cybersource settings in CI
* Address feedback
* Rm console.log on frontend
* Add Group Discount to Create Ticket Flow and Auto Scroll Down (#669)
* Add to cart feature (styling is borked)
* :bug: Broken code
* 🐛 fixed
* :art: Readd event preview
* :broom: Less jank way of doing group discount visibility
* :art: Address comments and actually type things
* :art: Address nit
---------
Co-authored-by: Julian Weng
Co-authored-by: Eunsoo Shin
* Use capture context in cart checkout (#671)
* Use capture context to verify transient token
* Add migration
* Minor changes to documentation
* Add tests
* Add comment explaining max char length
* fix N+1 query due to attribute
* Add support for ticket drop times (#672)
* Add ticket drop time to event attributes
* Set ticket drop time at tickets creation
* Add unit tests
* Minor refactor to tests
* Revert unneeded changes to Pipfile.lock
* Allow users to specify drop time + more guards/tests
* Remove dev artifact in tests
* Fix all typos with "transferred"
* Add 403 to response schema
* Only display tickets after they've dropped
* Add ability to transfer ownership of tickets (#653)
* add ability to transfer tickets
* send confirmation emails on transfer
* nit
* address comments, add ticket creation serializer
* fix migration
* remove print statement :pensive:
* grrr
* remove serializer, add tests
* tests + fix
* here at Penn Clubs, we love nits
* lint
* Ticketing Cart Pre-Checkout (#670)
* Cart skeleton
* Basic UI to payment.
* More appropriately-sized shopping cart icon
* Payment integration 1st step.
* fix(move)
* fix(TicketCard): extract and abstract
* checkout flow ui
* wip(payment)
* :tada: Add ability to remove tickets from cart
* :bug: Better backend error and remove cart logic
* :tada: Modify sold_out to return event type and count
* :tada: Add cart empty view and edit mode
* :art: Add empty view
* :art: Grafik design is my passion
* :tada: Modify edit success and display toast, correct e2e behavior
* :bug: Change color to make edit mode more obvious
* :tada: Toast for sold out tickets
* Add frontend auth check to cart page and fix sold_out toast functionality (multiple toasts per ticket, ticket event name)
* Fix backend tests for group discounts, new cart API, and more (#675)
* Refactor cart summation to helper fn
* Add tests for group discounts and cart totaling
* Minor change to docs
* Only populate `sold_out_tickets` if tickets cannot be replaced
* Refactor tests to use new cart API
* Make openAPI docs happy
* Make `_calculate_cart_total` static method
* Group discount shouldn't activate below threshold
* Fix API docs & improve tests
* Add minor subtest
* Align tests with new API
* Improve invalid ticket replacement
* :tada: Default to 1 ticket when buying smh
* :tada: Add a bunch of style and features
* :art: Kinda responsive
---------
Co-authored-by: Julian Weng
Co-authored-by: Eunsoo Shin
Co-authored-by: Avi Upadhyayula <69180850+aviupadhyayula@users.noreply.github.com>
Co-authored-by: aviupadhyayula
* Fix breaking changes introduced in #670 (#680)
* Add guards on ticketed event deletion (#667)
* Move guards on event deletion to the right place
* Add @update_holds to destroy
* Fix typo in testing docs
* Test guards on event deletion
* Improve test case logic
* Address nit in tests
* Default billing phone number to null
* Basic mobile responsiveness for TicketCard
* Add route for admins to issue tickets to users (#679)
* Add issues_ticket route
* Improve efficiency
* Add tests for `issue_tickets`
* Minor refactor to tests
* Create transaction records after issuing
* Make unit test more exhaustive
* Return errors as array in response
* Lock issue_tickets behind perms
* Revert "Lock issue_tickets behind perms"
This reverts commit 47e63b0893818372cd261ff93950262c97881e98.
* Remove unnecessary holds
* Change input schema naming
* Auth lock issue_tickets route
* Add test for perms
* Add route for attendance tracking (#684)
* attendance tracking
* add comment
* Fix ticket interface for sellers (#665)
* Fix ticket interface for sellers.
* Clarify type for buyersPerm.
* :art: Yay
* Frontend for admin ticket transfering and base props fix.
* :tada: Add issue ticket
* Frontend for marking tickets as attended/not attended
* :art: Split String and handle input edge case
* :broom: Hehe
* :rotating_light: Some changes with a fake api endpoint
* :tada: Some fire UI
* :bug: Error Response not showing correctly
* :art: Brr
* Lint, improve UI language, fix items remaining in cart after deletion through button, fix updating item quantities in cart through button, fix success vs error toast for adding tickets to cart.
* API integration for issuing tickets
* Ticket transfer interface
* Integrate attendance into frontend and add warning popup for un-attending people
* :art: Nit
* :bug: Joy fixes everything
---------
Co-authored-by: Joy Liu <34288846+joyliu-q@users.noreply.github.com>
Co-authored-by: Joy Liu
* ✨ feat(checkout): cybersource integration
* :art: It's toast time
* 🛠️ fix(close): handle modal close with confirm
* ✨ feat(success): checkout success message
* ❌ feat(handle error)
* 🛠️ fix(message): more explicit message for modal close confirmation
* Fix lint, min/max add to cart quantities per class
* Add support for free tickets (#658)
* Added support for free tickets
* fix lint error
* fix lint error
* Added free ticket tests
* Address feedback
* Address feedback
* Add user a parameter to _give_tickets
* :arrow_down: Downgrade Eslint dependency
* Add ability to make tickets not buyable + disable paid tickets for beta (#685)
* Add buyable field and try to enforce it. Disable tickets with payments on frontend.
* Add frontend handling for no-cost cart.
* Ticketing Beta branding and File Cleanup (#686)
* Add beta tag everywhere and add frontend auth check for issuing tickets
* Delete legacy events code
* Merge master into ticketing (#687)
* fix expires_at test test (#664)
* Cast exception to str in management command
* Move submissions from /apply to user profile (#638)
* finished changes
* Remove Wharton applications from user profile (and update branch) (#641)
* Update main.ts
* Update frontend dependencies (#616)
Upgrade from Node 14 to Node 20 and bump frontend dependency versions to current.
---------
Co-authored-by: Julian Weng
* Update README.md
* Update README backend instructions and intro blurb
* Fix admin page access on frontend. (#626)
Add frontend check for existing pre-loaded permissions on /admin.
* Removed deprecated QuestionResponse model and duplicate line (#625)
* Remove deprecated QuestionResponse model
* remove duplicate line: 'rank field in Club'
* made migrations after deleting QuestionResponse
* Streamline django storages config (#618)
* Add staticfiles
* Fix AWS bucket routing for boto
* Try setting credentials through env vars
* Try renaming env vars?
* Set AWS region
* add back staticfiles
* Remove region and signature version
* Move credentials to new API
* Use env variables and remove staticfiles
* Add back staticfiles
* Make code look pretty
---------
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
* Add hour to displayed application deadline (#628)
* Add hour for application due dates
* Make linter happy
* Specify Eastern time zone
* Display username if name is empty (#637)
* finished changes
* Remove Wharton applications from user profile
* Fix weird artifacts from merge
---------
Co-authored-by: Joy Liu <34288846+joyliu-q@users.noreply.github.com>
Co-authored-by: Julian Weng
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
Co-authored-by: Thomas Ngulube <47449914+Porcupine1@users.noreply.github.com>
Co-authored-by: Owen Lester
* Bump debounce timeout to 400ms (#640)
* Bump debounce timeout to 400ms
* Make linter happy
* small changes
* added type
* merging
* deleted file
---------
Co-authored-by: Avi Upadhyayula <69180850+aviupadhyayula@users.noreply.github.com>
Co-authored-by: Joy Liu <34288846+joyliu-q@users.noreply.github.com>
Co-authored-by: Julian Weng
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
Co-authored-by: Thomas Ngulube <47449914+Porcupine1@users.noreply.github.com>
* Add carousel (#622)
* add carousel
* Old react-multi-carousel
* fixed npm yarn
* remvoed packagelock.json
* fixing issues
* change height
* minor changes
* deleted a comment
* merging?
* delete one onClick
* change breakpoint
* Revert "Move submissions from /apply to user profile (#638)" (#673)
This reverts commit 29d9a123d52ffe79ee5574b82127d240c2b24219.
* Revert "Add carousel (#622)" (#674)
This reverts commit fcab615cb01cbd51203df0d3d04b432f115789b5.
* Improve error reporting in mgmt cmds (#678)
* Fix bug in Excel column names (#683)
* Remove unused `tickets_count` field
---------
Co-authored-by: Thomas Ngulube <47449914+Porcupine1@users.noreply.github.com>
Co-authored-by: owlester12 <64493239+owlester12@users.noreply.github.com>
Co-authored-by: Joy Liu <34288846+joyliu-q@users.noreply.github.com>
Co-authored-by: Julian Weng
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
---------
Co-authored-by: cphalen
Co-authored-by: dfeng678
Co-authored-by: Avi Upadhyayula <69180850+aviupadhyayula@users.noreply.github.com>
Co-authored-by: Julian Weng
Co-authored-by: alnasir7
Co-authored-by: Mohamed Abaker
Co-authored-by: DiiZyy
Co-authored-by: printer83mph
Co-authored-by: Alexander Kyimpopkin <39439486+alxkp@users.noreply.github.com>
Co-authored-by: Joy Liu
Co-authored-by: Rohan Moniz <60864468+rm03@users.noreply.github.com>
Co-authored-by: joel8019 <46795321+joel8019@users.noreply.github.com>
Co-authored-by: Eunsoo Shin
Co-authored-by: Anthony Li
Co-authored-by: aviupadhyayula
Co-authored-by: Joy Liu <34288846+joyliu-q@users.noreply.github.com>
Co-authored-by: Thomas Ngulube <47449914+Porcupine1@users.noreply.github.com>
Co-authored-by: owlester12 <64493239+owlester12@users.noreply.github.com>
---
.gitignore | 3 +-
README.md | 12 +
backend/Pipfile | 5 +-
backend/Pipfile.lock | 155 +-
backend/clubs/admin.py | 6 +
backend/clubs/management/commands/populate.py | 33 +
backend/clubs/migrations/0091_cart_ticket.py | 90 +
.../migrations/0092_auto_20221118_1424.py | 27 +
.../migrations/0095_merge_20240128_1321.py | 13 +
.../migrations/0096_merge_20240304_1450.py | 14 +
backend/clubs/migrations/0097_ticket_price.py | 18 +
...sactionrecord_ticket_transaction_record.py | 56 +
.../migrations/0100_merge_20240412_2206.py | 13 +
.../migrations/0101_merge_20240414_1708.py | 12 +
.../0102_event_ticket_order_limit.py | 18 +
...ticket_group_discount_ticket_group_size.py | 28 +
.../migrations/0104_cart_checkout_context.py | 18 +
.../migrations/0105_event_ticket_drop_time.py | 18 +
...ion_record_ticket_transferable_and_more.py | 78 +
.../clubs/migrations/0107_ticket_attended.py | 17 +
.../clubs/migrations/0108_ticket_buyable.py | 18 +
backend/clubs/models.py | 174 +-
backend/clubs/permissions.py | 10 +-
backend/clubs/serializers.py | 23 +
backend/clubs/urls.py | 2 +
backend/clubs/views.py | 2697 ++++++++++++-----
backend/pennclubs/settings/base.py | 3 +
backend/pennclubs/settings/ci.py | 10 +
backend/pennclubs/settings/development.py | 12 +-
backend/pennclubs/settings/production.py | 10 +
.../templates/emails/ticket_confirmation.html | 49 +
backend/templates/emails/ticket_transfer.html | 27 +
backend/tests/clubs/test_documentation.py | 2 +-
backend/tests/clubs/test_ticketing.py | 1520 ++++++++++
frontend/components/BaseLayout.tsx | 51 +
frontend/components/ClubEditPage.tsx | 10 +-
.../ClubEditPage/ApplicationsPage.tsx | 2 +-
.../components/ClubEditPage/EventsCard.tsx | 62 +-
.../ClubEditPage/EventsImportCard.tsx | 6 +-
.../components/ClubEditPage/QRCodeCard.tsx | 39 +-
.../components/ClubEditPage/TicketsModal.tsx | 389 +++
.../ClubEditPage/TicketsViewCard.tsx | 55 +
frontend/components/ClubPage/Actions.tsx | 6 +-
frontend/components/EventPage/EventCard.tsx | 129 +-
frontend/components/EventPage/EventModal.tsx | 115 +-
frontend/components/EventPage/SyncModal.tsx | 5 +-
frontend/components/FormComponents.tsx | 2 +-
frontend/components/Header/Links.tsx | 31 +-
frontend/components/Header/index.tsx | 5 -
frontend/components/ModelForm.tsx | 3 +
frontend/components/SearchBar.tsx | 2 +-
frontend/components/Settings/BulkEditTab.tsx | 13 +-
.../Settings/TicketTransferModal.tsx | 66 +
.../components/Settings/TicketsTab/index.tsx | 260 ++
.../Settings/WhartonApplicationTab.tsx | 2 +-
frontend/components/Tickets/CartTickets.tsx | 420 +++
frontend/components/Tickets/ManageBuyer.tsx | 69 +
frontend/components/Tickets/PaymentForm.tsx | 51 +
frontend/components/Tickets/TicketCard.tsx | 420 +++
frontend/components/common/BetaTag.tsx | 15 +
frontend/components/common/CSVTagInput.tsx | 84 +
frontend/components/common/Modal.tsx | 4 +-
frontend/components/common/Table.tsx | 106 +-
frontend/constants/routes.ts | 1 +
frontend/package.json | 12 +-
frontend/pages/_document.tsx | 21 +-
frontend/pages/events.tsx | 941 ------
frontend/pages/events/[id].tsx | 395 +++
frontend/pages/events/checkout.tsx | 69 +
frontend/pages/events/index.tsx | 149 +
frontend/pages/settings.tsx | 6 +
frontend/pages/tickets/[[...slug]].tsx | 404 +++
frontend/pages/zoom.tsx | 4 +-
frontend/public/static/img/empty_cart.svg | 55 +
frontend/public/static/img/empty_cart_two.svg | 20 +
.../public/static/img/icons/credit-card.svg | 1 +
frontend/public/static/img/icons/qr-code.svg | 20 +
frontend/public/static/img/icons/send.svg | 1 +
.../public/static/img/icons/shopping-cart.svg | 61 +
.../public/static/img/icons/swap-horiz.svg | 10 +
frontend/public/static/img/icons/ticket.svg | 3 +
frontend/public/static/img/tickets.png | Bin 0 -> 101603 bytes
frontend/renderPage.tsx | 10 +-
frontend/tsconfig.json | 1 +
frontend/types.ts | 24 +
frontend/utils.tsx | 4 +-
frontend/utils/branding.tsx | 1 +
frontend/utils/getBaseProps.ts | 68 +
frontend/yarn.lock | 241 +-
k8s/yarn.lock | 16 +
90 files changed, 8223 insertions(+), 1928 deletions(-)
create mode 100644 backend/clubs/migrations/0091_cart_ticket.py
create mode 100644 backend/clubs/migrations/0092_auto_20221118_1424.py
create mode 100644 backend/clubs/migrations/0095_merge_20240128_1321.py
create mode 100644 backend/clubs/migrations/0096_merge_20240304_1450.py
create mode 100644 backend/clubs/migrations/0097_ticket_price.py
create mode 100644 backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py
create mode 100644 backend/clubs/migrations/0100_merge_20240412_2206.py
create mode 100644 backend/clubs/migrations/0101_merge_20240414_1708.py
create mode 100644 backend/clubs/migrations/0102_event_ticket_order_limit.py
create mode 100644 backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py
create mode 100644 backend/clubs/migrations/0104_cart_checkout_context.py
create mode 100644 backend/clubs/migrations/0105_event_ticket_drop_time.py
create mode 100644 backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py
create mode 100644 backend/clubs/migrations/0107_ticket_attended.py
create mode 100644 backend/clubs/migrations/0108_ticket_buyable.py
create mode 100644 backend/templates/emails/ticket_confirmation.html
create mode 100644 backend/templates/emails/ticket_transfer.html
create mode 100644 backend/tests/clubs/test_ticketing.py
create mode 100644 frontend/components/BaseLayout.tsx
create mode 100644 frontend/components/ClubEditPage/TicketsModal.tsx
create mode 100644 frontend/components/ClubEditPage/TicketsViewCard.tsx
create mode 100644 frontend/components/Settings/TicketTransferModal.tsx
create mode 100644 frontend/components/Settings/TicketsTab/index.tsx
create mode 100644 frontend/components/Tickets/CartTickets.tsx
create mode 100644 frontend/components/Tickets/ManageBuyer.tsx
create mode 100644 frontend/components/Tickets/PaymentForm.tsx
create mode 100644 frontend/components/Tickets/TicketCard.tsx
create mode 100644 frontend/components/common/BetaTag.tsx
create mode 100644 frontend/components/common/CSVTagInput.tsx
delete mode 100644 frontend/pages/events.tsx
create mode 100644 frontend/pages/events/[id].tsx
create mode 100644 frontend/pages/events/checkout.tsx
create mode 100644 frontend/pages/events/index.tsx
create mode 100644 frontend/pages/tickets/[[...slug]].tsx
create mode 100644 frontend/public/static/img/empty_cart.svg
create mode 100644 frontend/public/static/img/empty_cart_two.svg
create mode 100644 frontend/public/static/img/icons/credit-card.svg
create mode 100644 frontend/public/static/img/icons/qr-code.svg
create mode 100644 frontend/public/static/img/icons/send.svg
create mode 100644 frontend/public/static/img/icons/shopping-cart.svg
create mode 100644 frontend/public/static/img/icons/swap-horiz.svg
create mode 100644 frontend/public/static/img/icons/ticket.svg
create mode 100644 frontend/public/static/img/tickets.png
create mode 100644 frontend/utils/getBaseProps.ts
diff --git a/.gitignore b/.gitignore
index 35a1877f2..79a6e3647 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,8 +23,9 @@ htmlcov/
# Test database
db.sqlite3
-# Mac
+# Misc
.DS_Store
+*.pem
# React
node_modules/
diff --git a/README.md b/README.md
index 9237fee70..542249db7 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ React/Next.js frontend and Django-based REST API.
## Installation
+You will need to start both the backend and the frontend to do Penn Clubs development.
+
You will need to start both the backend and the frontend to develop on Penn Clubs. Clubs supports Mac and Linux/WSL development.
Questions? Check out our [extended guide](https://github.com/pennlabs/penn-clubs/wiki/Development-Guide) for FAQs.
@@ -84,3 +86,13 @@ Use `$ yarn test` to run Cypress tests.
### Development
Click `Login` to log in as a test user. The `./manage.py populate` command creates a test user for you with username `bfranklin` and password `test`. Go to `/api/admin` to login to this account.
+
+#### Ticketing
+
+To test ticketing locally, you will need to [install](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation) `mkcert`, enter the `frontend` directory, and run the following commands:
+
+- `$ mkcert -install`
+- `$ mkcert localhost 127.0.0.1 ::1`
+- `$ export DOMAIN=https://localhost:3001 NODE_TLS_REJECT_UNAUTHORIZED=0`
+
+Then, after the frontend is running, run `yarn ssl-proxy` **in a new terminal window** and access the application at [https://localhost:3001](https://localhost:3001).
\ No newline at end of file
diff --git a/backend/Pipfile b/backend/Pipfile
index c97c74300..5031e8a53 100644
--- a/backend/Pipfile
+++ b/backend/Pipfile
@@ -36,7 +36,7 @@ django-runtime-options = "*"
social-auth-app-django = "*"
django-redis = "*"
channels-redis = "*"
-uwsgi = {version = "*", sys_platform = "== 'linux'"}
+uwsgi = {version ="==2.0.24", sys_platform = "== 'linux'"}
uvloop = {version = "*", sys_platform = "== 'linux'"}
uvicorn = {extras = ["standard"], version = "*"}
gunicorn = "*"
@@ -54,6 +54,9 @@ pandas = "*"
drf-excel = "*"
numpy = "*"
inflection = "*"
+cybersource-rest-client-python = "*"
+pyjwt = "*"
+freezegun = "*"
[requires]
python_version = "3.11"
diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock
index cdecb8eda..e319b9e1c 100644
--- a/backend/Pipfile.lock
+++ b/backend/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "4057df7b74f8c1e641c8f0fad1954492c21eac3651768db019f8d174749a018e"
+ "sha256": "af92de209d2c1cbc993a93920faad16210c881c09fb904ed2495b65a42bf9516"
},
"pipfile-spec": 6,
"requires": {
@@ -321,41 +321,39 @@
},
"cryptography": {
"hashes": [
- "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee",
- "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576",
- "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d",
- "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30",
- "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413",
- "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb",
- "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da",
- "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4",
- "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd",
- "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc",
- "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8",
- "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1",
- "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc",
- "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e",
- "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8",
- "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940",
- "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400",
- "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7",
- "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16",
- "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278",
- "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74",
- "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec",
- "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1",
- "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2",
- "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c",
- "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922",
- "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a",
- "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6",
- "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1",
- "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e",
- "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac",
- "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"
+ "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960",
+ "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a",
+ "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc",
+ "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a",
+ "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf",
+ "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1",
+ "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39",
+ "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406",
+ "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a",
+ "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a",
+ "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c",
+ "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be",
+ "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15",
+ "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2",
+ "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d",
+ "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157",
+ "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003",
+ "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248",
+ "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a",
+ "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec",
+ "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309",
+ "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7",
+ "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"
],
"markers": "python_version >= '3.7'",
- "version": "==42.0.5"
+ "version": "==41.0.7"
+ },
+ "cybersource-rest-client-python": {
+ "hashes": [
+ "sha256:f1843572ddf5da67a0b70a29f0b6bc936e6bcdb8f1939912f0ea3b98b82d74f1"
+ ],
+ "index": "pypi",
+ "version": "==0.0.53"
},
"daphne": {
"hashes": [
@@ -365,6 +363,14 @@
"markers": "python_version >= '3.8'",
"version": "==4.1.2"
},
+ "datetime": {
+ "hashes": [
+ "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120",
+ "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==5.5"
+ },
"defusedxml": {
"hashes": [
"sha256:138c7d540a78775182206c7c97fe65b246a2f40b29471e1a2f1b0da76e7a3942",
@@ -511,6 +517,15 @@
"markers": "python_version >= '3.8'",
"version": "==3.13.4"
},
+ "freezegun": {
+ "hashes": [
+ "sha256:10939b0ba0ff5adaecf3b06a5c2f73071d9678e507c5eaedb23c761d56ac774b",
+ "sha256:55e0fc3c84ebf0a96a5aa23ff8b53d70246479e9a68863f1fcac5a3e52f19dd6"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.7'",
+ "version": "==1.4.0"
+ },
"gunicorn": {
"hashes": [
"sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0",
@@ -1196,20 +1211,60 @@
"markers": "python_version >= '3.8'",
"version": "==2.22"
},
+ "pycryptodome": {
+ "hashes": [
+ "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690",
+ "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7",
+ "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4",
+ "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd",
+ "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5",
+ "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc",
+ "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818",
+ "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab",
+ "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d",
+ "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a",
+ "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25",
+ "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091",
+ "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea",
+ "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a",
+ "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c",
+ "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72",
+ "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9",
+ "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6",
+ "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044",
+ "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04",
+ "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c",
+ "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e",
+ "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f",
+ "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b",
+ "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4",
+ "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33",
+ "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f",
+ "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e",
+ "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a",
+ "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2",
+ "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3",
+ "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==3.20.0"
+ },
"pyjwt": {
"hashes": [
"sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de",
"sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"
],
+ "index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==2.8.0"
},
"pyopenssl": {
"hashes": [
- "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad",
- "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f"
+ "sha256:24f0dc5227396b3e831f4c7f602b950a5e9833d292c8e4a2e06b709292806ae2",
+ "sha256:276f931f55a452e7dea69c7173e984eb2a4407ce413c918aa34b55f82f9b8bac"
],
- "version": "==24.1.0"
+ "markers": "python_version >= '3.6'",
+ "version": "==23.2.0"
},
"pypng": {
"hashes": [
@@ -1365,11 +1420,11 @@
},
"setuptools": {
"hashes": [
- "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e",
- "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"
+ "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987",
+ "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"
],
"markers": "python_version >= '3.8'",
- "version": "==69.2.0"
+ "version": "==69.5.1"
},
"six": {
"hashes": [
@@ -1414,11 +1469,11 @@
},
"sqlparse": {
"hashes": [
- "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3",
- "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"
+ "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93",
+ "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"
],
- "markers": "python_version >= '3.5'",
- "version": "==0.4.4"
+ "markers": "python_version >= '3.8'",
+ "version": "==0.5.0"
},
"tatsu": {
"hashes": [
@@ -1519,7 +1574,7 @@
"sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
],
- "markers": "python_version >= '3.6'",
+ "markers": "python_version >= '3.8'",
"version": "==2.2.1"
},
"uvicorn": {
@@ -1749,7 +1804,7 @@
],
"version": "==12.0"
},
- "zope-interface": {
+ "zope.interface": {
"hashes": [
"sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e",
"sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf",
@@ -2074,11 +2129,11 @@
},
"sqlparse": {
"hashes": [
- "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3",
- "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"
+ "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93",
+ "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"
],
- "markers": "python_version >= '3.5'",
- "version": "==0.4.4"
+ "markers": "python_version >= '3.8'",
+ "version": "==0.5.0"
},
"unittest-xml-reporting": {
"hashes": [
diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py
index d594e38c9..0535fc7b3 100644
--- a/backend/clubs/admin.py
+++ b/backend/clubs/admin.py
@@ -22,6 +22,7 @@
ApplicationSubmission,
Asset,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -50,6 +51,8 @@
TargetStudentType,
TargetYear,
Testimonial,
+ Ticket,
+ TicketTransactionRecord,
Year,
ZoomMeetingVisit,
)
@@ -451,4 +454,7 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin):
admin.site.register(Year, YearAdmin)
admin.site.register(ZoomMeetingVisit, ZoomMeetingVisitAdmin)
admin.site.register(AdminNote)
+admin.site.register(Ticket)
+admin.site.register(TicketTransactionRecord)
+admin.site.register(Cart)
admin.site.register(ApplicationCycle)
diff --git a/backend/clubs/management/commands/populate.py b/backend/clubs/management/commands/populate.py
index 5c8dd77c5..892865ce5 100644
--- a/backend/clubs/management/commands/populate.py
+++ b/backend/clubs/management/commands/populate.py
@@ -15,6 +15,7 @@
ApplicationQuestion,
ApplicationSubmission,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -28,6 +29,7 @@
StudentType,
Tag,
Testimonial,
+ Ticket,
Year,
)
@@ -759,4 +761,35 @@ def get_image(url):
first_mship.save()
count += 1
+ # Add tickets
+
+ hr = Club.objects.get(code="harvard-rejects")
+
+ hr_events = Event.objects.filter(club=hr)
+
+ for idx, e in enumerate(hr_events[:3]):
+ # Switch up person every so often
+ person = ben if idx < 2 else user_objs[1]
+
+ # Create some unowned tickets
+ Ticket.objects.bulk_create(
+ [Ticket(event=e, type="Regular", price=10.10) for _ in range(10)]
+ )
+
+ Ticket.objects.bulk_create(
+ [Ticket(event=e, type="Premium", price=100.10) for _ in range(5)]
+ )
+
+ # Create some owned tickets and tickets in cart
+ for i in range((idx + 1) * 10):
+ if i % 5:
+ Ticket.objects.create(
+ event=e, owner=person, type="Regular", price=i
+ )
+ else:
+ c, _ = Cart.objects.get_or_create(owner=person)
+ c.tickets.add(
+ Ticket.objects.create(event=e, type="Premium", price=i)
+ )
+
self.stdout.write("Finished populating database!")
diff --git a/backend/clubs/migrations/0091_cart_ticket.py b/backend/clubs/migrations/0091_cart_ticket.py
new file mode 100644
index 000000000..7441013da
--- /dev/null
+++ b/backend/clubs/migrations/0091_cart_ticket.py
@@ -0,0 +1,90 @@
+# Generated by Django 3.2.8 on 2022-11-12 20:05
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("clubs", "0090_auto_20230106_1443"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Cart",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "owner",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="cart",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Ticket",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("type", models.CharField(max_length=100)),
+ ("holding_expiration", models.DateTimeField(blank=True, null=True)),
+ (
+ "carts",
+ models.ManyToManyField(
+ blank=True, related_name="tickets", to="clubs.Cart"
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="tickets",
+ to="clubs.event",
+ ),
+ ),
+ (
+ "holder",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="held_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "owner",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="owned_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/backend/clubs/migrations/0092_auto_20221118_1424.py b/backend/clubs/migrations/0092_auto_20221118_1424.py
new file mode 100644
index 000000000..3a799d29d
--- /dev/null
+++ b/backend/clubs/migrations/0092_auto_20221118_1424.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.15 on 2022-11-18 19:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0091_cart_ticket"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="historicalclub",
+ options={
+ "get_latest_by": ("history_date", "history_id"),
+ "ordering": ("-history_date", "-history_id"),
+ "verbose_name": "historical club",
+ "verbose_name_plural": "historical clubs",
+ },
+ ),
+ migrations.AlterField(
+ model_name="historicalclub",
+ name="history_date",
+ field=models.DateTimeField(db_index=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0095_merge_20240128_1321.py b/backend/clubs/migrations/0095_merge_20240128_1321.py
new file mode 100644
index 000000000..7254d57ce
--- /dev/null
+++ b/backend/clubs/migrations/0095_merge_20240128_1321.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.18 on 2024-01-28 18:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0092_auto_20221118_1424"),
+ ("clubs", "0094_applicationcycle_release_date"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0096_merge_20240304_1450.py b/backend/clubs/migrations/0096_merge_20240304_1450.py
new file mode 100644
index 000000000..f3b45a75f
--- /dev/null
+++ b/backend/clubs/migrations/0096_merge_20240304_1450.py
@@ -0,0 +1,14 @@
+# Generated by Django 5.0.3 on 2024-03-04 19:50
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0095_merge_20240128_1321"),
+ ("clubs", "0095_rm_field_add_count"),
+ ]
+
+ operations = [
+ ]
diff --git a/backend/clubs/migrations/0097_ticket_price.py b/backend/clubs/migrations/0097_ticket_price.py
new file mode 100644
index 000000000..5c6c5fa97
--- /dev/null
+++ b/backend/clubs/migrations/0097_ticket_price.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.3 on 2024-04-03 04:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0096_merge_20240304_1450"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="price",
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=5),
+ preserve_default=False,
+ ),
+ ]
diff --git a/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py
new file mode 100644
index 000000000..4e6ea6e7e
--- /dev/null
+++ b/backend/clubs/migrations/0098_tickettransactionrecord_ticket_transaction_record.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.0.3 on 2024-04-14 20:25
+
+import django.db.models.deletion
+import phonenumber_field.modelfields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0097_ticket_price"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TicketTransactionRecord",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "reconciliation_id",
+ models.CharField(blank=True, max_length=100, null=True),
+ ),
+ ("total_amount", models.DecimalField(decimal_places=2, max_digits=5)),
+ (
+ "buyer_phone",
+ phonenumber_field.modelfields.PhoneNumberField(
+ blank=True, max_length=128, null=True, region=None
+ ),
+ ),
+ ("buyer_first_name", models.CharField(max_length=100)),
+ ("buyer_last_name", models.CharField(max_length=100)),
+ (
+ "buyer_email",
+ models.EmailField(blank=True, max_length=254, null=True),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="ticket",
+ name="transaction_record",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="tickets",
+ to="clubs.tickettransactionrecord",
+ ),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0100_merge_20240412_2206.py b/backend/clubs/migrations/0100_merge_20240412_2206.py
new file mode 100644
index 000000000..090b09a4a
--- /dev/null
+++ b/backend/clubs/migrations/0100_merge_20240412_2206.py
@@ -0,0 +1,13 @@
+# Generated by Django 5.0.4 on 2024-04-13 02:06
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0096_merge_20240304_1450"),
+ ("clubs", "0099_remove_club_elo_remove_historicalclub_elo_and_more"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0101_merge_20240414_1708.py b/backend/clubs/migrations/0101_merge_20240414_1708.py
new file mode 100644
index 000000000..b694a9b16
--- /dev/null
+++ b/backend/clubs/migrations/0101_merge_20240414_1708.py
@@ -0,0 +1,12 @@
+# Generated by Django 5.0.3 on 2024-04-14 21:08
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0098_tickettransactionrecord_ticket_transaction_record"),
+ ("clubs", "0100_merge_20240412_2206"),
+ ]
+
+ operations = []
diff --git a/backend/clubs/migrations/0102_event_ticket_order_limit.py b/backend/clubs/migrations/0102_event_ticket_order_limit.py
new file mode 100644
index 000000000..79515fad4
--- /dev/null
+++ b/backend/clubs/migrations/0102_event_ticket_order_limit.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-14 22:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0101_merge_20240414_1708"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="event",
+ name="ticket_order_limit",
+ field=models.IntegerField(default=10),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py
new file mode 100644
index 000000000..a22e2a541
--- /dev/null
+++ b/backend/clubs/migrations/0103_ticket_group_discount_ticket_group_size.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.4 on 2024-04-16 16:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0102_event_ticket_order_limit"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="group_discount",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ max_digits=3,
+ default=0,
+ ),
+ ),
+ migrations.AddField(
+ model_name="ticket",
+ name="group_size",
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0104_cart_checkout_context.py b/backend/clubs/migrations/0104_cart_checkout_context.py
new file mode 100644
index 000000000..3f7c3f220
--- /dev/null
+++ b/backend/clubs/migrations/0104_cart_checkout_context.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-21 00:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0103_ticket_group_discount_ticket_group_size"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="cart",
+ name="checkout_context",
+ field=models.CharField(blank=True, max_length=8297, null=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0105_event_ticket_drop_time.py b/backend/clubs/migrations/0105_event_ticket_drop_time.py
new file mode 100644
index 000000000..81ac89a68
--- /dev/null
+++ b/backend/clubs/migrations/0105_event_ticket_drop_time.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-21 04:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0104_cart_checkout_context"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="event",
+ name="ticket_drop_time",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py b/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py
new file mode 100644
index 000000000..f37777a9e
--- /dev/null
+++ b/backend/clubs/migrations/0106_remove_ticket_transaction_record_ticket_transferable_and_more.py
@@ -0,0 +1,78 @@
+# Generated by Django 5.0.4 on 2024-04-21 21:38
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0105_event_ticket_drop_time"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="ticket",
+ name="transaction_record",
+ ),
+ migrations.AddField(
+ model_name="ticket",
+ name="transferable",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name="tickettransactionrecord",
+ name="ticket",
+ field=models.ForeignKey(
+ default="",
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="transaction_records",
+ to="clubs.ticket",
+ ),
+ preserve_default=False,
+ ),
+ migrations.CreateModel(
+ name="TicketTransferRecord",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "receiver",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="received_transfers",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "sender",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="sent_transfers",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "ticket",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="transfer_records",
+ to="clubs.ticket",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/backend/clubs/migrations/0107_ticket_attended.py b/backend/clubs/migrations/0107_ticket_attended.py
new file mode 100644
index 000000000..16b0d87ce
--- /dev/null
+++ b/backend/clubs/migrations/0107_ticket_attended.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0.4 on 2024-04-25 07:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("clubs", "0106_remove_ticket_transaction_record_ticket_transferable_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="attended",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/backend/clubs/migrations/0108_ticket_buyable.py b/backend/clubs/migrations/0108_ticket_buyable.py
new file mode 100644
index 000000000..514dedc89
--- /dev/null
+++ b/backend/clubs/migrations/0108_ticket_buyable.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-04-28 17:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("clubs", "0107_ticket_attended"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ticket",
+ name="buyable",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/backend/clubs/models.py b/backend/clubs/models.py
index b59439629..238984a2d 100644
--- a/backend/clubs/models.py
+++ b/backend/clubs/models.py
@@ -1,11 +1,14 @@
+import base64
import datetime
import os
import re
import uuid
import warnings
+from io import BytesIO
from urllib.parse import urlparse
import pytz
+import qrcode
import requests
import yaml
from django.conf import settings
@@ -325,7 +328,6 @@ class Club(models.Model):
# cache club aggregation counts
favorite_count = models.IntegerField(default=0)
membership_count = models.IntegerField(default=0)
-
# cache club rankings
rank = models.IntegerField(default=0)
@@ -922,6 +924,8 @@ class Event(models.Model):
parent_recurring_event = models.ForeignKey(
RecurringEvent, on_delete=models.CASCADE, blank=True, null=True
)
+ ticket_order_limit = models.IntegerField(default=10)
+ ticket_drop_time = models.DateTimeField(null=True, blank=True)
OTHER = 0
RECRUITMENT = 1
@@ -1774,6 +1778,174 @@ class Meta:
unique_together = (("question", "submission"),)
+class Cart(models.Model):
+ """
+ Represents an instance of a ticket cart for a user
+ """
+
+ owner = models.OneToOneField(
+ get_user_model(), related_name="cart", on_delete=models.CASCADE
+ )
+ # Capture context from Cybersource should be 8297 chars
+ checkout_context = models.CharField(max_length=8297, blank=True, null=True)
+
+
+class TicketManager(models.Manager):
+ # Update holds for all tickets
+ def update_holds(self):
+ expired_tickets = self.select_for_update().filter(
+ holder__isnull=False, holding_expiration__lte=timezone.now()
+ )
+
+ if not expired_tickets:
+ return
+
+ with transaction.atomic():
+ for ticket in expired_tickets:
+ ticket.holder = None
+ self.bulk_update(expired_tickets, ["holder"])
+
+
+class Ticket(models.Model):
+ """
+ Represents an instance of a ticket for an event
+ """
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ event = models.ForeignKey(
+ Event, related_name="tickets", on_delete=models.DO_NOTHING
+ )
+ type = models.CharField(max_length=100)
+ owner = models.ForeignKey(
+ get_user_model(),
+ related_name="owned_tickets",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ holder = models.ForeignKey(
+ get_user_model(),
+ related_name="held_tickets",
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ holding_expiration = models.DateTimeField(null=True, blank=True)
+ carts = models.ManyToManyField(Cart, related_name="tickets", blank=True)
+ price = models.DecimalField(max_digits=5, decimal_places=2)
+ group_discount = models.DecimalField(
+ max_digits=3,
+ decimal_places=2,
+ default=0,
+ blank=True,
+ )
+ group_size = models.PositiveIntegerField(null=True, blank=True)
+ transferable = models.BooleanField(default=True)
+ attended = models.BooleanField(default=False)
+ # TODO: change to enum between All, Club, None
+ buyable = models.BooleanField(default=True)
+ objects = TicketManager()
+
+ def get_qr(self):
+ """
+ Return a QR code image linking to the ticket page
+ """
+ if not self.owner:
+ return None
+
+ url = f"https://{settings.DOMAINS[0]}/api/tickets/{self.id}"
+ qr_image = qrcode.make(url, box_size=20, border=0)
+ return qr_image
+
+ def send_confirmation_email(self):
+ """
+ Send a confirmation email to the ticket owner after purchase
+ """
+ owner = self.owner
+
+ output = BytesIO()
+ qr_image = self.get_qr()
+ qr_image.save(output, format="PNG")
+ decoded_image = base64.b64encode(output.getvalue()).decode("ascii")
+
+ context = {
+ "first_name": self.owner.first_name,
+ "name": self.event.name,
+ "type": self.type,
+ "start_time": self.event.start_time,
+ "end_time": self.event.end_time,
+ "qr": decoded_image,
+ }
+
+ if self.owner.email:
+ send_mail_helper(
+ name="ticket_confirmation",
+ subject=f"Ticket confirmation for {owner.get_full_name()}",
+ emails=[owner.email],
+ context=context,
+ )
+
+
+class TicketTransactionRecord(models.Model):
+ """
+ Represents an instance of a transaction record for an ticket, used for bookkeeping
+ """
+
+ ticket = models.ForeignKey(
+ Ticket, related_name="transaction_records", on_delete=models.PROTECT
+ )
+ reconciliation_id = models.CharField(max_length=100, null=True, blank=True)
+ total_amount = models.DecimalField(max_digits=5, decimal_places=2)
+ buyer_phone = PhoneNumberField(null=True, blank=True)
+ buyer_first_name = models.CharField(max_length=100)
+ buyer_last_name = models.CharField(max_length=100)
+ buyer_email = models.EmailField(blank=True, null=True)
+
+
+class TicketTransferRecord(models.Model):
+ """
+ Represents a transfer of ticket ownership, used for bookkeeping
+ """
+
+ ticket = models.ForeignKey(
+ Ticket, related_name="transfer_records", on_delete=models.PROTECT
+ )
+ sender = models.ForeignKey(
+ get_user_model(),
+ related_name="sent_transfers",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ )
+ receiver = models.ForeignKey(
+ get_user_model(),
+ related_name="received_transfers",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def send_confirmation_email(self):
+ """
+ Send confirmation email to the sender and recipient of the transfer.
+ """
+ context = {
+ "sender_first_name": self.sender.first_name,
+ "receiver_first_name": self.receiver.first_name,
+ "receiver_username": self.receiver.username,
+ "event_name": self.ticket.event.name,
+ "type": self.ticket.event.type,
+ }
+
+ send_mail_helper(
+ name="ticket_transfer",
+ subject=f"Ticket transfer confirmation for {self.ticket.event.name}",
+ emails=[self.sender.email, self.receiver.email],
+ context=context,
+ )
+
+
@receiver(models.signals.pre_delete, sender=Asset)
def asset_delete_cleanup(sender, instance, **kwargs):
if instance.file:
diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py
index 266d3686e..daf103333 100644
--- a/backend/clubs/permissions.py
+++ b/backend/clubs/permissions.py
@@ -187,7 +187,7 @@ def has_permission(self, request, view):
class EventPermission(permissions.BasePermission):
"""
- Officers and above can create/update/delete events.
+ Officers and above can create/update/delete events and view ticket buyers.
Everyone else can view and list events.
"""
@@ -223,7 +223,13 @@ def has_object_permission(self, request, view, obj):
if not old_type == FAIR_TYPE and new_type == FAIR_TYPE:
return False
-
+ elif view.action in ["buyers", "create_tickets", "issue_tickets"]:
+ if not request.user.is_authenticated:
+ return False
+ membership = find_membership_helper(request.user, obj.club)
+ return membership is not None and membership.role <= Membership.ROLE_OFFICER
+ elif view.action in ["add_to_cart", "remove_from_cart"]:
+ return request.user.is_authenticated
return True
diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py
index 4a1b7be12..496b7566b 100644
--- a/backend/clubs/serializers.py
+++ b/backend/clubs/serializers.py
@@ -55,6 +55,7 @@
TargetStudentType,
TargetYear,
Testimonial,
+ Ticket,
Year,
)
from clubs.utils import clean
@@ -362,8 +363,12 @@ class ClubEventSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField("get_image_url")
large_image_url = serializers.SerializerMethodField("get_large_image_url")
url = serializers.SerializerMethodField("get_event_url")
+ ticketed = serializers.SerializerMethodField("get_ticketed")
creator = serializers.HiddenField(default=serializers.CurrentUserDefault())
+ def get_ticketed(self, obj) -> bool:
+ return obj.tickets.count() > 0
+
def get_event_url(self, obj):
# if no url, return that
if not obj.url:
@@ -501,6 +506,7 @@ class Meta:
"location",
"name",
"start_time",
+ "ticketed",
"type",
"url",
]
@@ -1745,6 +1751,22 @@ class Meta:
fields = ("club", "role", "title", "active", "public")
+class TicketSerializer(serializers.ModelSerializer):
+ """
+ Used to return a ticket object
+ """
+
+ owner = serializers.SerializerMethodField("get_owner_name")
+ event = EventSerializer()
+
+ def get_owner_name(self, obj):
+ return obj.owner.get_full_name() if obj.owner else "None"
+
+ class Meta:
+ model = Ticket
+ fields = ("id", "event", "type", "owner", "price")
+
+
class UserUUIDSerializer(serializers.ModelSerializer):
"""
Used to get the uuid of a user (for ICS Calendar export)
@@ -2809,6 +2831,7 @@ def save(self):
"You cannot edit committees once the application is open"
)
# nasty hack for idempotency
+ prev_committee_names = prev_committees.values("name")
for prev_committee in prev_committees:
if prev_committee.name not in committees:
prev_committee.delete()
diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py
index 69d517551..4395aa838 100644
--- a/backend/clubs/urls.py
+++ b/backend/clubs/urls.py
@@ -43,6 +43,7 @@
SubscribeViewSet,
TagViewSet,
TestimonialViewSet,
+ TicketViewSet,
UserGroupAPIView,
UserPermissionAPIView,
UserUpdateAPIView,
@@ -69,6 +70,7 @@
router.register(r"searches", SearchQueryViewSet, basename="searches")
router.register(r"memberships", MembershipViewSet, basename="members")
router.register(r"requests", MembershipRequestViewSet, basename="requests")
+router.register(r"tickets", TicketViewSet, basename="tickets")
router.register(r"schools", SchoolViewSet, basename="schools")
router.register(r"majors", MajorViewSet, basename="majors")
diff --git a/backend/clubs/views.py b/backend/clubs/views.py
index 6ded99725..03e6152bd 100644
--- a/backend/clubs/views.py
+++ b/backend/clubs/views.py
@@ -1,4 +1,5 @@
import argparse
+import base64
import collections
import datetime
import functools
@@ -8,14 +9,23 @@
import re
import secrets
import string
+from functools import wraps
+from typing import Tuple
from urllib.parse import urlparse
+import jwt
import pandas as pd
import pytz
import qrcode
import requests
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
+from CyberSource import (
+ PaymentsApi,
+ TransientTokenDataApi,
+ UnifiedCheckoutCaptureContextApi,
+)
+from CyberSource.rest import ApiException
from dateutil.parser import parse
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -28,6 +38,7 @@
from django.core.management import call_command, get_commands, load_command_class
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import validate_email
+from django.db import transaction
from django.db.models import (
Case,
CharField,
@@ -35,6 +46,7 @@
DurationField,
ExpressionWrapper,
F,
+ Max,
Prefetch,
Q,
TextField,
@@ -80,6 +92,7 @@
ApplicationSubmission,
Asset,
Badge,
+ Cart,
Club,
ClubApplication,
ClubFair,
@@ -102,6 +115,9 @@
Subscribe,
Tag,
Testimonial,
+ Ticket,
+ TicketTransactionRecord,
+ TicketTransferRecord,
Year,
ZoomMeetingVisit,
get_mail_type_annotation,
@@ -172,6 +188,7 @@
SubscribeSerializer,
TagSerializer,
TestimonialSerializer,
+ TicketSerializer,
UserClubVisitSerializer,
UserClubVisitWriteSerializer,
UserMembershipInviteSerializer,
@@ -190,6 +207,19 @@
from clubs.utils import fuzzy_lookup_club, html_to_text
+def update_holds(func):
+ """
+ Decorator to update ticket holds
+ """
+
+ @wraps(func)
+ def wrap(self, request, *args, **kwargs):
+ Ticket.objects.update_holds()
+ return func(self, request, *args, **kwargs)
+
+ return wrap
+
+
def file_upload_endpoint_helper(request, code):
obj = get_object_or_404(Club, code=code)
if "file" in request.data and isinstance(request.data["file"], UploadedFile):
@@ -303,6 +333,24 @@ def hour_to_string_helper(hour):
return hour_string
+def validate_transient_token(cc: str, tt: str) -> Tuple[bool, str]:
+ """Validate the integrity of the transient token using
+ the public key (JWK) obtained from the capture context"""
+
+ try:
+ _, body, _ = cc.split(".")
+ decoded_body = json.loads(base64.b64decode(body + "==="))
+ jwk = decoded_body["flx"]["jwk"]
+
+ public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
+ # This will throw if the key is invalid
+ jwt.decode(tt, key=public_key, algorithms=["RS256"])
+ return (True, "Successfully decoded the JWT")
+
+ except Exception as e:
+ return (False, str(e))
+
+
class ReportViewSet(viewsets.ModelViewSet):
"""
retrieve:
@@ -1576,7 +1624,6 @@ def booths(self, request, *args, **kwargs):
"""
club = self.get_object()
res = ClubFairBooth.objects.filter(club=club).select_related("club").all()
-
return Response(ClubBoothSerializer(res, many=True).data)
def get_operation_id(self, **kwargs):
@@ -2264,6 +2311,19 @@ class ClubEventViewSet(viewsets.ModelViewSet):
destroy:
Delete an event.
+
+ tickets:
+ Get or create tickets for particular event
+
+ buyers:
+ Get information about the buyers of an event's ticket
+
+ remove_from_cart:
+ Remove a ticket for this event from cart
+
+ add_to_cart:
+ Add a ticket for this event to cart
+
"""
permission_classes = [EventPermission | IsSuperuser]
@@ -2285,220 +2345,484 @@ def get_serializer_class(self):
return EventSerializer
@action(detail=True, methods=["post"])
- def upload(self, request, *args, **kwargs):
+ @transaction.atomic
+ @update_holds
+ def add_to_cart(self, request, *args, **kwargs):
"""
- Upload a picture for the event.
+ Add a certain number of tickets to the cart
---
requestBody:
content:
- multipart/form-data:
+ application/json:
schema:
type: object
properties:
- file:
- type: object
- format: binary
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
responses:
"200":
- description: Returned if the file was successfully uploaded.
- content: &upload_resp
+ content:
application/json:
schema:
- type: object
- properties:
+ type: object
+ properties:
detail:
type: string
- description: The status of the file upload.
- url:
+ success:
+ type: boolean
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
type: string
- description: >
- The URL of the newly uploaded file.
- Only exists if the file was successfully
- uploaded.
- "400":
- description: Returned if there was an error while uploading the file.
- content: *upload_resp
+ success:
+ type: boolean
---
"""
- event = Event.objects.get(id=kwargs["id"])
- self.check_object_permissions(request, event)
+ event = self.get_object()
+ cart, _ = Cart.objects.get_or_create(owner=self.request.user)
- resp = upload_endpoint_helper(request, Event, "image", "image", pk=event.pk)
+ # Cannot add tickets that haven't dropped yet
+ if event.ticket_drop_time and timezone.now() < event.ticket_drop_time:
+ return Response(
+ {"detail": "Ticket drop time has not yet elapsed"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
- # if image uploaded, create thumbnail
- if status.is_success(resp.status_code):
- event.create_thumbnail(request)
+ quantities = request.data.get("quantities")
+ if not quantities:
+ return Response(
+ {"detail": "Quantities must be specified", "success": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- return resp
+ num_requested = sum(item["count"] for item in quantities)
+ num_carted = cart.tickets.filter(event=event).count()
- def create(self, request, *args, **kwargs):
+ if num_requested + num_carted > event.ticket_order_limit:
+ return Response(
+ {
+ "detail": f"Order exceeds the maximum ticket limit of "
+ f"{event.ticket_order_limit}.",
+ "success": False,
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ for item in quantities:
+ type = item["type"]
+ count = item["count"]
+
+ # Count unowned/unheld tickets of requested type
+ # We don't need a lock since we aren't changing the holder or owner
+ tickets = (
+ Ticket.objects.filter(
+ event=event,
+ type=type,
+ owner__isnull=True,
+ holder__isnull=True,
+ buyable=True,
+ )
+ .prefetch_related("carts")
+ .exclude(carts__owner=self.request.user)
+ )
+
+ if tickets.count() < count:
+ return Response(
+ {"detail": f"Not enough tickets of type {type} left!"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+ cart.tickets.add(*tickets[:count])
+
+ cart.save()
+ return Response(
+ {"detail": f"Successfully added {count} to cart", "success": True}
+ )
+
+ @action(detail=True, methods=["post"])
+ @transaction.atomic
+ @update_holds
+ def remove_from_cart(self, request, *args, **kwargs):
"""
- Has the option to create a recurring event by specifying an offset and an
- end date. Additionaly, do not let non-superusers create events with the
- `FAIR` type through the API.
+ Remove a certain type/number of tickets from the cart
---
requestBody:
content:
application/json:
schema:
- allOf:
- - $ref: "#/components/schemas/EventWrite"
- - type: object
- properties:
- is_recurring:
- type: boolean
- description: >
- If this value is set, then make
- recurring events instead of a single event.
- offset:
- type: number
- description: >
- The offset between recurring events, in days.
- Only specify this if the event is recurring.
- end_date:
+ type: object
+ properties:
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
type: string
- format: date-time
- description: >
- The date when all items in the recurring event
- series should end. Only specify this if the
- event is recurring.
-
+ success:
+ type: boolean
---
"""
- # get event type
- type = request.data.get("type", 0)
- if type == Event.FAIR and not self.request.user.is_superuser:
- raise DRFValidationError(
- detail="Approved activities fair events have already been created. "
- "See above for events to edit, and "
- f"please email {settings.FROM_EMAIL} if this is en error."
+ event = self.get_object()
+ quantities = request.data.get("quantities")
+ if not quantities:
+ return Response(
+ {
+ "detail": "Quantities must be specified",
+ "success": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ if not all(isinstance(item, dict) for item in quantities):
+ return Response(
+ {
+ "detail": "Quantities must be a list of dictionaries",
+ "success": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
)
+ cart = get_object_or_404(Cart, owner=self.request.user)
- # handle recurring events
- if request.data.get("is_recurring", None) is not None:
- parent_recurring_event = RecurringEvent.objects.create()
- event_data = request.data.copy()
- start_time = parse(event_data.pop("start_time"))
- end_time = parse(event_data.pop("end_time"))
- offset = event_data.pop("offset")
- end_date = parse(event_data.pop("end_date"))
- event_data.pop("is_recurring")
+ for item in quantities:
+ type = item["type"]
+ count = item["count"]
+ tickets_to_remove = cart.tickets.filter(type=type, event=event)
- result_data = []
- while start_time < end_date:
- event_data["start_time"] = start_time
- event_data["end_time"] = end_time
- event_serializer = EventWriteSerializer(
- data=event_data, context={"request": request, "view": self}
- )
- if event_serializer.is_valid():
- ev = event_serializer.save()
- ev.parent_recurring_event = parent_recurring_event
- result_data.append(ev)
- else:
- return Response(
- event_serializer.errors, status=status.HTTP_400_BAD_REQUEST
- )
+ # Ensure we don't try to remove more tickets than we can
+ count = min(count, tickets_to_remove.count())
+ cart.tickets.remove(*tickets_to_remove[:count])
- start_time = start_time + datetime.timedelta(days=offset)
- end_time = end_time + datetime.timedelta(days=offset)
+ cart.save()
+ return Response(
+ {"detail": f"Successfully removed {count} from cart", "success": True}
+ )
- Event.objects.filter(pk__in=[e.pk for e in result_data]).update(
- parent_recurring_event=parent_recurring_event
- )
+ @action(detail=True, methods=["get"])
+ def buyers(self, request, *args, **kwargs):
+ """
+ Get information about ticket buyers
+ ---
+ requestBody: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ buyers:
+ type: array
+ items:
+ type: object
+ properties:
+ fullname:
+ type: string
+ id:
+ type: string
+ owner_id:
+ type: integer
+ type:
+ type: string
+ attended:
+ type: boolean
+ ---
+ """
+ tickets = Ticket.objects.filter(event=self.get_object()).annotate(
+ fullname=Concat("owner__first_name", Value(" "), "owner__last_name")
+ )
- return Response(EventSerializer(result_data, many=True).data)
+ buyers = tickets.filter(owner__isnull=False).values(
+ "fullname", "id", "owner_id", "type", "attended", "owner__email"
+ )
- return super().create(request, *args, **kwargs)
+ return Response({"buyers": buyers})
- def destroy(self, request, *args, **kwargs):
+ @action(detail=True, methods=["get"])
+ def tickets(self, request, *args, **kwargs):
"""
- Do not let non-superusers delete events with the FAIR type through the API.
+ Get information about tickets for particular event
+ ---
+ requestBody: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ totals:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ available:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ ---
"""
event = self.get_object()
+ tickets = Ticket.objects.filter(event=event)
- if event.type == Event.FAIR and not self.request.user.is_superuser:
- raise DRFValidationError(
- detail="You cannot delete activities fair events. "
- f"If you would like to do this, email {settings.FROM_EMAIL}."
- )
-
- return super().destroy(request, *args, **kwargs)
+ if event.ticket_drop_time and timezone.now() < event.ticket_drop_time:
+ return Response({"totals": [], "available": []})
- def get_queryset(self):
- qs = Event.objects.all()
- is_club_specific = self.kwargs.get("club_code") is not None
- if is_club_specific:
- qs = qs.filter(club__code=self.kwargs["club_code"])
- qs = qs.filter(
- Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True),
- club__archived=False,
- )
- else:
- qs = qs.filter(
- Q(club__approved=True)
- | Q(type=Event.FAIR)
- | Q(club__ghost=True)
- | Q(club__isnull=True),
- Q(club__isnull=True) | Q(club__archived=False),
- )
+ # Take price of first ticket of given type for now
+ totals = (
+ tickets.values("type")
+ .annotate(price=Max("price"))
+ .annotate(count=Count("type"))
+ .order_by("type")
+ )
+ available = (
+ tickets.filter(owner__isnull=True, holder__isnull=True, buyable=True)
+ .values("type")
+ .annotate(price=Max("price"))
+ .annotate(count=Count("type"))
+ .order_by("type")
+ )
+ return Response({"totals": list(totals), "available": list(available)})
- return (
- qs.select_related("club", "creator")
- .prefetch_related(
- Prefetch(
- "club__badges",
- queryset=(
- Badge.objects.filter(
- fair__id=self.request.query_params.get("fair")
+ @tickets.mapping.put
+ @transaction.atomic
+ def create_tickets(self, request, *args, **kwargs):
+ """
+ Create ticket offerings for event
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ quantities:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ count:
+ type: integer
+ price:
+ type: number
+ group_size:
+ type: number
+ required: false
+ group_discount:
+ type: number
+ format: float
+ required: false
+ transferable:
+ type: boolean
+ buyable:
+ type: boolean
+ required: false
+ order_limit:
+ type: int
+ required: false
+ drop_time:
+ type: string
+ format: date-time
+ required: false
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ "400":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ ---
+ """
+ event = self.get_object()
+
+ # Tickets can't be edited after they've dropped
+ if event.ticket_drop_time and timezone.now() > event.ticket_drop_time:
+ return Response(
+ {"detail": "Tickets cannot be edited after they have dropped"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # Tickets can't be edited after they've been sold or held
+ if (
+ Ticket.objects.filter(event=event)
+ .filter(Q(owner__isnull=False) | Q(holder__isnull=False))
+ .exists()
+ ):
+ return Response(
+ {
+ "detail": "Tickets cannot be edited after they have been "
+ "sold or checked out"
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ quantities = request.data.get("quantities", [])
+ if not quantities:
+ return Response(
+ {"detail": "Quantities must be specified"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ for item in quantities:
+ if not item.get("type") or not item.get("count"):
+ return Response(
+ {"detail": "Specify type and count to create some tickets."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Ticket prices must be non-negative
+ if item.get("price", 0) < 0:
+ return Response(
+ {"detail": "Ticket price cannot be negative"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Group discounts must be between 0 and 1
+ if item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1:
+ return Response(
+ {"detail": "Group discount must be between 0 and 1"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Min group sizes must be greater than 1
+ if item.get("group_size", 2) <= 1:
+ return Response(
+ {"detail": "Min group size must be greater than 1"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Tickets must specify both group_discount and group_size or neither
+ if ("group_discount" in item) != ("group_size" in item):
+ return Response(
+ {
+ "detail": (
+ "Ticket must specify either both group_discount "
+ "and group_size or neither"
)
- if "fair" in self.request.query_params
- else Badge.objects.filter(visible=True)
- ),
+ },
+ status=status.HTTP_400_BAD_REQUEST,
)
+
+ # Atomicity ensures idempotency
+ Ticket.objects.filter(event=event).delete() # Idempotency
+ tickets = [
+ Ticket(
+ event=event,
+ type=item["type"],
+ price=item.get("price", 0),
+ group_discount=item.get("group_discount", 0),
+ group_size=item.get("group_size", None),
+ transferable=item.get("transferable", True),
+ buyable=item.get("buyable", True),
)
- .order_by("start_time")
- )
+ for item in quantities
+ for _ in range(item["count"])
+ ]
+ Ticket.objects.bulk_create(tickets)
-class EventViewSet(ClubEventViewSet):
- """
- list:
- Return a list of events for the entire site.
+ order_limit = request.data.get("order_limit", None)
+ if order_limit is not None:
+ event.ticket_order_limit = order_limit
+ event.save()
- retrieve:
- Return a single event.
+ drop_time = request.data.get("drop_time", None)
+ if drop_time is not None:
+ try:
+ drop_time = datetime.datetime.strptime(drop_time, "%Y-%m-%dT%H:%M:%S%z")
+ except ValueError as e:
+ return Response(
+ {"detail": f"Invalid drop time: {str(e)}"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- destroy:
- Delete an event.
- """
+ if drop_time < timezone.now():
+ return Response(
+ {"detail": "Specified drop time has already elapsed"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- def get_operation_id(self, **kwargs):
- return f"{kwargs['operId']} (Global)"
+ event.ticket_drop_time = drop_time
+ event.save()
- @action(detail=False, methods=["get"])
- def fair(self, request, *args, **kwargs):
+ return Response({"detail": "Successfully created tickets"})
+
+ @action(detail=True, methods=["post"])
+ @transaction.atomic
+ @update_holds
+ def issue_tickets(self, request, *args, **kwargs):
"""
- Get the minimal information required for a fair directory listing.
- Groups by the start date of the event, and then the event category.
- Each event's club must have an associated fair badge in order to be displayed.
+ Issue tickets that have already been created to users in bulk.
---
- parameters:
- - name: date
- in: query
- required: false
- description: >
- A date in YYYY-MM-DD format.
- If specified, will preview how this endpoint looked on the specified
- date.
- type: string
- - name: fair
- in: query
- required: false
- description: >
- A fair id. If specified, will preview how this endpoint will look for
- that fair. Overrides the date field if both are specified.
- type: number
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tickets:
+ type: array
+ items:
+ type: object
+ properties:
+ username:
+ type: string
+ ticket_type:
+ type: string
+
responses:
"200":
content:
@@ -2506,204 +2830,570 @@ def fair(self, request, *args, **kwargs):
schema:
type: object
properties:
- fair:
- type: object
- $ref: "#/components/schemas/ClubFair"
- events:
+ detail:
+ type: string
+ "400":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ errors:
type: array
items:
- type: object
- properties:
- start_time:
- type: string
- format: date-time
- end_time:
- type: string
- format: date-time
- events:
- type: array
- items:
- type: object
- properties:
- category:
- type: string
- events:
- type: array
- items:
- type: object
- properties:
- name:
- type: string
- code:
- type: string
+ type: string
---
"""
- # accept custom date for preview rendering
- date = request.query_params.get("date")
- if date in {"null", "undefined"}:
- date = None
- if date:
- date = parse(date)
+ event = self.get_object()
- # accept custom fair for preview rendering
- fair = request.query_params.get("fair")
- if fair in {"null", "undefined"}:
- fair = None
- if fair:
- fair = int(re.sub(r"\D", "", fair))
+ tickets = request.data.get("tickets", [])
- # cache the response for this endpoint with short timeout
- if date is None:
- key = f"events:fair:directory:{request.user.is_authenticated}:{fair}"
- cached = cache.get(key)
- if cached:
- return Response(cached)
- else:
- key = None
+ if not tickets:
+ return Response(
+ {"detail": "tickets must be specified", "errors": []},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- # lookup fair from id
- if fair:
- fair = get_object_or_404(ClubFair, id=fair)
- else:
- fair = (
- ClubFair.objects.filter(
- end_time__gte=timezone.now() - datetime.timedelta(minutes=30)
+ for item in tickets:
+ if not item.get("username") or not item.get("ticket_type"):
+ return Response(
+ {
+ "detail": "Specify username and ticket type to issue tickets",
+ "errors": [],
+ },
+ status=status.HTTP_400_BAD_REQUEST,
)
- .order_by("start_time")
- .first()
- )
- if not date and fair is not None:
- date = fair.start_time.date()
- now = date or timezone.now()
- events = Event.objects.filter(
- type=Event.FAIR, club__badges__purpose="fair", club__badges__fair=fair
- )
+ usernames = [item.get("username") for item in tickets]
+ ticket_types = [item.get("ticket_type") for item in tickets]
- # filter event range based on the fair times or provide a reasonable fallback
- if fair is None:
- events = events.filter(
- start_time__lte=now + datetime.timedelta(days=7),
- end_time__gte=now - datetime.timedelta(days=1),
+ # Validate all usernames
+ invalid_usernames = set(usernames) - set(
+ get_user_model()
+ .objects.filter(username__in=usernames)
+ .values_list("username", flat=True)
+ )
+ if invalid_usernames:
+ return Response(
+ {
+ "detail": "Invalid usernames",
+ "errors": sorted(list(invalid_usernames)),
+ },
+ status=status.HTTP_400_BAD_REQUEST,
)
- else:
- events = events.filter(
- start_time__lte=fair.end_time, end_time__gte=fair.start_time
+
+ # Validate all ticket types
+ invalid_types = set(ticket_types) - set(
+ Ticket.objects.filter(event=event).values_list("type", flat=True)
+ )
+ if invalid_types:
+ return Response(
+ {
+ "detail": "Invalid ticket classes",
+ "errors": sorted(list(invalid_types)),
+ },
+ status=status.HTTP_400_BAD_REQUEST,
)
- events = events.values_list(
- "start_time", "end_time", "club__name", "club__code", "club__badges__label"
- ).distinct()
- output = {}
- for event in events:
- # group by start date
- ts = int(event[0].replace(second=0, microsecond=0).timestamp())
- if ts not in output:
- output[ts] = {
- "start_time": event[0],
- "end_time": event[1],
- "events": {},
- }
+ tickets = []
+ for ticket_type, num_requested in collections.Counter(ticket_types).items():
+ available_tickets = Ticket.objects.select_for_update(
+ skip_locked=True
+ ).filter(
+ event=event, type=ticket_type, owner__isnull=True, holder__isnull=True
+ )[:num_requested]
- # group by category
- category = event[4]
- if category not in output[ts]["events"]:
- output[ts]["events"][category] = {
- "category": category,
- "events": [],
- }
+ if available_tickets.count() < num_requested:
+ return Response(
+ {
+ "detail": (
+ f"Not enough tickets available for type: {ticket_type}"
+ ),
+ "errors": [],
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- output[ts]["events"][category]["events"].append(
- {"name": event[2], "code": event[3]}
- )
- for item in output.values():
- item["events"] = list(
- sorted(item["events"].values(), key=lambda cat: cat["category"])
+ tickets.extend(available_tickets)
+
+ # Assign tickets to users
+ transaction_records = []
+
+ for username, ticket_type in zip(usernames, ticket_types):
+ user = get_user_model().objects.filter(username=username).first()
+ ticket = next(
+ ticket
+ for ticket in tickets
+ if ticket.type == ticket_type and ticket.owner is None
)
- for category in item["events"]:
- category["events"] = list(
- sorted(category["events"], key=lambda e: e["name"].casefold())
+ ticket.owner = user
+ ticket.holder = None
+
+ transaction_records.append(
+ TicketTransactionRecord(
+ ticket=ticket,
+ total_amount=0.0,
+ buyer_first_name=user.first_name,
+ buyer_last_name=user.last_name,
+ buyer_email=user.email,
)
+ )
- output = list(sorted(output.values(), key=lambda cat: cat["start_time"]))
- final_output = {
- "events": output,
- "fair": ClubFairSerializer(instance=fair).data,
- }
- if key:
- cache.set(key, final_output, 60 * 5)
+ Ticket.objects.bulk_update(tickets, ["owner", "holder"])
+ Ticket.objects.update_holds()
- return Response(final_output)
+ TicketTransactionRecord.objects.bulk_create(transaction_records)
- @action(detail=False, methods=["get"])
- def owned(self, request, *args, **kwargs):
+ for ticket in tickets:
+ ticket.send_confirmation_email()
+
+ return Response(
+ {"success": True, "detail": f"Issued {len(tickets)} tickets", "errors": []}
+ )
+
+ @action(detail=True, methods=["post"])
+ def upload(self, request, *args, **kwargs):
"""
- Return all events that the user has officer permissions over.
+ Upload a picture for the event.
+ ---
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ file:
+ type: object
+ format: binary
+ responses:
+ "200":
+ description: Returned if the file was successfully uploaded.
+ content: &upload_resp
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ description: The status of the file upload.
+ url:
+ type: string
+ description: >
+ The URL of the newly uploaded file.
+ Only exists if the file was successfully
+ uploaded.
+ "400":
+ description: Returned if there was an error while uploading the file.
+ content: *upload_resp
+ ---
"""
- if not request.user.is_authenticated:
- return Response([])
+ event = Event.objects.get(id=kwargs["id"])
+ self.check_object_permissions(request, event)
- now = timezone.now()
+ resp = upload_endpoint_helper(request, Event, "image", "image", pk=event.pk)
- events = self.filter_queryset(self.get_queryset()).filter(
- club__membership__person=request.user,
- club__membership__role__lte=Membership.ROLE_OFFICER,
- start_time__gte=now,
- )
+ # if image uploaded, create thumbnail
+ if status.is_success(resp.status_code):
+ event.create_thumbnail(request)
- return Response(EventSerializer(events, many=True).data)
+ return resp
+ def create(self, request, *args, **kwargs):
+ """
+ Has the option to create a recurring event by specifying an offset and an
+ end date. Additionaly, do not let non-superusers create events with the
+ `FAIR` type through the API.
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: "#/components/schemas/EventWrite"
+ - type: object
+ properties:
+ is_recurring:
+ type: boolean
+ description: >
+ If this value is set, then make
+ recurring events instead of a single event.
+ offset:
+ type: number
+ description: >
+ The offset between recurring events, in days.
+ Only specify this if the event is recurring.
+ end_date:
+ type: string
+ format: date-time
+ description: >
+ The date when all items in the recurring event
+ series should end. Only specify this if the
+ event is recurring.
-class TestimonialViewSet(viewsets.ModelViewSet):
- """
- list:
- Return a list of testimonials for this club.
+ ---
+ """
+ # get event type
+ type = request.data.get("type", 0)
+ if type == Event.FAIR and not self.request.user.is_superuser:
+ raise DRFValidationError(
+ detail="Approved activities fair events have already been created. "
+ "See above for events to edit, and "
+ f"please email {settings.FROM_EMAIL} if this is en error."
+ )
- create:
- Create a new testimonial for this club.
+ # handle recurring events
+ if request.data.get("is_recurring", None) is not None:
+ parent_recurring_event = RecurringEvent.objects.create()
+ event_data = request.data.copy()
+ start_time = parse(event_data.pop("start_time"))
+ end_time = parse(event_data.pop("end_time"))
+ offset = event_data.pop("offset")
+ end_date = parse(event_data.pop("end_date"))
+ event_data.pop("is_recurring")
- update:
- Update a testimonial for this club.
- All fields must be specified.
+ result_data = []
+ while start_time < end_date:
+ event_data["start_time"] = start_time
+ event_data["end_time"] = end_time
+ event_serializer = EventWriteSerializer(
+ data=event_data, context={"request": request, "view": self}
+ )
+ if event_serializer.is_valid():
+ ev = event_serializer.save()
+ ev.parent_recurring_event = parent_recurring_event
+ result_data.append(ev)
+ else:
+ return Response(
+ event_serializer.errors, status=status.HTTP_400_BAD_REQUEST
+ )
- partial_update:
- Update a testimonial for this club.
- Specify only the fields you want to update.
+ start_time = start_time + datetime.timedelta(days=offset)
+ end_time = end_time + datetime.timedelta(days=offset)
- retrieve:
- Retrieve a single testimonial.
+ Event.objects.filter(pk__in=[e.pk for e in result_data]).update(
+ parent_recurring_event=parent_recurring_event
+ )
- destroy:
- Delete a testimonial.
- """
+ return Response(EventSerializer(result_data, many=True).data)
- serializer_class = TestimonialSerializer
- permission_classes = [ClubItemPermission | IsSuperuser]
+ return super().create(request, *args, **kwargs)
+
+ @update_holds
+ def destroy(self, request, *args, **kwargs):
+ """
+ Do not let non-superusers delete events with the FAIR type through the API.
+ Check if there are bought or held tickets before deletion.
+ """
+ event = self.get_object()
+
+ if event.type == Event.FAIR and not self.request.user.is_superuser:
+ raise DRFValidationError(
+ detail="You cannot delete activities fair events. "
+ f"If you would like to do this, email {settings.FROM_EMAIL}."
+ )
+
+ if (
+ Ticket.objects.filter(event=event)
+ .filter(Q(owner__isnull=False) | Q(holder__isnull=False))
+ .exists()
+ ):
+ raise DRFValidationError(
+ detail=(
+ "This event cannot be deleted because there are tickets "
+ "that have been bought or are being checked out."
+ )
+ )
+
+ return super().destroy(request, *args, **kwargs)
def get_queryset(self):
- return Testimonial.objects.filter(club__code=self.kwargs["club_code"])
+ qs = Event.objects.all()
+ is_club_specific = self.kwargs.get("club_code") is not None
+ if is_club_specific:
+ qs = qs.filter(club__code=self.kwargs["club_code"])
+ qs = qs.filter(
+ Q(club__approved=True) | Q(type=Event.FAIR) | Q(club__ghost=True),
+ club__archived=False,
+ )
+ else:
+ qs = qs.filter(
+ Q(club__approved=True)
+ | Q(type=Event.FAIR)
+ | Q(club__ghost=True)
+ | Q(club__isnull=True),
+ Q(club__isnull=True) | Q(club__archived=False),
+ )
+
+ return (
+ qs.select_related("club", "creator")
+ .prefetch_related(
+ Prefetch(
+ "club__badges",
+ queryset=(
+ Badge.objects.filter(
+ fair__id=self.request.query_params.get("fair")
+ )
+ if "fair" in self.request.query_params
+ else Badge.objects.filter(visible=True)
+ ),
+ ),
+ "tickets",
+ )
+ .order_by("start_time")
+ )
-class QuestionAnswerViewSet(viewsets.ModelViewSet):
+class EventViewSet(ClubEventViewSet):
"""
list:
- Return a list of questions and answers for this club.
-
- create:
- Create a new question for this club.
-
- update:
- Change the question or the answer for this club.
+ Return a list of events for the entire site.
retrieve:
- Return a single testimonial.
+ Return a single event.
destroy:
- Delete a testimonial.
- """
+ Delete an event.
- serializer_class = QuestionAnswerSerializer
- permission_classes = [QuestionAnswerPermission | IsSuperuser]
+ fair:
+ Get information about a fair listing
+
+ owned:
+ Return all events that the user has officer permissions over.
+ """
+
+ def get_operation_id(self, **kwargs):
+ return f"{kwargs['operId']} (Global)"
+
+ @action(detail=False, methods=["get"])
+ def fair(self, request, *args, **kwargs):
+ """
+ Get the minimal information required for a fair directory listing.
+ Groups by the start date of the event, and then the event category.
+ Each event's club must have an associated fair badge in order to be displayed.
+ ---
+ parameters:
+ - name: date
+ in: query
+ required: false
+ description: >
+ A date in YYYY-MM-DD format.
+ If specified, will preview how this endpoint looked on the specified
+ date.
+ type: string
+ - name: fair
+ in: query
+ required: false
+ description: >
+ A fair id. If specified, will preview how this endpoint will look for
+ that fair. Overrides the date field if both are specified.
+ type: number
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ fair:
+ type: object
+ $ref: "#/components/schemas/ClubFair"
+ events:
+ type: array
+ items:
+ type: object
+ properties:
+ start_time:
+ type: string
+ format: date-time
+ end_time:
+ type: string
+ format: date-time
+ events:
+ type: array
+ items:
+ type: object
+ properties:
+ category:
+ type: string
+ events:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ code:
+ type: string
+ ---
+ """
+ # accept custom date for preview rendering
+ date = request.query_params.get("date")
+ if date in {"null", "undefined"}:
+ date = None
+ if date:
+ date = parse(date)
+
+ # accept custom fair for preview rendering
+ fair = request.query_params.get("fair")
+ if fair in {"null", "undefined"}:
+ fair = None
+ if fair:
+ fair = int(re.sub(r"\D", "", fair))
+
+ # cache the response for this endpoint with short timeout
+ if date is None:
+ key = f"events:fair:directory:{request.user.is_authenticated}:{fair}"
+ cached = cache.get(key)
+ if cached:
+ return Response(cached)
+ else:
+ key = None
+
+ # lookup fair from id
+ if fair:
+ fair = get_object_or_404(ClubFair, id=fair)
+ else:
+ fair = (
+ ClubFair.objects.filter(
+ end_time__gte=timezone.now() - datetime.timedelta(minutes=30)
+ )
+ .order_by("start_time")
+ .first()
+ )
+ if not date and fair is not None:
+ date = fair.start_time.date()
+
+ now = date or timezone.now()
+ events = Event.objects.filter(
+ type=Event.FAIR, club__badges__purpose="fair", club__badges__fair=fair
+ )
+
+ # filter event range based on the fair times or provide a reasonable fallback
+ if fair is None:
+ events = events.filter(
+ start_time__lte=now + datetime.timedelta(days=7),
+ end_time__gte=now - datetime.timedelta(days=1),
+ )
+ else:
+ events = events.filter(
+ start_time__lte=fair.end_time, end_time__gte=fair.start_time
+ )
+
+ events = events.values_list(
+ "start_time", "end_time", "club__name", "club__code", "club__badges__label"
+ ).distinct()
+ output = {}
+ for event in events:
+ # group by start date
+ ts = int(event[0].replace(second=0, microsecond=0).timestamp())
+ if ts not in output:
+ output[ts] = {
+ "start_time": event[0],
+ "end_time": event[1],
+ "events": {},
+ }
+
+ # group by category
+ category = event[4]
+ if category not in output[ts]["events"]:
+ output[ts]["events"][category] = {
+ "category": category,
+ "events": [],
+ }
+
+ output[ts]["events"][category]["events"].append(
+ {"name": event[2], "code": event[3]}
+ )
+ for item in output.values():
+ item["events"] = list(
+ sorted(item["events"].values(), key=lambda cat: cat["category"])
+ )
+ for category in item["events"]:
+ category["events"] = list(
+ sorted(category["events"], key=lambda e: e["name"].casefold())
+ )
+
+ output = list(sorted(output.values(), key=lambda cat: cat["start_time"]))
+ final_output = {
+ "events": output,
+ "fair": ClubFairSerializer(instance=fair).data,
+ }
+ if key:
+ cache.set(key, final_output, 60 * 5)
+
+ return Response(final_output)
+
+ @action(detail=False, methods=["get"])
+ def owned(self, request, *args, **kwargs):
+ """
+ Return all events that the user has officer permissions over.
+ """
+ if not request.user.is_authenticated:
+ return Response([])
+
+ now = timezone.now()
+
+ events = self.filter_queryset(self.get_queryset()).filter(
+ club__membership__person=request.user,
+ club__membership__role__lte=Membership.ROLE_OFFICER,
+ start_time__gte=now,
+ )
+
+ return Response(EventSerializer(events, many=True).data)
+
+
+class TestimonialViewSet(viewsets.ModelViewSet):
+ """
+ list:
+ Return a list of testimonials for this club.
+
+ create:
+ Create a new testimonial for this club.
+
+ update:
+ Update a testimonial for this club.
+ All fields must be specified.
+
+ partial_update:
+ Update a testimonial for this club.
+ Specify only the fields you want to update.
+
+ retrieve:
+ Retrieve a single testimonial.
+
+ destroy:
+ Delete a testimonial.
+ """
+
+ serializer_class = TestimonialSerializer
+ permission_classes = [ClubItemPermission | IsSuperuser]
+
+ def get_queryset(self):
+ return Testimonial.objects.filter(club__code=self.kwargs["club_code"])
+
+
+class QuestionAnswerViewSet(viewsets.ModelViewSet):
+ """
+ list:
+ Return a list of questions and answers for this club.
+
+ create:
+ Create a new question for this club.
+
+ update:
+ Change the question or the answer for this club.
+
+ retrieve:
+ Return a single testimonial.
+
+ destroy:
+ Delete a testimonial.
+ """
+
+ serializer_class = QuestionAnswerSerializer
+ permission_classes = [QuestionAnswerPermission | IsSuperuser]
def get_queryset(self):
club_code = self.kwargs["club_code"]
@@ -3781,36 +4471,340 @@ def post(self, request):
.get("leave_time", None)
)
- meeting = (
- ZoomMeetingVisit.objects.filter(
- meeting_id=meeting_id,
- participant_id=participant_id,
- leave_time__isnull=True,
- )
- .order_by("-created_at")
- .first()
+ meeting = (
+ ZoomMeetingVisit.objects.filter(
+ meeting_id=meeting_id,
+ participant_id=participant_id,
+ leave_time__isnull=True,
+ )
+ .order_by("-created_at")
+ .first()
+ )
+ if meeting is not None:
+ meeting.leave_time = leave_time
+ meeting.save()
+ event_id = meeting.event.id
+
+ if event_id is not None:
+ channel_layer = get_channel_layer()
+ if channel_layer is not None:
+ async_to_sync(channel_layer.group_send)(
+ f"events-live-{event_id}", {"type": "join_leave", "event": action}
+ )
+
+ return Response({"success": True})
+
+
+class MeetingZoomAPIView(APIView):
+ """
+ get: Return a list of upcoming Zoom meetings for a user.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """
+ ---
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ meetings:
+ type: object
+ additionalProperties:
+ type: string
+ extra_details:
+ type: object
+ additionalProperties:
+ type: string
+ ---
+ """
+ refresh = request.query_params.get("refresh", "false").lower() == "true"
+
+ if request.user.is_authenticated:
+ key = f"zoom:meetings:{request.user.username}"
+ if not refresh:
+ res = cache.get(key)
+ if res is not None:
+ return Response(res)
+
+ try:
+ data = zoom_api_call(
+ request.user, "GET", "https://api.zoom.us/v2/users/{uid}/meetings"
+ )
+ except requests.exceptions.HTTPError as e:
+ raise DRFValidationError(
+ "An error occured while fetching meetings for current user."
+ ) from e
+
+ # get meeting ids
+ body = data.json()
+ meetings = [meeting["id"] for meeting in body.get("meetings", [])]
+
+ # get user events
+ if request.user.is_authenticated:
+ events = Event.objects.filter(
+ club__membership__role__lte=Membership.ROLE_OFFICER,
+ club__membership__person=request.user,
+ )
+ else:
+ events = []
+
+ extra_details = {}
+ for event in events:
+ if event.url is not None and "zoom.us" in event.url:
+ match = re.search(r"(\d+)", urlparse(event.url).path)
+ if match is not None:
+ zoom_id = int(match[1])
+ if zoom_id in meetings:
+ try:
+ individual_data = zoom_api_call(
+ request.user,
+ "GET",
+ f"https://api.zoom.us/v2/meetings/{zoom_id}",
+ ).json()
+ extra_details[individual_data["id"]] = individual_data
+ except requests.exceptions.HTTPError:
+ pass
+
+ response = {
+ "success": data.ok,
+ "meetings": body,
+ "extra_details": extra_details,
+ }
+ if response["success"]:
+ cache.set(key, response, 120)
+ return Response(response)
+
+ def delete(self, request):
+ """
+ Delete the Zoom meeting for this event.
+ """
+ event = get_object_or_404(Event, id=request.query_params.get("event"))
+
+ if (
+ not request.user.has_perm("clubs.manage_club")
+ and not event.club.membership_set.filter(
+ person=request.user, role__lte=Membership.ROLE_OFFICER
+ ).exists()
+ ):
+ return Response(
+ {
+ "success": False,
+ "detail": "You do not have permission to perform this action.",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ if event.url:
+ match = re.search(r"(\d+)", urlparse(event.url).path)
+ if "zoom.us" in event.url and match is not None:
+ zoom_id = int(match[1])
+ zoom_api_call(
+ request.user, "DELETE", f"https://api.zoom.us/v2/meetings/{zoom_id}"
+ )
+
+ event.url = None
+ event.save()
+ return Response(
+ {
+ "success": True,
+ "detail": "The Zoom meeting has been unlinked and deleted.",
+ }
+ )
+ else:
+ return Response(
+ {
+ "success": True,
+ "detail": "There is no Zoom meeting configured for this event.",
+ }
+ )
+
+ def post(self, request):
+ """
+ Create a new Zoom meeting for this event
+ or try to fix the existing zoom meeting.
+ ---
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ detail:
+ type: string
+ ---
+ """
+ try:
+ event = Event.objects.get(id=request.query_params.get("event"))
+ except Event.DoesNotExist as e:
+ raise DRFValidationError(
+ "The event you are trying to modify does not exist."
+ ) from e
+
+ eastern = pytz.timezone("America/New_York")
+
+ # ensure user can do this
+ if not request.user.has_perm(
+ "clubs.manage_club"
+ ) and not event.club.membership_set.filter(
+ role__lte=Membership.ROLE_OFFICER, person=request.user
+ ):
+ return Response(
+ {
+ "success": False,
+ "detail": "You are not allowed to perform this action!",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ # add all other officers as alternative hosts
+ alt_hosts = []
+ for mship in event.club.membership_set.filter(
+ role__lte=Membership.ROLE_OFFICER
+ ):
+ social = mship.person.social_auth.filter(provider="zoom-oauth2").first()
+ if social is not None:
+ alt_hosts.append(social.extra_data["email"])
+
+ # recommended zoom meeting settings
+ recommended_settings = {
+ "audio": "both",
+ "join_before_host": True,
+ "mute_upon_entry": True,
+ "waiting_room": False,
+ "meeting_authentication": True,
+ "authentication_domains": "upenn.edu,*.upenn.edu",
+ }
+
+ if alt_hosts:
+ recommended_settings["alternative_hosts"] = ",".join(alt_hosts)
+
+ if not event.url:
+ password = generate_zoom_password()
+ body = {
+ "topic": f"Virtual Activities Fair - {event.club.name}",
+ "type": 2,
+ "start_time": event.start_time.astimezone(eastern)
+ .replace(tzinfo=None, microsecond=0, second=0)
+ .isoformat(),
+ "duration": (event.end_time - event.start_time)
+ / datetime.timedelta(minutes=1),
+ "timezone": "America/New_York",
+ "agenda": f"Virtual Activities Fair Booth for {event.club.name}",
+ "password": password,
+ "settings": recommended_settings,
+ }
+ data = zoom_api_call(
+ request.user,
+ "POST",
+ "https://api.zoom.us/v2/users/{uid}/meetings",
+ json=body,
+ )
+ out = data.json()
+ event.url = out.get("join_url", "")
+ event.save(update_fields=["url"])
+ return Response(
+ {
+ "success": True,
+ "detail": "Your Zoom meeting has been created! "
+ "The following Zoom accounts have been made hosts:"
+ f" {', '.join(alt_hosts)}",
+ }
+ )
+ else:
+ parsed_url = urlparse(event.url)
+
+ if "zoom.us" not in parsed_url.netloc:
+ return Response(
+ {
+ "success": False,
+ "detail": "The current meeting link is not a Zoom link. "
+ "If you would like to have your Zoom link automatically "
+ "generated, please clear the URL field and try again.",
+ }
+ )
+
+ if "upenn.zoom.us" not in parsed_url.netloc:
+ return Response(
+ {
+ "success": False,
+ "detail": "The current meeting link is not a Penn Zoom link. "
+ "If you would like to have your Penn Zoom link automatically "
+ "generated, login with your Penn Zoom account, clear the URL "
+ "from your event, and try this process again.",
+ }
+ )
+
+ match = re.search(r"(\d+)", parsed_url.path)
+ if match is None:
+ return Response(
+ {
+ "success": False,
+ "detail": "Failed to parse your URL, "
+ "are you sure this is a valid Zoom link?",
+ }
+ )
+
+ zoom_id = int(match[1])
+
+ data = zoom_api_call(
+ request.user, "GET", f"https://api.zoom.us/v2/meetings/{zoom_id}"
+ )
+ out = data.json()
+ event.url = out.get("join_url", event.url)
+ event.save(update_fields=["url"])
+
+ start_time = (
+ event.start_time.astimezone(eastern)
+ .replace(tzinfo=None, microsecond=0, second=0)
+ .isoformat()
)
- if meeting is not None:
- meeting.leave_time = leave_time
- meeting.save()
- event_id = meeting.event.id
- if event_id is not None:
- channel_layer = get_channel_layer()
- if channel_layer is not None:
- async_to_sync(channel_layer.group_send)(
- f"events-live-{event_id}", {"type": "join_leave", "event": action}
- )
+ body = {
+ "start_time": start_time,
+ "duration": (event.end_time - event.start_time)
+ / datetime.timedelta(minutes=1),
+ "timezone": "America/New_York",
+ "settings": recommended_settings,
+ }
- return Response({"success": True})
+ out = zoom_api_call(
+ request.user,
+ "PATCH",
+ f"https://api.zoom.us/v2/meetings/{zoom_id}",
+ json=body,
+ )
+
+ return Response(
+ {
+ "success": out.ok,
+ "detail": (
+ "Your Zoom meeting has been updated. "
+ "The following accounts have been made hosts:"
+ f" {', '.join(alt_hosts)}"
+ if out.ok
+ else "Your Zoom meeting has not been updated. "
+ "Are you the owner of the meeting?"
+ ),
+ }
+ )
-class MeetingZoomAPIView(APIView):
- """
- get: Return a list of upcoming Zoom meetings for a user.
+class UserZoomAPIView(APIView):
"""
+ get: Return information about the Zoom account associated with the logged in user.
- permission_classes = [IsAuthenticated]
+ post: Update the Zoom account settings to be the recommended Penn Clubs settings.
+ """
def get(self, request):
"""
@@ -3824,465 +4818,802 @@ def get(self, request):
properties:
success:
type: boolean
- meetings:
- type: object
- additionalProperties:
- type: string
- extra_details:
+ settings:
type: object
additionalProperties:
type: string
+ email:
+ type: string
---
"""
refresh = request.query_params.get("refresh", "false").lower() == "true"
+ no_cache = request.query_params.get("noCache", "false").lower() == "true"
if request.user.is_authenticated:
- key = f"zoom:meetings:{request.user.username}"
- if not refresh:
- res = cache.get(key)
- if res is not None:
- return Response(res)
+ key = f"zoom:user:{request.user.username}"
+ res = cache.get(key)
+ if res is not None:
+ if not refresh:
+ if res.get("success") is True:
+ return Response(res)
+ else:
+ cache.delete(key)
+ if no_cache:
+ cache.delete(key)
try:
- data = zoom_api_call(
- request.user, "GET", "https://api.zoom.us/v2/users/{uid}/meetings"
+ response = zoom_api_call(
+ request.user,
+ "GET",
+ "https://api.zoom.us/v2/users/{uid}/settings",
)
except requests.exceptions.HTTPError as e:
raise DRFValidationError(
- "An error occured while fetching meetings for current user."
+ "An error occured while fetching user information. "
+ "Your authentication with the Zoom API might have expired. "
+ "Try reconnecting your account."
) from e
- # get meeting ids
- body = data.json()
- meetings = [meeting["id"] for meeting in body.get("meetings", [])]
+ social = request.user.social_auth.filter(provider="zoom-oauth2").first()
+ if social is None:
+ email = None
+ else:
+ email = social.extra_data.get("email")
- # get user events
+ settings = response.json()
+ res = {
+ "success": settings.get("code") is None,
+ "settings": settings,
+ "email": email,
+ }
+
+ if res["success"]:
+ cache.set(key, res, 900)
+ return Response(res)
+
+ def post(self, request):
+ """
+ ---
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ detail:
+ type: string
+ ---
+ """
if request.user.is_authenticated:
- events = Event.objects.filter(
- club__membership__role__lte=Membership.ROLE_OFFICER,
- club__membership__person=request.user,
- )
- else:
- events = []
+ key = f"zoom:user:{request.user.username}"
+ cache.delete(key)
- extra_details = {}
- for event in events:
- if event.url is not None and "zoom.us" in event.url:
- match = re.search(r"(\d+)", urlparse(event.url).path)
- if match is not None:
- zoom_id = int(match[1])
- if zoom_id in meetings:
- try:
- individual_data = zoom_api_call(
- request.user,
- "GET",
- f"https://api.zoom.us/v2/meetings/{zoom_id}",
- ).json()
- extra_details[individual_data["id"]] = individual_data
- except requests.exceptions.HTTPError:
- pass
+ response = zoom_api_call(
+ request.user,
+ "PATCH",
+ "https://api.zoom.us/v2/users/{uid}/settings",
+ json={
+ "in_meeting": {
+ "breakout_room": True,
+ "waiting_room": False,
+ "co_host": True,
+ "screen_sharing": True,
+ }
+ },
+ )
+
+ return Response(
+ {
+ "success": response.ok,
+ "detail": (
+ "Your user settings have been updated on Zoom."
+ if response.ok
+ else "Failed to update Zoom user settings."
+ ),
+ }
+ )
+
+
+class UserUpdateAPIView(generics.RetrieveUpdateAPIView):
+ """
+ get: Return information about the logged in user, including bookmarks,
+ subscriptions, memberships, and school/major/graduation year information.
+
+ put: Update information about the logged in user.
+ All fields are required.
+
+ patch: Update information about the logged in user.
+ Only updates fields that are passed to the server.
+ """
+
+ permission_classes = [IsAuthenticated]
+ serializer_class = UserSerializer
+
+ def get(self, request, *args, **kwargs):
+ """
+ Cache the settings endpoint for 5 minutes or until user data is updated.
+ """
+ key = f"user:settings:{request.user.username}"
+ val = cache.get(key)
+ if val:
+ return Response(val)
+ resp = super().get(request, *args, **kwargs)
+ cache.set(key, resp.data, 5 * 60)
+ return resp
+
+ def put(self, request, *args, **kwargs):
+ """
+ Clear the cache when putting user settings.
+ """
+ key = f"user:settings:{request.user.username}"
+ cache.delete(key)
+ return super().put(request, *args, **kwargs)
+
+ def patch(self, request, *args, **kwargs):
+ """
+ Clear the cache when patching user settings.
+ """
+ key = f"user:settings:{request.user.username}"
+ cache.delete(key)
+ return super().patch(request, *args, **kwargs)
+
+ def get_operation_id(self, **kwargs):
+ if kwargs["action"] == "get":
+ return "Retrieve Self User"
+ return None
+
+ def get_object(self):
+ user = self.request.user
+ prefetch_related_objects(
+ [user],
+ "profile__school",
+ "profile__major",
+ )
+ return user
+
+
+class TicketViewSet(viewsets.ModelViewSet):
+ """
+ get:
+ Get a specific ticket owned by a user
+
+ list:
+ List all tickets owned by a user
+
+ partial_update:
+ Update attendance for a ticket
+
+ cart:
+ List all unowned/unheld tickets currently in a user's cart
+
+ initiate_checkout:
+ Initiate a hold on the tickets in a user's cart and create a capture context
+
+ complete_checkout:
+ Complete the checkout process after we have obtained an auth on a user's card
+
+ qr:
+ Get a ticket's QR code
- response = {
- "success": data.ok,
- "meetings": body,
- "extra_details": extra_details,
+ transfer:
+ Transfer a ticket to another user
+ """
+
+ permission_classes = [IsAuthenticated]
+ serializer_class = TicketSerializer
+ http_method_names = ["get", "post", "patch"]
+ lookup_field = "id"
+
+ @staticmethod
+ def _calculate_cart_total(cart) -> float:
+ """
+ Calculate the total price of all tickets in a cart, applying discounts
+ where appropriate. Does not validate that the cart is valid.
+
+ :param cart: Cart object
+ :return: Total price of all tickets in the cart
+ """
+ ticket_type_counts = {
+ item["type"]: item["count"]
+ for item in cart.tickets.values("type").annotate(count=Count("type"))
}
- if response["success"]:
- cache.set(key, response, 120)
- return Response(response)
+ cart_total = sum(
+ (
+ ticket.price * (1 - ticket.group_discount)
+ if ticket.group_size
+ and ticket_type_counts[ticket.type] >= ticket.group_size
+ else ticket.price
+ )
+ for ticket in cart.tickets.all()
+ )
+ return cart_total
- def delete(self, request):
+ def partial_update(self, request, *args, **kwargs):
"""
- Delete the Zoom meeting for this event.
+ Update a ticket's attendance (only accessible by club officers)
+ ---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ attended:
+ type: boolean
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Ticket'
+ "400":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ ---
"""
- event = get_object_or_404(Event, id=request.query_params.get("event"))
+ attended = request.data.get("attended")
+ if attended is None or not isinstance(attended, bool):
+ return Response(
+ {"detail": "Missing boolean attribute 'attended'."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ ticket = self.get_object()
+ ticket.attended = attended
+ ticket.save()
+ return Response(TicketSerializer(ticket).data)
- if (
- not request.user.has_perm("clubs.manage_club")
- and not event.club.membership_set.filter(
- person=request.user, role__lte=Membership.ROLE_OFFICER
- ).exists()
- ):
+ @transaction.atomic
+ @update_holds
+ @action(detail=False, methods=["get"])
+ def cart(self, request, *args, **kwargs):
+ """
+ Validate tickets in a cart and return them. Replace in-cart tickets that
+ have been bought/held by someone else.
+ ---
+ requestBody:
+ content: {}
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ tickets:
+ allOf:
+ - $ref: "#/components/schemas/Ticket"
+ sold_out:
+ type: array
+ items:
+ type: object
+ properties:
+ event:
+ type: object
+ properties:
+ id:
+ type: integer
+ name:
+ type: string
+ type:
+ type: string
+ count:
+ type: integer
+ ---
+ """
+
+ cart, _ = Cart.objects.prefetch_related("tickets").get_or_create(
+ owner=self.request.user
+ )
+
+ tickets_to_replace = cart.tickets.filter(
+ Q(owner__isnull=False) | Q(holder__isnull=False)
+ ).exclude(holder=self.request.user)
+
+ # In most cases, we won't need to replace, so exit early
+ if not tickets_to_replace.exists():
return Response(
{
- "success": False,
- "detail": "You do not have permission to perform this action.",
+ "tickets": TicketSerializer(cart.tickets.all(), many=True).data,
+ "sold_out": [],
},
- status=status.HTTP_403_FORBIDDEN,
)
- if event.url:
- match = re.search(r"(\d+)", urlparse(event.url).path)
- if "zoom.us" in event.url and match is not None:
- zoom_id = int(match[1])
- zoom_api_call(
- request.user, "DELETE", f"https://api.zoom.us/v2/meetings/{zoom_id}"
+ # Attempt to replace all tickets that have gone stale
+ replacement_tickets, sold_out_tickets = [], []
+
+ tickets_in_cart = cart.tickets.values_list("id", flat=True)
+ tickets_to_replace = tickets_to_replace.select_related("event")
+
+ for ticket_class in tickets_to_replace.values(
+ "type", "event", "event__name"
+ ).annotate(count=Count("id")):
+ # we don't need to lock, since we aren't updating holder/owner
+ available_tickets = Ticket.objects.filter(
+ event=ticket_class["event"],
+ type=ticket_class["type"],
+ buyable=True, # should not be triggered as buyable is by ticket class
+ owner__isnull=True,
+ holder__isnull=True,
+ ).exclude(id__in=tickets_in_cart)[: ticket_class["count"]]
+
+ num_short = ticket_class["count"] - available_tickets.count()
+ if num_short > 0:
+ sold_out_tickets.append(
+ {
+ "type": ticket_class["type"],
+ "event": {
+ "id": ticket_class["event"],
+ "name": ticket_class["event__name"],
+ },
+ "count": num_short,
+ }
)
- event.url = None
- event.save()
- return Response(
- {
- "success": True,
- "detail": "The Zoom meeting has been unlinked and deleted.",
- }
- )
- else:
- return Response(
- {
- "success": True,
- "detail": "There is no Zoom meeting configured for this event.",
- }
- )
+ replacement_tickets.extend(list(available_tickets))
- def post(self, request):
+ cart.tickets.remove(*tickets_to_replace)
+ if replacement_tickets:
+ cart.tickets.add(*replacement_tickets)
+ cart.save()
+
+ return Response(
+ {
+ "tickets": TicketSerializer(cart.tickets.all(), many=True).data,
+ "sold_out": sold_out_tickets,
+ },
+ )
+
+ @action(detail=False, methods=["post"])
+ @update_holds
+ @transaction.atomic
+ def initiate_checkout(self, request, *args, **kwargs):
"""
- Create a new Zoom meeting for this event
- or try to fix the existing zoom meeting.
+ Checkout all tickets in cart and create a Cybersource capture context
+
+ NOTE: this does NOT buy tickets, it simply initiates a checkout process
+ which includes a 10-minute ticket hold
+
+ Once the user has entered their payment details and submitted the form
+ the request will be routed to complete_checkout
+
+ 403 implies a stale cart.
---
+ requestBody: {}
responses:
"200":
content:
application/json:
schema:
- type: object
- properties:
+ type: object
+ properties:
+ detail:
+ type: string
success:
type: boolean
+ sold_free_tickets:
+ type: boolean
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
detail:
type: string
+ success:
+ type: boolean
+ sold_free_tickets:
+ type: boolean
---
"""
- try:
- event = Event.objects.get(id=request.query_params.get("event"))
- except Event.DoesNotExist as e:
- raise DRFValidationError(
- "The event you are trying to modify does not exist."
- ) from e
+ cart = get_object_or_404(Cart, owner=self.request.user)
- eastern = pytz.timezone("America/New_York")
+ # Cart must have at least one ticket
+ if not cart.tickets.exists():
+ return Response(
+ {
+ "success": False,
+ "detail": "No tickets selected for checkout.",
+ "sold_free_tickets": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
- # ensure user can do this
- if not request.user.has_perm(
- "clubs.manage_club"
- ) and not event.club.membership_set.filter(
- role__lte=Membership.ROLE_OFFICER, person=request.user
- ):
+ # skip_locked is important here because if any of the tickets in cart
+ # are locked, we shouldn't block.
+ tickets = cart.tickets.select_for_update(skip_locked=True).filter(
+ Q(holder__isnull=True) | Q(holder=self.request.user),
+ owner__isnull=True,
+ buyable=True,
+ )
+
+ # Assert that the filter succeeded in freezing all the tickets for checkout
+ if tickets.count() != cart.tickets.count():
return Response(
{
"success": False,
- "detail": "You are not allowed to perform this action!",
+ "detail": (
+ "Cart is stale or empty, invoke /api/tickets/cart to refresh"
+ ),
+ "sold_free_tickets": False,
},
status=status.HTTP_403_FORBIDDEN,
)
- # add all other officers as alternative hosts
- alt_hosts = []
- for mship in event.club.membership_set.filter(
- role__lte=Membership.ROLE_OFFICER
- ):
- social = mship.person.social_auth.filter(provider="zoom-oauth2").first()
- if social is not None:
- alt_hosts.append(social.extra_data["email"])
-
- # recommended zoom meeting settings
- recommended_settings = {
- "audio": "both",
- "join_before_host": True,
- "mute_upon_entry": True,
- "waiting_room": False,
- "meeting_authentication": True,
- "authentication_domains": "upenn.edu,*.upenn.edu",
- }
+ cart_total = self._calculate_cart_total(cart)
+
+ # If all tickets are free, we can skip the payment process
+ if not cart_total:
+ order_info = {
+ "amountDetails": {"totalAmount": "0.00"},
+ "billTo": {
+ "reconciliationId": None,
+ "firstName": self.request.user.first_name,
+ "lastName": self.request.user.last_name,
+ "phoneNumber": None,
+ "email": self.request.user.email,
+ },
+ }
- if alt_hosts:
- recommended_settings["alternative_hosts"] = ",".join(alt_hosts)
+ # Place hold on tickets for 10 mins
+ self._place_hold_on_tickets(tickets)
+ # Skip payment process and give tickets to user/buyer
+ self._give_tickets(self.request.user, order_info, cart, None)
- if not event.url:
- password = generate_zoom_password()
- body = {
- "topic": f"Virtual Activities Fair - {event.club.name}",
- "type": 2,
- "start_time": event.start_time.astimezone(eastern)
- .replace(tzinfo=None, microsecond=0, second=0)
- .isoformat(),
- "duration": (event.end_time - event.start_time)
- / datetime.timedelta(minutes=1),
- "timezone": "America/New_York",
- "agenda": f"Virtual Activities Fair Booth for {event.club.name}",
- "password": password,
- "settings": recommended_settings,
- }
- data = zoom_api_call(
- request.user,
- "POST",
- "https://api.zoom.us/v2/users/{uid}/meetings",
- json=body,
- )
- out = data.json()
- event.url = out.get("join_url", "")
- event.save(update_fields=["url"])
return Response(
{
"success": True,
- "detail": "Your Zoom meeting has been created! "
- "The following Zoom accounts have been made hosts:"
- f" {', '.join(alt_hosts)}",
+ "detail": "Free tickets sold.",
+ "sold_free_tickets": True,
}
)
- else:
- parsed_url = urlparse(event.url)
-
- if "zoom.us" not in parsed_url.netloc:
- return Response(
- {
- "success": False,
- "detail": "The current meeting link is not a Zoom link. "
- "If you would like to have your Zoom link automatically "
- "generated, please clear the URL field and try again.",
- }
- )
-
- if "upenn.zoom.us" not in parsed_url.netloc:
- return Response(
- {
- "success": False,
- "detail": "The current meeting link is not a Penn Zoom link. "
- "If you would like to have your Penn Zoom link automatically "
- "generated, login with your Penn Zoom account, clear the URL "
- "from your event, and try this process again.",
- }
- )
-
- match = re.search(r"(\d+)", parsed_url.path)
- if match is None:
- return Response(
- {
- "success": False,
- "detail": "Failed to parse your URL, "
- "are you sure this is a valid Zoom link?",
- }
- )
-
- zoom_id = int(match[1])
-
- data = zoom_api_call(
- request.user, "GET", f"https://api.zoom.us/v2/meetings/{zoom_id}"
- )
- out = data.json()
- event.url = out.get("join_url", event.url)
- event.save(update_fields=["url"])
-
- start_time = (
- event.start_time.astimezone(eastern)
- .replace(tzinfo=None, microsecond=0, second=0)
- .isoformat()
- )
- body = {
- "start_time": start_time,
- "duration": (event.end_time - event.start_time)
- / datetime.timedelta(minutes=1),
- "timezone": "America/New_York",
- "settings": recommended_settings,
- }
+ capture_context_request = {
+ "_target_origins": [settings.CYBERSOURCE_TARGET_ORIGIN],
+ "_client_version": settings.CYBERSOURCE_CLIENT_VERSION,
+ "_allowed_card_networks": [
+ "VISA",
+ "MASTERCARD",
+ "AMEX",
+ "DISCOVER",
+ ],
+ "_allowed_payment_types": ["PANENTRY", "SRC"],
+ "_country": "US",
+ "_locale": "en_US",
+ "_capture_mandate": {
+ "_billing_type": "FULL",
+ "_request_email": True,
+ "_request_phone": True,
+ "_request_shipping": True,
+ "_show_accepted_network_icons": True,
+ },
+ "_order_information": {
+ "_amount_details": {
+ "_total_amount": f"{cart_total:.2f}",
+ "_currency": "USD",
+ }
+ },
+ }
- out = zoom_api_call(
- request.user,
- "PATCH",
- f"https://api.zoom.us/v2/meetings/{zoom_id}",
- json=body,
+ try:
+ context, http_status, _ = UnifiedCheckoutCaptureContextApi(
+ settings.CYBERSOURCE_CONFIG
+ ).generate_unified_checkout_capture_context_with_http_info(
+ json.dumps(capture_context_request)
)
+ if not context or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {context} with HTTP status {http_status}",
+ )
+
+ # Tie generated capture context to user cart
+ if cart.checkout_context != context:
+ cart.checkout_context = context
+ cart.save()
+
+ # Place hold on tickets for 10 mins
+ self._place_hold_on_tickets(tickets)
return Response(
{
- "success": out.ok,
- "detail": (
- "Your Zoom meeting has been updated. "
- "The following accounts have been made hosts:"
- f" {', '.join(alt_hosts)}"
- if out.ok
- else "Your Zoom meeting has not been updated. "
- "Are you the owner of the meeting?"
- ),
+ "success": True,
+ "detail": context,
+ "sold_free_tickets": False,
}
)
+ except ApiException as e:
+ return Response(
+ {
+ "success": False,
+ "detail": f"Unable to generate capture context: {e}",
+ "sold_free_tickets": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
-class UserZoomAPIView(APIView):
- """
- get: Return information about the Zoom account associated with the logged in user.
-
- post: Update the Zoom account settings to be the recommended Penn Clubs settings.
- """
-
- def get(self, request):
+ @action(detail=False, methods=["post"])
+ @update_holds
+ @transaction.atomic
+ def complete_checkout(self, request, *args, **kwargs):
"""
+ Complete the checkout after the user has entered their payment details
+ and obtained a transient token on the frontend.
+
+ 403 implies a stale cart.
---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ transient_token:
+ type: string
responses:
"200":
content:
application/json:
schema:
- type: object
- properties:
+ type: object
+ properties:
+ detail:
+ type: string
success:
type: boolean
- settings:
- type: object
- additionalProperties:
- type: string
- email:
- type: string
---
"""
- refresh = request.query_params.get("refresh", "false").lower() == "true"
- no_cache = request.query_params.get("noCache", "false").lower() == "true"
+ tt = request.data.get("transient_token")
+ cart = get_object_or_404(
+ Cart.objects.prefetch_related("tickets"), owner=self.request.user
+ )
- if request.user.is_authenticated:
- key = f"zoom:user:{request.user.username}"
- res = cache.get(key)
- if res is not None:
- if not refresh:
- if res.get("success") is True:
- return Response(res)
- else:
- cache.delete(key)
- if no_cache:
- cache.delete(key)
+ cc = cart.checkout_context
+ if cc is None:
+ return Response(
+ {"success": False, "detail": "Associated capture context not found"},
+ status=status.HTTP_500_BAD_REQUEST,
+ )
+
+ ok, message = validate_transient_token(cc, tt)
+ if not ok:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+ return Response(
+ {"success": False, "detail": message},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ # Guard against holds expiring before the capture context
+ tickets = cart.tickets.filter(holder=self.request.user, owner__isnull=True)
+ if tickets.count() != cart.tickets.count():
+ return Response(
+ {
+ "success": False,
+ "detail": "Cart is stale, invoke /api/tickets/cart to refresh",
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
try:
- response = zoom_api_call(
- request.user,
- "GET",
- "https://api.zoom.us/v2/users/{uid}/settings",
+ _, http_status, transaction_data = TransientTokenDataApi(
+ settings.CYBERSOURCE_CONFIG
+ ).get_transaction_for_transient_token(tt)
+
+ if not transaction_data or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {transaction_data} with HTTP status {http_status}"
+ )
+ transaction_data = json.loads(transaction_data)
+ except ApiException as e:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+
+ return Response(
+ {
+ "success": False,
+ "detail": f"Transaction failed: {e}",
+ },
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
- except requests.exceptions.HTTPError as e:
- raise DRFValidationError(
- "An error occured while fetching user information. "
- "Your authentication with the Zoom API might have expired. "
- "Try reconnecting your account."
- ) from e
- social = request.user.social_auth.filter(provider="zoom-oauth2").first()
- if social is None:
- email = None
- else:
- email = social.extra_data.get("email")
+ create_payment_request = {"tokenInformation": {"transientTokenJwt": tt}}
- settings = response.json()
- res = {
- "success": settings.get("code") is None,
- "settings": settings,
- "email": email,
- }
+ try:
+ payment_response, http_status, _ = PaymentsApi(
+ settings.CYBERSOURCE_CONFIG
+ ).create_payment(json.dumps(create_payment_request))
- if res["success"]:
- cache.set(key, res, 900)
- return Response(res)
+ if payment_response.status != "AUTHORIZED":
+ raise ApiException(reason="Payment response status is not authorized")
+ reconciliation_id = payment_response.reconciliation_id
- def post(self, request):
+ if not payment_response or http_status >= 400:
+ raise ApiException(
+ reason=f"Received {payment_response} with HTTP status {http_status}"
+ )
+ except ApiException as e:
+ # Cleanup state since the purchase failed
+ cart.tickets.update(holder=None, owner=None)
+ cart.checkout_context = None
+ cart.save()
+
+ return Response(
+ {
+ "success": False,
+ "detail": f"Transaction failed: {e}",
+ },
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ order_info = transaction_data["orderInformation"]
+ self._give_tickets(self.request.user, order_info, cart, reconciliation_id)
+
+ return Response(
+ {
+ "success": True,
+ "detail": "Payment successful.",
+ }
+ )
+
+ @action(detail=True, methods=["get"])
+ def qr(self, request, *args, **kwargs):
+ """
+ Return a QR code png image representing a link to the ticket.
+ ---
+ operationId: Generate QR Code for ticket
+ responses:
+ "200":
+ description: Return a png image representing a QR code to the ticket.
+ content:
+ image/png:
+ schema:
+ type: binary
+ ---
+ """
+ ticket = self.get_object()
+ qr_image = ticket.get_qr()
+ response = HttpResponse(content_type="image/png")
+ qr_image.save(response, "PNG")
+ return response
+
+ @action(detail=True, methods=["post"])
+ @transaction.atomic
+ def transfer(self, request, *args, **kwargs):
"""
+ Transfer a ticket to another user
---
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ username:
+ type: string
+ required:
+ - username
responses:
"200":
content:
application/json:
schema:
- type: object
- properties:
- success:
- type: boolean
+ type: object
+ properties:
+ detail:
+ type: string
+ "403":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ detail:
+ type: string
+ "404":
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
detail:
type: string
---
"""
- if request.user.is_authenticated:
- key = f"zoom:user:{request.user.username}"
- cache.delete(key)
-
- response = zoom_api_call(
- request.user,
- "PATCH",
- "https://api.zoom.us/v2/users/{uid}/settings",
- json={
- "in_meeting": {
- "breakout_room": True,
- "waiting_room": False,
- "co_host": True,
- "screen_sharing": True,
- }
- },
- )
-
- return Response(
- {
- "success": response.ok,
- "detail": (
- "Your user settings have been updated on Zoom."
- if response.ok
- else "Failed to update Zoom user settings."
- ),
- }
+ receiver = get_object_or_404(
+ get_user_model(), username=request.data.get("username")
)
+ # checking whether the request's user owns the ticket is handled by the queryset
+ ticket = self.get_object()
+ if not ticket.transferable:
+ return Response(
+ {"detail": "The ticket is non-transferable"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
-class UserUpdateAPIView(generics.RetrieveUpdateAPIView):
- """
- get: Return information about the logged in user, including bookmarks,
- subscriptions, memberships, and school/major/graduation year information.
+ if self.request.user == receiver:
+ return Response(
+ {"detail": "You cannot transfer a ticket to yourself"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
- put: Update information about the logged in user.
- All fields are required.
+ ticket.owner = receiver
+ ticket.save()
+ TicketTransferRecord.objects.create(
+ ticket=ticket, sender=self.request.user, receiver=receiver
+ ).send_confirmation_email()
+ ticket.send_confirmation_email() # send event details to recipient
- patch: Update information about the logged in user.
- Only updates fields that are passed to the server.
- """
+ return Response({"detail": "Successfully transferred ownership of ticket"})
- permission_classes = [IsAuthenticated]
- serializer_class = UserSerializer
+ def get_queryset(self):
+ if self.action == "partial_update":
+ officer_clubs = Membership.objects.filter(
+ person=self.request.user, role__lte=Membership.ROLE_OFFICER
+ ).values_list("club", flat=True)
+ return Ticket.objects.filter(event__club__in=officer_clubs).select_related(
+ "event__club"
+ )
+ return Ticket.objects.filter(owner=self.request.user.id)
- def get(self, request, *args, **kwargs):
+ def _give_tickets(self, user, order_info, cart, reconciliation_id):
"""
- Cache the settings endpoint for 5 minutes or until user data is updated.
+ Helper function that gives user/buyer their held tickets
+ and archives the transaction data
"""
- key = f"user:settings:{request.user.username}"
- val = cache.get(key)
- if val:
- return Response(val)
- resp = super().get(request, *args, **kwargs)
- cache.set(key, resp.data, 5 * 60)
- return resp
- def put(self, request, *args, **kwargs):
- """
- Clear the cache when putting user settings.
- """
- key = f"user:settings:{request.user.username}"
- cache.delete(key)
- return super().put(request, *args, **kwargs)
+ # At this point, we have validated that the payment was authorized
+ # Give the tickets to the user
+ tickets = cart.tickets.select_for_update().filter(holder=user)
- def patch(self, request, *args, **kwargs):
- """
- Clear the cache when patching user settings.
- """
- key = f"user:settings:{request.user.username}"
- cache.delete(key)
- return super().patch(request, *args, **kwargs)
+ # Archive transaction data for historical purposes.
+ # We're explicitly using the response data over what's in self.request.user
+ transaction_records = []
- def get_operation_id(self, **kwargs):
- if kwargs["action"] == "get":
- return "Retrieve Self User"
- return None
+ for ticket in tickets:
+ transaction_records.append(
+ TicketTransactionRecord(
+ ticket=ticket,
+ reconciliation_id=str(reconciliation_id),
+ total_amount=float(order_info["amountDetails"]["totalAmount"]),
+ buyer_first_name=order_info["billTo"]["firstName"],
+ buyer_last_name=order_info["billTo"]["lastName"],
+ # TODO: investigate why phone numbers don't show in test API
+ buyer_phone=order_info["billTo"].get("phoneNumber", None),
+ buyer_email=order_info["billTo"]["email"],
+ )
+ )
- def get_object(self):
- user = self.request.user
- prefetch_related_objects(
- [user],
- "profile__school",
- "profile__major",
- )
- return user
+ tickets.update(owner=self.request.user, holder=None)
+ TicketTransactionRecord.objects.bulk_create(transaction_records)
+ cart.tickets.clear()
+ for ticket in tickets:
+ ticket.send_confirmation_email()
+
+ Ticket.objects.update_holds()
+
+ cart.checkout_context = None
+ cart.save()
+
+ def _place_hold_on_tickets(self, tickets):
+ """
+ Helper function that places a 10 minute hold on tickets for a user
+ """
+ holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
+ tickets.update(holder=self.request.user, holding_expiration=holding_expiration)
class MemberInviteViewSet(viewsets.ModelViewSet):
diff --git a/backend/pennclubs/settings/base.py b/backend/pennclubs/settings/base.py
index 2b71a8e24..cbdac66cd 100644
--- a/backend/pennclubs/settings/base.py
+++ b/backend/pennclubs/settings/base.py
@@ -255,3 +255,6 @@
PHONENUMBER_DB_FORMAT = "NATIONAL"
PHONENUMBER_DEFAULT_REGION = "US"
+
+# Cybersource settings
+CYBERSOURCE_CLIENT_VERSION = "0.15"
diff --git a/backend/pennclubs/settings/ci.py b/backend/pennclubs/settings/ci.py
index 417929077..0f2e707ed 100644
--- a/backend/pennclubs/settings/ci.py
+++ b/backend/pennclubs/settings/ci.py
@@ -20,3 +20,13 @@
# Allow http callback for DLA
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": "testrest",
+ "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda",
+ "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=",
+ "run_environment": "apitest.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001"
diff --git a/backend/pennclubs/settings/development.py b/backend/pennclubs/settings/development.py
index e578f4cfd..90c2d1504 100644
--- a/backend/pennclubs/settings/development.py
+++ b/backend/pennclubs/settings/development.py
@@ -17,7 +17,7 @@
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
# Allow requests from frontend
-CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"]
+CSRF_TRUSTED_ORIGINS = ["https://localhost:3001", "http://localhost:3000"]
# Use console email backend during development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
@@ -32,3 +32,13 @@
# Caching settings
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": "testrest",
+ "merchant_keyid": "08c94330-f618-42a3-b09d-e1e43be5efda",
+ "merchant_secretkey": "yBJxy6LjM2TmcPGu+GaJrHtkke25fPpUX+UY6/L/1tE=",
+ "run_environment": "apitest.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001"
diff --git a/backend/pennclubs/settings/production.py b/backend/pennclubs/settings/production.py
index 2be4f552d..dcaf37299 100644
--- a/backend/pennclubs/settings/production.py
+++ b/backend/pennclubs/settings/production.py
@@ -74,3 +74,13 @@
"KEY_PREFIX": "django",
}
}
+
+# Cybersource settings
+CYBERSOURCE_CONFIG = {
+ "authentication_type": "http_signature",
+ "merchantid": os.getenv("MERCHANT_ID"),
+ "merchant_keyid": os.getenv("MERCHANT_KEYID"),
+ "merchant_secretkey": os.getenv("MERCHANT_SECRETKEY"),
+ "run_environment": "api.cybersource.com",
+}
+CYBERSOURCE_TARGET_ORIGIN = "https://pennclubs.com"
diff --git a/backend/templates/emails/ticket_confirmation.html b/backend/templates/emails/ticket_confirmation.html
new file mode 100644
index 000000000..e6ac4db89
--- /dev/null
+++ b/backend/templates/emails/ticket_confirmation.html
@@ -0,0 +1,49 @@
+
+
+{% extends 'emails/base.html' %}
+
+{% block content %}
+
Thanks for using Penn Clubs!
+
+
+ {{ first_name }}, thank you for your recent purchase of a ticket to {{ name }} with ticket type {{type }}.
+
+
+
+ As a reminder, the event starts at {{ start_time }} and ends at {{ end_time }}.
+
+
+
+
+
+ Please be 10 minutes early for a smooth seating experience.
+
+
+
Below is a
+ QR code for
+ your confirmation.
+
+
+
+
+
Note: all tickets issued by us are non-refundable.
+
+
+
+
+ If you have any questions, feel free to respond to this email.
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/templates/emails/ticket_transfer.html b/backend/templates/emails/ticket_transfer.html
new file mode 100644
index 000000000..d4b69f39f
--- /dev/null
+++ b/backend/templates/emails/ticket_transfer.html
@@ -0,0 +1,27 @@
+
+
+{% extends 'emails/base.html' %}
+
+{% block content %}
+
Ticket Transfer Confirmation
+
+
+ {{ sender_first_name }}, this is confirmation that you have transferred a ticket to {{ receiver_first_name }} ({{ receiver_username }}) for {{ event_name }} with ticket type {{ type }}.
+
+
+
+
+ If you believe that this was sent in error or have any questions, feel free to respond to this email.
+
+ }
+ />
+
+ {name}
+
+ Create new tickets for this event. For our alpha, only free tickets
+ will be supported for now: stay tuned for payments integration!
+
+
+
+
+ Your cart is empty
+
+ To add tickets to your cart, visit the event page and select the
+ tickets you wish to purchase. If you believe this is an error,
+ please contact support at
+
+ contact@pennlabs.org
+
+ .
+
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ Remove Ticket
+
+ Are you sure you want to remove this ticket for{' '}
+ {removeModal?.event.name}?
+