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. +

+{% endblock %} \ No newline at end of file diff --git a/backend/tests/clubs/test_documentation.py b/backend/tests/clubs/test_documentation.py index 01865073a..67bfe8170 100644 --- a/backend/tests/clubs/test_documentation.py +++ b/backend/tests/clubs/test_documentation.py @@ -204,8 +204,8 @@ def test_openapi_docs(self): ) if "application/json" in content["content"]: json_content = content["content"]["application/json"] - self.assertTrue("schema" in json_content) try: + self.assertTrue("schema" in json_content) self.verify_schema(json_content["schema"]) except AssertionError as e: raise AssertionError( diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py new file mode 100644 index 000000000..767d2eb4c --- /dev/null +++ b/backend/tests/clubs/test_ticketing.py @@ -0,0 +1,1520 @@ +import json +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import timedelta +from unittest.mock import patch + +import freezegun +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.db.models.deletion import ProtectedError +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from clubs.models import ( + Cart, + Club, + Event, + Membership, + Ticket, + TicketTransactionRecord, + TicketTransferRecord, +) + + +def commonSetUp(self): + self.client = APIClient() + + self.user1 = get_user_model().objects.create_user( + "jadams", "jadams@sas.upenn.edu", "test" + ) + self.user1.first_name = "John" + self.user1.last_name = "Adams" + self.user1.is_staff = True + self.user1.is_superuser = True + self.user1.save() + + self.user2 = get_user_model().objects.create_user( + "bfranklin", "bfranklin@seas.upenn.edu", "test" + ) + self.user2.first_name = "Benjamin" + self.user2.last_name = "Franklin" + self.user2.save() + + self.club1 = Club.objects.create( + code="test-club", + name="Test Club", + approved=True, + email="example@example.com", + ) + + self.event1 = Event.objects.create( + code="test-event", + club=self.club1, + name="Test Event", + start_time=timezone.now() + timezone.timedelta(days=2), + end_time=timezone.now() + timezone.timedelta(days=3), + ) + + self.ticket_totals = [ + {"type": "normal", "count": 20, "price": 15.0}, + {"type": "premium", "count": 10, "price": 30.0}, + ] + + self.tickets1 = [ + Ticket.objects.create(type="normal", event=self.event1, price=15.0) + for _ in range(20) + ] + self.tickets2 = [ + Ticket.objects.create(type="premium", event=self.event1, price=30.0) + for _ in range(10) + ] + + +class TicketEventTestCase(TestCase): + """ + Test cases related to the methods on the ClubEventViewSet + that correspond to the ticketing project: + + tickets (get), tickets (put), buyers, add_to_cart, remove_from_cart + """ + + def setUp(self): + commonSetUp(self) + + def test_create_ticket_offerings(self): + self.client.login(username=self.user1.username, password="test") + qts = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + } + + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + qts, + format="json", + ) + + aggregated_tickets = list( + Ticket.objects.filter(event=self.event1, type__contains="_") + .values("type", "price") + .annotate(count=Count("id")) + ) + for t1, t2 in zip(qts["quantities"], aggregated_tickets): + self.assertEqual(t1["type"], t2["type"]) + self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.02) + self.assertEqual(t1["count"], t2["count"]) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + def test_create_ticket_offerings_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + + tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(10)] + Ticket.objects.bulk_create(tickets) + + qts = { + "quantities": [ + {"type": "_free", "count": 10, "price": 0}, + ] + } + + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + qts, + format="json", + ) + + aggregated_tickets = list( + Ticket.objects.filter(event=self.event1, type__contains="_") + .values("type", "price") + .annotate(count=Count("id")) + ) + for t1, t2 in zip(qts["quantities"], aggregated_tickets): + self.assertEqual(t1["type"], t2["type"]) + self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.00) + self.assertEqual(t1["count"], t2["count"]) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + def test_create_ticket_offerings_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + qts = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + } + + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + qts, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + + def test_create_ticket_offerings_bad_data(self): + self.client.login(username=self.user1.username, password="test") + bad_data = [ + { + # Bad toplevel field + "quant1t13s": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ] + }, + { + "quantities": [ + # Negative price + {"type": "_normal", "count": 20, "price": -10}, + {"type": "_premium", "count": 10, "price": -20}, + ] + }, + { + "quantities": [ + # Bad field members + {"abcd": "_normal", "abcde": 20, "price": -10}, + ] + }, + ] + + for data in bad_data: + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + data, + format="json", + ) + self.assertIn(resp.status_code, [400], resp.content) + self.assertEqual(Ticket.objects.filter(type__contains="_").count(), 0, data) + + def test_create_ticket_offerings_delay_drop(self): + self.client.login(username=self.user1.username, password="test") + + args = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ], + "drop_time": (timezone.now() + timezone.timedelta(hours=12)).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ), + } + _ = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + self.event1.refresh_from_db() + + # Drop time should be set + self.assertIsNotNone(self.event1.ticket_drop_time) + + # Drop time should be 12 hours from initial ticket creation + expected_drop_time = timezone.now() + timezone.timedelta(hours=12) + diff = abs(self.event1.ticket_drop_time - expected_drop_time) + self.assertTrue(diff < timezone.timedelta(minutes=5)) + + # Move Django's internal clock 13 hours forward + with freezegun.freeze_time(timezone.now() + timezone.timedelta(hours=13)): + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + # Tickets shouldn't be editable after drop time has elapsed + self.assertEqual(resp.status_code, 403, resp.content) + + def test_create_ticket_offerings_already_owned_or_held(self): + self.client.login(username=self.user1.username, password="test") + + # Create ticket offerings + args = { + "quantities": [ + {"type": "_normal", "count": 5, "price": 10}, + {"type": "_premium", "count": 3, "price": 20}, + ], + } + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Simulate checkout by applying holds + for ticket in Ticket.objects.filter(type="_normal"): + ticket.holder = self.user1 + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Simulate purchase by transferring ownership + for ticket in Ticket.objects.filter(type="_normal", holder=self.user1): + ticket.owner = self.user1 + ticket.holder = None + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + def test_issue_tickets(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user1.username, "ticket_type": "premium"}, + {"username": self.user2.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 200, resp.content) + + for item in args["tickets"]: + username, ticket_type = item["username"], item["ticket_type"] + user = get_user_model().objects.get(username=username) + + self.assertEqual( + Ticket.objects.filter(type=ticket_type, owner=user).count(), 1 + ) + self.assertTrue( + TicketTransactionRecord.objects.filter( + ticket__type=ticket_type, + ticket__owner=user, + total_amount=0.0, + ).exists() + ) + + def test_issue_tickets_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "normal"}, + ] + } + + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + + def test_issue_tickets_invalid_username_ticket_type(self): + # All usernames must be valid + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": "invalid_user_1", "ticket_type": "normal"}, + {"username": "invalid_user_2", "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_user_1", "invalid_user_2"]) + + # All requested ticket types must be valid + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "invalid_type_1"}, + {"username": self.user2.username, "ticket_type": "invalid_type_2"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_type_1", "invalid_type_2"]) + + def test_issue_tickets_insufficient_quantity(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "normal"} + for _ in range(100) + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn( + "Not enough tickets available for type: normal", str(resp.content) + ) + + # No tickets should be transferred + self.assertEqual(Ticket.objects.filter(owner=self.user2).count(), 0) + + # No holds should be given + self.assertEqual( + Ticket.objects.filter(type="normal", holder__isnull=False).count(), 0 + ) + + def test_get_tickets_information_no_tickets(self): + # Delete all the tickets + Ticket.objects.all().delete() + + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + data = resp.json() + self.assertEqual(data["totals"], [], data["totals"]) + self.assertEqual(data["available"], [], data["available"]) + + def test_get_tickets_information(self): + # Buy all normal tickets + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + data = resp.json() + self.assertEqual(data["totals"], self.ticket_totals, data["totals"]) + self.assertEqual( + data["available"], + # Only premium tickets available + [t for t in self.ticket_totals if t["type"] == "premium"], + data["available"], + ) + + def test_get_tickets_before_drop_time(self): + self.event1.ticket_drop_time = timezone.now() + timedelta(days=1) + self.event1.save() + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + + # Tickets shouldn't be available before the drop time + self.assertEqual(data["totals"], []) + self.assertEqual(data["available"], []) + + def test_get_tickets_buyers(self): + self.client.login(username=self.user1.username, password="test") + + # Buy all normal tickets + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)), + ) + + data = resp.json() + # Assert ownership correctly determined + for owned_ticket in data["buyers"]: + self.assertEqual(owned_ticket["owner_id"], self.user1.id) + + def test_get_tickets_buyers_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + for ticket in self.tickets1: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get( + reverse("club-events-buyers", args=(self.club1.code, self.event1.pk)), + ) + + self.assertEqual(resp.status_code, 403, resp) + + def test_add_to_cart(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + def test_add_to_cart_twice_accumulates(self): + self.client.login(username=self.user1.username, password="test") + + # Adding 3 tickets twice + for _ in range(2): + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse( + "club-events-add-to-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 6, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 4, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 2, cart.tickets) + + def test_add_to_cart_order_limit_exceeded(self): + self.client.login(username=self.user1.username, password="test") + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 200}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn( + "Order exceeds the maximum ticket limit of", resp.data["detail"], resp.data + ) + + def test_add_to_cart_tickets_unavailable(self): + self.client.login(username=self.user1.username, password="test") + + # Delete all but 1 ticket + for t in list(Ticket.objects.all())[:-1]: + t.delete() + + # Try to add two tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.data) + self.assertIn( + "Not enough tickets of type normal left!", resp.data["detail"], resp.data + ) + + def test_add_to_cart_before_ticket_drop(self): + self.client.login(username=self.user1.username, password="test") + + # Set drop time + self.event1.ticket_drop_time = timezone.now() + timedelta(hours=12) + self.event1.save() + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + + # Tickets should not be added to cart before drop time + self.assertEqual(resp.status_code, 403, resp.content) + + def test_remove_from_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + # Remove all but one from normal + tickets_to_remove = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + + resp = self.client.post( + reverse( + "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_remove, + format="json", + ) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.filter(type="normal").count(), 1, cart.tickets) + + def test_remove_from_cart_extra(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 3, cart.tickets) + self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) + self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + + # Remove more than what exists...still ok. + tickets_to_remove = { + "quantities": [ + {"type": "normal", "count": 200}, + {"type": "premium", "count": 100}, + ] + } + resp = self.client.post( + reverse( + "club-events-remove-from-cart", args=(self.club1.code, self.event1.pk) + ), + tickets_to_remove, + format="json", + ) + + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.get(owner=self.user1) + self.assertEqual(cart.tickets.count(), 0, cart.tickets) + + def test_delete_event_with_claimed_tickets(self): + # Simulate checkout (hold ticket) + self.tickets1[0].holder = self.user1 + self.tickets1[0].save() + + self.client.login(username=self.user1.username, password="test") + resp_held = self.client.delete( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)) + ) + self.assertEqual(resp_held.status_code, 400, resp_held.content) + + # Simulate purchase (transfer ticket) + self.tickets1[0].holder = None + self.tickets1[0].owner = self.user1 + self.tickets1[0].save() + + resp_bought = self.client.delete( + reverse("club-events-detail", args=(self.club1.code, self.event1.pk)) + ) + self.assertEqual(resp_bought.status_code, 400, resp_bought.content) + + +@dataclass +class MockPaymentResponse: + status: str = "AUTHORIZED" + reconciliation_id: str = "abced" + + +@contextmanager +def mock_cybersource_apis(): + """Mock cybersource APIs and validate_transient_token""" + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context, patch( + ".".join( + [ + "CyberSource", + "TransientTokenDataApi", + "get_transaction_for_transient_token", + ] + ) + ) as fake_get_transaction, patch( + ".".join( + [ + "CyberSource", + "PaymentsApi", + "create_payment", + ] + ) + ) as fake_create_payment, patch( + "clubs.views.validate_transient_token" + ) as fake_validate_tt: + fake_validate_tt.return_value = (True, "") + fake_cap_context.return_value = "abcde", 200, None + fake_get_transaction.return_value = ( + "", + 200, + json.dumps( + { + "orderInformation": { + "amountDetails": { + "totalAmount": 20, + }, + "billTo": { + "firstName": "Rohan", + "lastName": "Gupta", + "phoneNumber": "3021239234", + "email": "r@g.com", + }, + } + } + ), + ) + fake_create_payment.return_value = MockPaymentResponse(), 200, "" + yield ( + fake_cap_context, + fake_get_transaction, + fake_create_payment, + fake_validate_tt, + ) + + +class TicketTestCase(TestCase): + """ + Test cases related to the methods on the TicketViewSet + that correspond to the ticketing project: + + get, list, initiate_checkout, complete_checkout + """ + + def setUp(self): + commonSetUp(self) + + def test_get_ticket_owned_by_me(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + + # Fail to get when not owned + resp = self.client.get( + reverse("tickets-detail", args=(ticket.id,)), format="json" + ) + self.assertEqual(resp.status_code, 404, resp.content) + + # Succeed when owned + ticket.owner = self.user1 + ticket.save() + resp = self.client.get( + reverse("tickets-detail", args=(ticket.id,)), format="json" + ) + data = resp.json() + self.assertEqual(resp.status_code, 200, resp.content) + + # Test the serializer API + for field in ["price", "id", "type", "owner", "event"]: + self.assertIn(field, data, data) + + def test_list_tickets_owned_by_me(self): + self.client.login(username=self.user1.username, password="test") + + # Fail to get when not owned + resp = self.client.get(reverse("tickets-list"), format="json") + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(resp.json(), [], resp) + + # List all 5 tickets when owned + for ticket in self.tickets1[:5]: + ticket.owner = self.user1 + ticket.save() + + resp = self.client.get(reverse("tickets-list"), format="json") + data = resp.json() + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(len(data), 5, data) + + def test_get_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # None are sold out + self.assertEqual(len(data["tickets"]), 5, data) + for t1, t2 in zip(data["tickets"], tickets_to_add): + self.assertEqual(t1["id"], str(t2.id)) + self.assertEqual(len(data["sold_out"]), 0, data) + + def test_calculate_cart_total(self): + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + expected_total = sum(t.price for t in tickets_to_add) + + from clubs.views import TicketViewSet + + actual_total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(actual_total, expected_total) + + def test_calculate_cart_total_with_group_discount(self): + # Create tickets with group discount + tickets = [ + Ticket.objects.create( + type="group", + event=self.event1, + price=10.0, + group_size=2, + group_discount=0.2, + ) + for _ in range(10) + ] + + cart, _ = Cart.objects.get_or_create(owner=self.user1) + from clubs.views import TicketViewSet + + # Add 1 ticket, shouldn't activate group discount + cart.tickets.add(tickets[0]) + cart.save() + + total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(total, 10.0) # 1 * price=10 = 10 + + # Add 4 more tickets, enough to activate group discount + tickets_to_add = tickets[1:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + self.assertEqual(cart.tickets.count(), 5) + + total = TicketViewSet._calculate_cart_total(cart) + self.assertEqual(total, 40.0) # 5 * price=10 * (1 - group_discount=0.2) = 40 + + def test_get_cart_replacement_required(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # Sell the first two + for selling_ticket in tickets_to_add[:2]: + selling_ticket.owner = self.user2 + selling_ticket.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # The cart still has 5 tickets: just replaced with available ones + self.assertEqual(len(data["tickets"]), 5, data) + self.assertEqual(len(data["sold_out"]), 0, data) + + in_cart = set(map(lambda t: t["id"], data["tickets"])) + to_add = set(map(lambda t: str(t.id), tickets_to_add)) + + # 3 tickets are the same + self.assertEqual(len(in_cart & to_add), 3, in_cart | to_add) + + def test_get_cart_replacement_required_sold_out(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # There are 5 tickets in the cart. We will sell + # all but 3 tickets of this type to someone + # This should force 2 tickets reporting as sold out + for selling_ticket in self.tickets1[:-3]: + selling_ticket.owner = self.user2 + selling_ticket.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # The cart now has 3 tickets + self.assertEqual(len(data["tickets"]), 3, data) + + # Only 1 type of ticket should be sold out + self.assertEqual(len(data["sold_out"]), 1, data) + + # 2 normal tickets should be sold out + expected_sold_out = { + "type": self.tickets1[0].type, + "event": { + "id": self.tickets1[0].event.id, + "name": self.tickets1[0].event.name, + }, + "count": 2, + } + for key, val in expected_sold_out.items(): + self.assertEqual(data["sold_out"][0][key], val, data) + + # 0 tickets are the same (we sell all but last 3) + in_cart = set(map(lambda t: t["id"], data["tickets"])) + to_add = set(map(lambda t: str(t.id), tickets_to_add)) + self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add) + + def test_initiate_checkout_non_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate checkout + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + # No free tickets should be sold + self.assertFalse(resp.data["sold_free_tickets"]) + + # Capture context should be tied to cart + cart = Cart.objects.filter(owner=self.user1).first() + self.assertIsNotNone(cart.checkout_context) + self.assertEqual(cart.checkout_context, cap_context_data) + + # Tickets should be held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets) + self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets) + + def test_initiate_checkout_free_and_non_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + Ticket.objects.create(type="free", event=self.event1, price=0.0) + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "free", "count": 1}, + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate checkout + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + # Free ticket should be sold with non-free tickets if purchased together + self.assertFalse(resp.data["sold_free_tickets"]) + + # Capture context should be tied to cart + cart = Cart.objects.filter(owner=self.user1).first() + self.assertIsNotNone(cart.checkout_context) + self.assertEqual(cart.checkout_context, cap_context_data) + + # Tickets should be held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 3, held_tickets) + self.assertEqual(held_tickets.filter(type="free").count(), 1, held_tickets) + self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets) + self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets) + + def test_initiate_checkout_only_free_tickets(self): + self.client.login(username=self.user1.username, password="test") + + tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(3)] + Ticket.objects.bulk_create(tickets) + + # Add a few free tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "free", "count": 3}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate checkout + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + cap_context_data = "abcde" + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + # check that free tickets were sold + self.assertTrue(resp.data["sold_free_tickets"]) + + # Ownership transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 3, owned_tickets) + + # Cart empty + user_cart = Cart.objects.get(owner=self.user1) + self.assertEqual(user_cart.tickets.count(), 0, user_cart) + + # Tickets held is 0 + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + # Transaction record created + record_exists = TicketTransactionRecord.objects.filter( + reconciliation_id="None" + ).exists() + self.assertTrue(record_exists) + + def test_initiate_concurrent_checkouts(self): + self.client.login(username=self.user1.username, password="test") + + # Add tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Initiate first checkout + cap_context_data = "abc" + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_1 = cart.checkout_context + + # Initiate second checkout + cap_context_data = "def" # simulate capture context changing between checkouts + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = cap_context_data, 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + cart = Cart.objects.filter(owner=self.user1).first() + cap_context_2 = cart.checkout_context + + # Stored capture context should change between checkouts + self.assertNotEqual(cap_context_1, cap_context_2) + + def test_initiate_checkout_fails_with_empty_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Assert non existent cart + cart, created = Cart.objects.get_or_create(owner=self.user1) + self.assertTrue(created) + + # Initiate checkout, fail with 400 + # NOTE: If the cart does not exist, we will have a 404 + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = "abcde", 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertEquals(resp.status_code, 400, resp.content) + + # Tickets are not held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertFalse(held_tickets.exists()) + + def test_initiate_checkout_stale_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # In the meantime, someone snipes a ticket we added by holding + sniped_ticket = self.tickets1[0] + sniped_ticket.holder = self.user2 + sniped_ticket.save() + + # Initiate checkout for the first time + with patch( + ".".join( + [ + "CyberSource", + "UnifiedCheckoutCaptureContextApi", + "generate_unified_checkout_capture_context_with_http_info", + ] + ) + ) as fake_cap_context: + fake_cap_context.return_value = "abcde", 200, None + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn("Cart is stale", resp.data["detail"], resp.data) + + # Tickets are not held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + # Ok, so now we call /api/tickets/cart to refresh + resp = self.client.get(reverse("tickets-cart"), format="json") + + # Initiate checkout again...this should work + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Tickets are held + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertNotIn(sniped_ticket, held_tickets, held_tickets) + self.assertEqual(held_tickets.count(), 5, held_tickets) + + def test_complete_checkout(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + {"type": "premium", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + with mock_cybersource_apis(): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + # Complete checkout + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertIn("Payment successful", resp.data["detail"], resp.data) + + # Ownership transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 2, owned_tickets) + + # Cart empty + user_cart = Cart.objects.get(owner=self.user1) + self.assertEqual(user_cart.tickets.count(), 0, user_cart) + + # Tickets held is 0 + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + # Transaction record created + record_exists = TicketTransactionRecord.objects.filter( + reconciliation_id=MockPaymentResponse().reconciliation_id + ).exists() + self.assertTrue(record_exists) + + def test_complete_checkout_stale_cart(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis(): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + # Make holds expire prematurely, creating a stale cart + for ticket in held_tickets: + ticket.holding_expiration = timezone.now() - timedelta(minutes=1) + ticket.save() + + # Invoking this API endpoint causes all holds to be expired + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn("Cart is stale", resp.data["detail"], resp.content) + + # Ownership not transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + def test_complete_checkout_validate_token_fails(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis() as (_, _, _, fake_validate_token): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + fake_validate_token.return_value = (False, "Validation failed") + + # Try to complete + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + + # Fails because token validation failed + self.assertEqual(resp.status_code, 500, resp.content) + self.assertIn("Validation failed", resp.data["detail"], resp.content) + + # Ownership not transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + # Hold cancelled + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + def test_complete_checkout_cybersource_fails(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets to cart + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:2] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + with mock_cybersource_apis() as (_, fake_create_payment, _, _): + # Initiate checkout + resp = self.client.post(reverse("tickets-initiate-checkout")) + self.assertIn(resp.status_code, [200, 201], resp.content) + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 2, held_tickets) + + fake_create_payment.return_value = ( + MockPaymentResponse(status="UNAUTHORIZED"), + 400, + "", + ) + + # Try to complete + resp = self.client.post( + reverse("tickets-complete-checkout"), + {"transient_token": "abcdefg"}, + format="json", + ) + + # Fails because cybersource fails + self.assertEqual(resp.status_code, 500, resp.content) + self.assertIn("Transaction failed", resp.data["detail"], resp.content) + self.assertIn("HTTP status 400", resp.data["detail"], resp.content) + + # Ownership not transferred + owned_tickets = Ticket.objects.filter(owner=self.user1) + self.assertEqual(owned_tickets.count(), 0, owned_tickets) + + # Hold cancelled + held_tickets = Ticket.objects.filter(holder=self.user1) + self.assertEqual(held_tickets.count(), 0, held_tickets) + + def test_transfer_ticket(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + + # fail to transfer when not owned + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + self.assertEqual(resp.status_code, 404, resp.content) + + ticket.owner = self.user1 + ticket.save() + + # successful transfer when owned + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + ticket.refresh_from_db() + + self.assertEqual(resp.status_code, 200, resp.content) + self.assertEqual(ticket.owner, self.user2, ticket.owner) + + def test_transfer_non_transferable_ticket(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.transferable = False + ticket.save() + + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user2.username}, + format="json", + ) + ticket.refresh_from_db() + + self.assertEqual(resp.status_code, 403, resp.content) + self.assertEqual(ticket.owner, self.user1, ticket.owner) + + def test_transfer_ticket_to_self(self): + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + + resp = self.client.post( + reverse("tickets-transfer", args=(ticket.id,)), + {"username": self.user1.username}, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + def test_update_attendance(self): + self.client.login(username=self.user1.username, password="test") + Membership.objects.create( + person=self.user1, + club=self.club1, + title="Officer", + role=Membership.ROLE_OFFICER, + ) + ticket = self.tickets1[0] + ticket.owner = self.user2 + ticket.save() + + resp = self.client.patch( + reverse("tickets-detail", args=(ticket.id,)), + {"attended": True}, + format="json", + ) + ticket.refresh_from_db() + self.assertEqual(resp.status_code, 200, resp.content) + self.assertTrue(ticket.attended) + + def test_update_attendance_non_officer(self): + # user1 is no longer an officer for the ticket's club + self.client.login(username=self.user1.username, password="test") + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + + resp = self.client.patch( + reverse("tickets-detail", args=(ticket.id,)), + {"attended": True}, + format="json", + ) + ticket.refresh_from_db() + self.assertEqual(resp.status_code, 404, resp.content) + self.assertFalse(ticket.attended) + + +class TicketModelTestCase(TestCase): + """ + Test cases related to the models that correspond to the ticketing project: + Ticket, TicketTransactionRecord, TicketTransferRecord + """ + + def setUp(self): + commonSetUp(self) + + def test_delete_ticket_after_purchase(self): + ticket = self.tickets1[0] + ticket.owner = self.user1 + ticket.save() + TicketTransactionRecord.objects.create( + ticket=ticket, + buyer_first_name=self.user1.first_name, + buyer_last_name=self.user2.last_name, + total_amount=ticket.price, + ) + + with self.assertRaises(ProtectedError): + ticket.delete() + + def test_delete_ticket_after_transfer(self): + ticket = self.tickets1[0] + ticket.owner = self.user2 + ticket.save() + TicketTransferRecord.objects.create( + ticket=ticket, sender=self.user1, receiver=self.user2 + ) + + with self.assertRaises(ProtectedError): + ticket.delete() diff --git a/frontend/components/BaseLayout.tsx b/frontend/components/BaseLayout.tsx new file mode 100644 index 000000000..f7862138a --- /dev/null +++ b/frontend/components/BaseLayout.tsx @@ -0,0 +1,51 @@ +import { PropsWithChildren } from 'react' +import { ToastContainer } from 'react-toastify' +import styled from 'styled-components' + +import AuthPrompt from '~/components/common/AuthPrompt' +import Footer from '~/components/Footer' +import Header from '~/components/Header' +import { NAV_HEIGHT, SNOW } from '~/constants' +import { GlobalStyle, RenderPageWrapper, ToastStyle } from '~/renderPage' +import { PermissionsContext } from '~/utils' +import { createBasePropFetcher } from '~/utils/getBaseProps' + +type BaseLayoutProps = Awaited< + ReturnType> +> & { + authRequired?: boolean +} + +export const Wrapper = styled.div` + min-height: calc(100vh - ${NAV_HEIGHT}); + background: ${SNOW}; +` + +export const BaseLayout: React.FC> = ({ + auth, + permissions, + children, + authRequired, +}) => { + const authError = !!(authRequired && !auth.authenticated) + return ( + <> + + +
+ {authError ? : {children}} +