diff --git a/bundles/admin/donationProcessing/processPendingBids.tsx b/bundles/admin/donationProcessing/processPendingBids.tsx index b00a328b4..d394bb99d 100644 --- a/bundles/admin/donationProcessing/processPendingBids.tsx +++ b/bundles/admin/donationProcessing/processPendingBids.tsx @@ -65,9 +65,13 @@ export default React.memo(function ProcessPendingBids() { error: bidError, refetch: refetchBids, isFetching: bidFetching, - } = useBidTreeQuery({ - urlParams: { ...eventFilter, feed: 'pending' }, - }); + } = useBidTreeQuery( + { + urlParams: { ...eventFilter, feed: 'pending' }, + listen: true, + }, + {}, + ); const { data: event, error: eventError, diff --git a/bundles/admin/totalWatch.tsx b/bundles/admin/totalWatch.tsx index e6d969cf6..845ed2458 100644 --- a/bundles/admin/totalWatch.tsx +++ b/bundles/admin/totalWatch.tsx @@ -403,10 +403,18 @@ export default React.memo(function TotalWatch() { )} {bid.options - ?.toSorted((a, b) => b.total - a.total) + ?.toSorted((a, b) => { + if (a.state === 'OPENED' && b.state !== 'OPENED') { + return -1; + } + if (b.state === 'OPENED' && a.state !== 'OPENED') { + return 1; + } + return b.total - a.total; + }) .map(o => (

- {o.name} ${format.format(o.total)} {bid.allowuseroptions && `(${o.state})`} + {o.name} ${format.format(o.total)} {`(${o.state})`}

))} diff --git a/bundles/processing/modules/donations/DonationRow.tsx b/bundles/processing/modules/donations/DonationRow.tsx index ad7b5f2ce..f90f5a3bb 100644 --- a/bundles/processing/modules/donations/DonationRow.tsx +++ b/bundles/processing/modules/donations/DonationRow.tsx @@ -92,6 +92,8 @@ export default function DonationRow(props: DonationRowProps) { {donation.donor_name || UNKNOWN_DONOR_NAME} {donation.pinned && } + {' ยท '} + {donation.domain} ); diff --git a/bundles/processing/modules/settings/PrimaryNavPopout.tsx b/bundles/processing/modules/settings/PrimaryNavPopout.tsx index c1be4fac8..67956046d 100644 --- a/bundles/processing/modules/settings/PrimaryNavPopout.tsx +++ b/bundles/processing/modules/settings/PrimaryNavPopout.tsx @@ -27,7 +27,7 @@ const NavRoutes = { MILESTONES: (eventId: string | number) => `/tracker/milestones/${eventId}`, PRIZES: (eventId: string | number) => `/tracker/prizes/${eventId}`, RUNS: (eventId: string | number) => `/tracker/runs/${eventId}`, - LOGOUT: `/tracker/user/logout/`, + LOGOUT: `/tracker/user/logout_form/`, SELF_SERVICE: `/tracker/user/index/`, ADMIN_HOME: `/`, diff --git a/bundles/public/apiv2/APITypes.ts b/bundles/public/apiv2/APITypes.ts index fb91243e3..b1a0c0ad6 100644 --- a/bundles/public/apiv2/APITypes.ts +++ b/bundles/public/apiv2/APITypes.ts @@ -190,8 +190,17 @@ export interface DonationPost { bids: DonationPostBid[]; domain?: DonationDomain; // defaults to 'LOCAL' // only with creation permission + domain_id?: string; // required for 'TWITCH' donations donor_email?: string; donor_id?: number; + donor_twitch_id?: number; +} + +export interface DonationBidPost { + bid?: number; + amount: number; + parent?: number; + name?: string; } export interface APIRun diff --git a/bundles/public/apiv2/Endpoints.ts b/bundles/public/apiv2/Endpoints.ts index 6c8e2ebbc..2db8e5a80 100644 --- a/bundles/public/apiv2/Endpoints.ts +++ b/bundles/public/apiv2/Endpoints.ts @@ -28,6 +28,7 @@ function appendTree(path: string, tree?: number | { tree?: boolean }) { } const Endpoints = { + DONATE: 'donate/', DONATIONS: (params: number | { eventId?: number; state?: DonationState } = {}) => appendState(prependEvent('donations', params), params), DONATIONS_UNPROCESS: (donationId: number) => `donations/${donationId}/unprocess/`, diff --git a/bundles/public/apiv2/Models.ts b/bundles/public/apiv2/Models.ts index 999cce9f9..3a9b58b29 100644 --- a/bundles/public/apiv2/Models.ts +++ b/bundles/public/apiv2/Models.ts @@ -83,7 +83,7 @@ export interface Event extends ModelBase { } export type DonationTransactionState = 'COMPLETED' | 'PENDING' | 'CANCELLED' | 'FLAGGED'; -export type DonationDomain = 'PAYPAL' | 'LOCAL' | 'CHIPIN'; +export type DonationDomain = 'PAYPAL' | 'LOCAL' | 'CHIPIN' | 'TWITCH'; export type DonationReadState = 'PENDING' | 'READY' | 'IGNORED' | 'READ' | 'FLAGGED'; export type DonationCommentState = 'ABSENT' | 'PENDING' | 'DENIED' | 'APPROVED' | 'FLAGGED'; diff --git a/bundles/tracker/donation/__tests__/Donate.spec.tsx b/bundles/tracker/donation/__tests__/Donate.spec.tsx index a09c0847c..556ce2db9 100644 --- a/bundles/tracker/donation/__tests__/Donate.spec.tsx +++ b/bundles/tracker/donation/__tests__/Donate.spec.tsx @@ -7,6 +7,7 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Constants, { DefaultConstants } from '@common/Constants'; +import { DonationPost } from '@public/apiv2/APITypes'; import Endpoints from '@public/apiv2/Endpoints'; import HTTPUtils from '@public/apiv2/HTTPUtils'; import { setRoot } from '@public/apiv2/reducers/apiRoot'; @@ -37,36 +38,45 @@ const renderDonate = async () => { const getSubmitButton = () => rendered.getByTestId('donation-submit') as HTMLButtonElement; const getSubmitBidButton = () => rendered.getByTestId('incentiveBidForm-submitBid') as HTMLButtonElement; + const submitForm = async (mock: MockAdapter, payload: Partial) => { + // TODO: filling in the bid amount doesn't seem to be getting picked up by ReactNumeric even with userEvent + expect(getSubmitButton().disabled).toBe(false); + + expect(HTMLFormElement.prototype.submit).not.toHaveBeenCalled(); + + await act(() => fireEvent.click(getSubmitButton())); + + expect(mock.history.post.length).toBe(1); + const post = mock.history.post[0]; + expect(post?.url).toEqual('/' + Endpoints.DONATE); + expect(JSON.parse(post?.data)).toEqual(jasmine.objectContaining({ event: eventId, ...payload })); + expect(HTMLFormElement.prototype.submit).toHaveBeenCalled(); + }; + const fillField = async (fieldLabel: string | RegExp, value: string) => { const input = rendered.getByLabelText(fieldLabel); - if (fieldLabel.toString().includes('amount')) { - // ReactNumeric does not like fireEvent - await act(() => userEvent.type(input, value)); - } else { - await act(() => fireEvent.change(input, { target: value })); - } + await act(() => userEvent.type(input, value)); }; const addIncentive = async () => { - const addButton = getAddIncentivesButton(); - await act(() => fireEvent.click(addButton)); + await act(() => fireEvent.click(getAddIncentivesButton())); }; - const fillBid = async (incentiveId: string, bid: { choiceId?: string; amount?: number; custom?: string }) => { + const fillBid = async ( + incentiveId: string | number | undefined, + bid: { choiceId?: string; amount?: number; custom?: string }, + ) => { + expect(incentiveId).toBeDefined(); await act(() => fireEvent.click(rendered.getByTestId(`incentiveform-incentive-${incentiveId}`))); if (bid.amount != null) { await fillField(/Amount to put towards incentive/i, bid.amount.toString()); } if (bid.custom != null) { - await act(() => { - const customOption = rendered.getByTestId('incentiveBidNewOption'); - fireEvent.click(customOption); - }); - await act(() => { - const customInput = rendered.getByTestId('incentiveBidCustomOption'); - fireEvent.change(customInput, { target: { value: bid.custom } }); - }); + await act(() => fireEvent.click(rendered.getByTestId('incentiveBidNewOption'))); + await act(() => + fireEvent.change(rendered.getByTestId('incentiveBidCustomOption'), { target: { value: bid.custom } }), + ); } }; @@ -89,6 +99,7 @@ const renderDonate = async () => { return { ...rendered, getSubmitButton, + submitForm, getAddIncentivesButton, fillField, addIncentive, @@ -100,19 +111,23 @@ const renderDonate = async () => { describe('Donate', () => { let mock: MockAdapter; + let bids: ReturnType; beforeAll(() => { mock = new MockAdapter(HTTPUtils.getInstance(), { onNoMatch: 'throwException' }); }); beforeEach(() => { + bids = getFixtureMixedBidsTree(); store.dispatch(setRoot({ root: '//testserver/', limit: 500, csrfToken: 'deadbeef' })); store.dispatch(trackerApi.util.resetApiState()); mock.reset(); mock.onGet('//testserver/' + Endpoints.EVENTS).reply(() => [200, getFixturePagedEvent({ id: eventId })]); + mock.onGet('//testserver/' + Endpoints.BIDS({ eventId: 2, feed: 'open', tree: true })).reply(() => [200, bids]); mock - .onGet('//testserver/' + Endpoints.BIDS({ eventId: 2, feed: 'open', tree: true })) - .reply(() => [200, getFixtureMixedBidsTree({})]); + .onPost('//testserver/' + Endpoints.DONATE) + .reply(() => [200, { confirm_url: `//${window.location.host}/paypal_confirm` }]); + spyOn(HTMLFormElement.prototype, 'submit'); }); afterAll(() => { @@ -126,30 +141,39 @@ describe('Donate', () => { }); it('is submittable with just an amount set', async () => { - const { getSubmitButton, fillField } = await renderDonate(); + const { submitForm, fillField } = await renderDonate(); await fillField(/amount/i, '10'); - - expect(getSubmitButton().disabled).toBe(false); + await submitForm(mock, { amount: 10, requested_email: '', comment: '', requested_alias: '' }); }); it('is submittable with no alias set', async () => { - const { getSubmitButton, fillField } = await renderDonate(); + const { submitForm, fillField } = await renderDonate(); await fillField(/email/i, 'someone@example.com'); await fillField(/amount/i, '10'); - - expect(getSubmitButton().disabled).toBe(false); + await submitForm(mock, { + amount: 10, + requested_email: 'someone@example.com', + comment: '', + requested_alias: '', + }); }); it('is submittable with all donation fields filled out', async () => { - const { getSubmitButton, fillField } = await renderDonate(); + const { submitForm, fillField } = await renderDonate(); await fillField(/alias/i, 'my name'); await fillField(/email/i, 'someone@example.com'); await fillField(/amount/i, '10'); await fillField(/comment/i, 'got a comment here'); - - expect(getSubmitButton().disabled).toBe(false); + await submitForm(mock, { + amount: 10, + requested_email: 'someone@example.com', + comment: 'got a comment here', + requested_alias: 'my name', + }); }); + it('redirects to confirmation url when request is successful', async () => {}); + describe('adding incentives', () => { it('is disabled with no amount set', async () => { const { getAddIncentivesButton } = await renderDonate(); @@ -165,25 +189,31 @@ describe('Donate', () => { }); it('works with a valid bid', async () => { - const { addIncentive, fillField, fillBid, getSubmitButton, submitBid } = await renderDonate(); + const { addIncentive, fillField, fillBid, submitBid, submitForm } = await renderDonate(); await fillField(/amount/i, '10'); + const challengeId = bids.results.find(b => b.bid_type === 'challenge')?.id; + await addIncentive(); - await fillBid('121', { amount: 4.2 }); + await fillBid(challengeId, { amount: 4.2 }); await submitBid(); - expect(getSubmitButton().disabled).toBe(false); + // FIXME: amount change not getting picked up by ReactNumeric in test environment + await submitForm(mock, { bids: [{ id: challengeId!, amount: 10 }] }); }); it('works with a custom bid option', async () => { - const { addIncentive, fillField, fillBid, getSubmitButton, submitBid } = await renderDonate(); + const { addIncentive, fillField, fillBid, submitBid, submitForm } = await renderDonate(); await fillField(/amount/i, '10'); + const choiceId = bids.results.find(b => b.bid_type === 'choice')?.id; + await addIncentive(); - await fillBid('122', { choiceId: '3', amount: 3.7, custom: 'idk' }); + await fillBid(choiceId, { choiceId: '3', amount: 3.7, custom: 'idk' }); await submitBid(); - expect(getSubmitButton().disabled).toBe(false); + // FIXME: amount change not getting picked up by ReactNumeric in test environment + await submitForm(mock, { bids: [{ parent: choiceId!, amount: 10, name: 'idk' }] }); }); it('can remove added bids', async () => { diff --git a/bundles/tracker/donation/components/Donate.tsx b/bundles/tracker/donation/components/Donate.tsx index cc035f9d4..f0a694c4f 100644 --- a/bundles/tracker/donation/components/Donate.tsx +++ b/bundles/tracker/donation/components/Donate.tsx @@ -98,6 +98,13 @@ function Internal({ event }: { event: Event }) { } }, [confirmUrl]); + const [error, setError] = React.useState(null); + + if (error) { + // thrown here so that it tears down the entire tree to make it obvious something is very wrong + throw new Error(error); + } + const handleSubmit = React.useCallback(async () => { if (errors == null && donation.amount) { const { data } = await donate({ ...donation, amount: donation.amount, event: event.id }); @@ -107,8 +114,8 @@ function Internal({ event }: { event: Event }) { setConfirmUrl(url.toString()); } else { // this is a serious misconfiguration issue - throw new Error( - `confirmation url and window url origin did not match: ${url.origin} !== ${window.location.origin}`, + setError( + `confirmation url and window url origin did not match, server configuration is incorrect\nExpected: ${window.location.origin}\nActual: ${url.origin}`, ); } } else { diff --git a/bundles/tracker/donation/components/DonationBidForm.tsx b/bundles/tracker/donation/components/DonationBidForm.tsx index da623f198..a0ee34e01 100644 --- a/bundles/tracker/donation/components/DonationBidForm.tsx +++ b/bundles/tracker/donation/components/DonationBidForm.tsx @@ -136,17 +136,20 @@ const DonationBidForm = (props: DonationBidFormProps) => { max={remainingDonationTotal} /> - {incentive.options?.toSorted(compareBidChild).map(option => ( - - {option.name} - {eventCurrency(option.total)} - - ))} + {incentive.options?.toSorted(compareBidChild).map( + option => + option.state === 'OPENED' && ( + + {option.name} + {eventCurrency(option.total)} + + ), + )} {incentive.allowuseroptions && ( <> diff --git a/bundles/tracker/prizes/components/PrizeDetail.tsx b/bundles/tracker/prizes/components/PrizeDetail.tsx index 82a92804d..70dfe0c46 100644 --- a/bundles/tracker/prizes/components/PrizeDetail.tsx +++ b/bundles/tracker/prizes/components/PrizeDetail.tsx @@ -171,7 +171,7 @@ const PrizeDetail = (props: PrizeProps) => { {event != null ? ( - {event.name} + {event.name}  ·  diff --git a/bundles/tracker/router/RouterUtils.ts b/bundles/tracker/router/RouterUtils.ts index 0fb776a4a..b058f5cd0 100644 --- a/bundles/tracker/router/RouterUtils.ts +++ b/bundles/tracker/router/RouterUtils.ts @@ -6,9 +6,9 @@ export const Routes = { // TODO: This URL is currently inverted as other parts of the tracker have // expect it to be in this format. Once those dependencies can be updated, // this can change to match normal REST structure. - EVENT_DONATE: (eventId: string | number) => `/donate/${eventId}`, - EVENT_PRIZES: (eventId: string | number) => Routes.EVENT_BASE(eventId) + '/prizes', - EVENT_PRIZE: (eventId: string | number, prizeId: number) => Routes.EVENT_BASE(eventId) + `/prizes/${prizeId}`, + EVENT_DONATE: (eventId: string | number) => `${Routes.EVENT_BASE(eventId)}/donate`, + EVENT_PRIZES: (eventId: string | number) => `${Routes.EVENT_BASE(eventId)}/prizes`, + EVENT_PRIZE: (eventId: string | number, prizeId: number) => `${Routes.EVENT_BASE(eventId)}/prizes/${prizeId}`, }; type NavigateOptions = { diff --git a/tests/apiv2/test_donate.py b/tests/apiv2/test_donate.py index 81e80f4fd..5695d0eb8 100644 --- a/tests/apiv2/test_donate.py +++ b/tests/apiv2/test_donate.py @@ -12,6 +12,8 @@ class TestDonate(APITestCase): def setUp(self): super().setUp() + self.event.minimumdonation = 5 + self.event.save() self.opened_challenge = models.Bid.objects.create( event=self.event, name='Challenge', goal=1000, istarget=True, state='OPENED' ) @@ -136,6 +138,14 @@ def test_donate(self): expected_error_codes={'domain': 'invalid'}, ) + with self.subTest('twitch without uuid'): + self.post_new( + data={**valid, 'domain': 'TWITCH'}, + status_code=400, + model_name='donate', + expected_error_codes={'domain_id': 'invalid'}, + ) + with self.subTest('other domain'): self.post_new( data={**valid, 'domain': 'CHIPIN'}, @@ -371,6 +381,7 @@ def test_donate(self): ) donation = models.Donation.objects.get(id=response['id']) self.assertV2ModelPresent(DonationSerializer(donation).data, response) + self.assertEqual(donation.donor, self.donor) response = self.post_new( data={ @@ -386,3 +397,74 @@ def test_donate(self): ) donation = models.Donation.objects.get(id=response['id']) self.assertV2ModelPresent(DonationSerializer(donation).data, response) + self.assertEqual(donation.donor, self.donor) + + with self.subTest('create twitch'), self.saveSnapshot(): + with self.subTest('without payload'): + response = self.post_new( + data={ + **valid, + 'amount': 1, # twitch donations can be as small as a dollar + # FIXME: what to do about hidden bids? + 'bids': [], + 'domain': 'TWITCH', + 'domain_id': 'some-twitch-uuid', + 'requested_alias': 'Kappa', + 'donor_twitch_id': 12345678, + }, + model_name='donate', + status_code=201, + user=self.add_user, + ) + donation = models.Donation.objects.get(id=response['id']) + self.assertV2ModelPresent(DonationSerializer(donation).data, response) + self.assertEqual(donation.donor.twitch_id, 12345678) + self.assertEqual(donation.donor.email, '12345678@users.twitch.tv.fake') + self.assertEqual(donation.donor.alias, 'Kappa') + self.assertEqual(donation.donor.visibility, 'ALIAS') + self.assertEqual(donation.domainId, 'some-twitch-uuid'), + self.assertEqual(donation.requestedalias, 'Kappa') + self.assertEqual(donation.requestedvisibility, 'ALIAS') + self.assertEqual(donation.transactionstate, 'COMPLETED') + + with self.subTest('with payload'): + twitch_payload = dict( + id='a1b2c3-aabb-4455-d1e2f3', + campaign_id='123-abc-456-def', + broadcaster_user_id='123456', + broadcaster_user_name='SunnySideUp', + broadcaster_user_login='sunnysideup', + user_id='654321', + user_login='generoususer1', + user_name='GenerousUser1', + charity_name='Example name', + charity_description='Example description', + charity_logo='https://abc.cloudfront.net/ppgf/1000/100.png', + charity_website='https://www.example.com', + amount=dict(value=10000, decimal_places=2, currency='USD'), + ) + response = self.post_new( + data=dict( + event=valid['event'], + twitch=twitch_payload, + ), + model_name='donate', + status_code=201, + user=self.add_user, + ) + donation = models.Donation.objects.get(id=response['id']) + self.assertV2ModelPresent(DonationSerializer(donation).data, response) + self.assertEqual( + donation.donor.twitch_id, int(twitch_payload['user_id']) + ) + self.assertEqual( + donation.donor.email, + f"{int(twitch_payload['user_id'])}@users.twitch.tv.fake", + ) + self.assertEqual(donation.donor.alias, twitch_payload['user_name']) + self.assertEqual(donation.donor.visibility, 'ALIAS') + self.assertEqual(donation.domainId, twitch_payload['id']) + self.assertEqual(donation.requestedalias, twitch_payload['user_name']) + self.assertEqual(donation.requestedvisibility, 'ALIAS') + self.assertEqual(donation.transactionstate, 'COMPLETED') + self.assertEqual(donation.amount, 100) diff --git a/tests/apiv2/test_donation_bids.py b/tests/apiv2/test_donation_bids.py index 4bcb7be93..106378a3f 100644 --- a/tests/apiv2/test_donation_bids.py +++ b/tests/apiv2/test_donation_bids.py @@ -3,6 +3,7 @@ from tests import randgen from tests.util import APITestCase from tracker import models +from tracker.api import messages from tracker.api.serializers import DonationBidSerializer @@ -10,6 +11,7 @@ class TestDonationBids(APITestCase): model_name = 'donationbid' serializer_class = DonationBidSerializer extra_serializer_kwargs = {'with_permissions': 'tracker.view_bid'} + add_user_permissions = ['add_donationbid'] view_user_permissions = ['view_bid'] def _format_donation_bid(self, bid): @@ -54,6 +56,12 @@ def setUp(self): self.rand, domain='PAYPAL', transactionstate='PENDING' ) self.pending_donation.save() + self.blank_donation = randgen.generate_donation(self.rand, event=self.event) + self.blank_donation.save() + self.archived_donation = randgen.generate_donation( + self.rand, event=self.archived_event + ) + self.archived_donation.save() self.opened_bid = randgen.generate_bid( self.rand, event=self.event, @@ -66,6 +74,10 @@ def setUp(self): self.rand, parent=self.opened_bid, state='OPENED', allow_children=False )[0] self.opened_child.save() + self.second_child = randgen.generate_bid( + self.rand, parent=self.opened_bid, state='OPENED', allow_children=False + )[0] + self.second_child.save() self.denied_child = randgen.generate_bid( self.rand, parent=self.opened_bid, state='DENIED', allow_children=False )[0] @@ -256,6 +268,153 @@ def test_fetch(self): user=None, status_code=404, ) + self.get_noun( + 'donations', + model_name='bid', + kwargs={'pk': 5000}, + status_code=404, + ) + self.get_noun( + 'bids', + model_name='donation', + kwargs={'pk': 5000}, + status_code=404, + ) + + def test_create(self): + with self.saveSnapshot(): + with self.subTest('via donation'): + with self.subTest('duplicate name'): + response = self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'parent': self.opened_bid.id, + 'amount': float(self.blank_donation.amount), + 'name': self.opened_child.name, + }, + model_name='donation', + user=self.add_user, + ) + bid = models.DonationBid.objects.get(pk=response['id']) + # FIXME + response['bid_total'] = float( + Decimal(response['bid_total']).quantize(Decimal('0.00')) + ) + response['bid_name'] = f'{bid.bid.parent.name} -- {bid.bid.name}' + self.assertExactV2Models([bid], response) + self.assertEqual(bid.bid.state, 'OPENED') + bid.delete() + + with self.subTest('new name'): + response = self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'parent': self.opened_bid.id, + 'amount': float(self.blank_donation.amount), + 'name': 'New Name', + }, + model_name='donation', + user=self.add_user, + ) + bid = models.DonationBid.objects.get(pk=response['id']) + # FIXME + response['bid_total'] = float( + Decimal(response['bid_total']).quantize(Decimal('0.00')) + ) + response['bid_name'] = f'{bid.bid.parent.name} -- {bid.bid.name}' + self.assertExactV2Models([bid], response) + self.assertTrue(bid.bid.istarget) + self.assertEqual(bid.bid.state, 'PENDING') + bid.delete() + + with self.subTest('existing bid'): + response = self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'bid': self.opened_child.id, + 'amount': float(self.blank_donation.amount), + }, + model_name='donation', + user=self.add_user, + ) + bid = models.DonationBid.objects.get(pk=response['id']) + # FIXME + response['bid_total'] = float( + Decimal(response['bid_total']).quantize(Decimal('0.00')) + ) + response['bid_name'] = f'{bid.bid.parent.name} -- {bid.bid.name}' + self.assertExactV2Models([bid], response) + + with self.subTest('error cases'): + # duplicate allocation + self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'bid': self.opened_child.id, + 'amount': 100, + }, + model_name='donation', + user=self.add_user, + status_code=400, + expected_error_codes='unique_together', + ) + # already allocated + self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'bid': self.second_child.id, + 'amount': 100, + }, + model_name='donation', + user=self.add_user, + status_code=400, + expected_error_codes='invalid', + ) + # already allocated + self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={ + 'parent': self.opened_bid.id, + 'name': 'Another New Name', + 'amount': 100, + }, + model_name='donation', + user=self.add_user, + status_code=400, + expected_error_codes='invalid', + ) + self.post_noun( + 'bids', + kwargs={'pk': 5000}, + data={}, + model_name='donation', + user=self.add_user, + status_code=404, + expected_error_codes='not_found', + ) + self.post_noun( + 'bids', + kwargs={'pk': self.archived_donation.id}, + data={}, + model_name='donation', + user=self.add_user, + status_code=403, + expected_error_codes=messages.ARCHIVED_EVENT_CODE, + ) + self.post_noun( + 'bids', + kwargs={'pk': self.blank_donation.id}, + data={}, + model_name='donation', + user=None, + status_code=403, + ) def test_serializer(self): with self.assertRaises(AssertionError): diff --git a/tests/apiv2/test_runs.py b/tests/apiv2/test_runs.py index 7a6ddff3a..e38d77304 100644 --- a/tests/apiv2/test_runs.py +++ b/tests/apiv2/test_runs.py @@ -877,6 +877,17 @@ def test_interstitial_anchor_required(self): expected_error_codes={'interstitial': 'invalid'}, ) + def test_prize_restrictions_and_signals(self): + models.Prize.objects.create( + event=self.event, startrun=self.run2, endrun=self.run3 + ) + self.assertResults( + self.run2, + order=None, + expected_status_code=400, + expected_error_codes={'order': 'invalid'}, + ) + def test_suborder_collision(self): # interview is anchored to 3, ad is order 1, both have suborder 1 self.assertResults( diff --git a/tests/randgen.py b/tests/randgen.py index bf83a7c6d..4fb5527a3 100644 --- a/tests/randgen.py +++ b/tests/randgen.py @@ -523,10 +523,10 @@ def generate_donation( donation.timereceived = random_time(rand, min_time, max_time) donation.currency = 'USD' donation.transactionstate = transactionstate or 'COMPLETED' - if donation.domain == 'LOCAL': + if donation.domain in ['LOCAL', 'TWITCH']: assert ( donation.transactionstate == 'COMPLETED' - ), 'Local donations must be specified as COMPLETED' + ), 'Local or Twitch donations must be specified as COMPLETED' if not no_donor: if donor is None: @@ -546,11 +546,12 @@ def generate_donation_for_prize( if min_amount or min_time or max_time: warnings.warn('ignored parameter passed to generate_donation_for_prize') event = kwargs.pop('event', prize.event) + runs = event.speedrun_set.exclude(order=None) return generate_donation( rand, min_amount=prize.minimumbid, - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(runs), + max_time=prize.end_draw_time(runs), event=event, **kwargs, ) diff --git a/tests/test_donation.py b/tests/test_donation.py index 2471cebb7..f476d757d 100644 --- a/tests/test_donation.py +++ b/tests/test_donation.py @@ -307,6 +307,44 @@ def test_local_donation_broadcast(self, task): bid.save() task.assert_not_called() + @patch('tracker.tasks.post_donation_to_postbacks') + def test_twitch_donation_broadcast(self, task): + with override_settings(TRACKER_HAS_CELERY=True): + donation = models.Donation.objects.create(amount=50, domain='TWITCH') + task.delay.assert_called_with(donation.id) + task.assert_not_called() + task.reset_mock() + + donation.save() + task.delay.assert_not_called() + + # test here for convenience + bid = donation.bids.create(bid=self.bid, amount=donation.amount) + task.delay.assert_called_with(donation.id) + task.assert_not_called() + task.reset_mock() + + bid.save() + task.delay.assert_not_called() + + with override_settings(TRACKER_HAS_CELERY=False): + donation = models.Donation.objects.create(amount=50, domain='TWITCH') + task.assert_called_with(donation.id) + task.delay.assert_not_called() + task.reset_mock() + + donation.save() + task.assert_not_called() + + # test here for convenience + bid = donation.bids.create(bid=self.bid, amount=donation.amount) + task.assert_called_with(donation.id) + task.delay.assert_not_called() + task.reset_mock() + + bid.save() + task.assert_not_called() + class TestDonationAdmin(TestCase, AssertionHelpers): def setUp(self): diff --git a/tests/test_prize.py b/tests/test_prize.py index 8082e8218..b697533c0 100644 --- a/tests/test_prize.py +++ b/tests/test_prize.py @@ -10,11 +10,7 @@ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.auth.models import Permission, User from django.contrib.sites.models import Site -from django.core.exceptions import ( - ImproperlyConfigured, - ObjectDoesNotExist, - ValidationError, -) +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.models import Sum from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings from django.urls import reverse @@ -181,8 +177,9 @@ def test_time_prize_no_range(self): prize = randgen.generate_prize( self.rand, event=self.event, start_time=randomStart, end_time=randomEnd ) - self.assertEqual(randomStart, prize.start_draw_time()) - self.assertEqual(randomEnd, prize.end_draw_time()) + runs = self.event.speedrun_set.all() + self.assertEqual(randomStart, prize.start_draw_time(runs)) + self.assertEqual(randomEnd, prize.end_draw_time(runs)) class TestPrizeDrawingGeneratedEvent(TransactionTestCase): @@ -231,8 +228,8 @@ def test_draw_prize_one_donor(self): self.rand, donor=donor, event=self.event, - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) if donationSize == 'above': donation.amount = prize.minimumbid + Decimal('5.00') @@ -282,8 +279,8 @@ def test_draw_prize_multiple_donors_random_nosum(self): event=self.event, min_amount=prize.minimumbid, max_amount=prize.minimumbid + Decimal('100.00'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) donation.save() donationDonors.add(donor) @@ -295,7 +292,8 @@ def test_draw_prize_multiple_donors_random_nosum(self): event=self.event, min_amount=prize.minimumbid, max_amount=prize.minimumbid + Decimal('100.00'), - max_time=prize.start_draw_time() - datetime.timedelta(seconds=1), + max_time=prize.start_draw_time(self.runsList) + - datetime.timedelta(seconds=1), ) donation2.save() donation3 = randgen.generate_donation( @@ -304,15 +302,16 @@ def test_draw_prize_multiple_donors_random_nosum(self): event=self.event, min_amount=prize.minimumbid, max_amount=prize.minimumbid + Decimal('100.00'), - min_time=prize.end_draw_time() + datetime.timedelta(seconds=1), + min_time=prize.end_draw_time(self.runsList) + + datetime.timedelta(seconds=1), ) donation3.save() eligibleDonors = prize.eligible_donors() self.assertEqual(set(donationDonors), set(eligibleDonors)) for donor, amount in eligibleDonors.items(): donation = donor.donation_set.filter( - timereceived__gte=prize.start_draw_time(), - timereceived__lte=prize.end_draw_time(), + timereceived__gte=prize.start_draw_time(self.runsList), + timereceived__lte=prize.end_draw_time(self.runsList), )[0] self.assertEqual(donation.amount, amount) winners = [] @@ -357,8 +356,8 @@ def test_draw_prize_multiple_donors_random_sum(self): event=self.event, min_amount=Decimal('0.01'), max_amount=prize.minimumbid - Decimal('0.10'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) donation.save() donationDonors[donor] += donation.amount @@ -371,7 +370,7 @@ def test_draw_prize_multiple_donors_random_sum(self): event=self.event, min_amount=Decimal('0.01'), max_amount=prize.minimumbid - Decimal('0.10'), - max_time=prize.start_draw_time() + max_time=prize.start_draw_time(self.runsList) - datetime.timedelta(seconds=1), ) else: @@ -381,7 +380,8 @@ def test_draw_prize_multiple_donors_random_sum(self): event=self.event, min_amount=Decimal('0.01'), max_amount=prize.minimumbid - Decimal('0.10'), - min_time=prize.end_draw_time() + datetime.timedelta(seconds=1), + min_time=prize.end_draw_time(self.runsList) + + datetime.timedelta(seconds=1), ) donation.save() donationDonors = { @@ -392,8 +392,8 @@ def test_draw_prize_multiple_donors_random_sum(self): for donor in eligibleDonors: amount = donationDonors[donor] donations = donor.donation_set.filter( - timereceived__gte=prize.start_draw_time(), - timereceived__lte=prize.end_draw_time(), + timereceived__gte=prize.start_draw_time(self.runsList), + timereceived__lte=prize.end_draw_time(self.runsList), ) amount_sum = donations.aggregate(Sum('amount'))['amount__sum'] self.assertEqual(amount, amount_sum) @@ -438,8 +438,8 @@ def test_draw_prize_multiple_donors_norandom_nosum(self): event=self.event, min_amount=Decimal('0.01'), max_amount=Decimal('1000.00'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) donation.save() if donation.amount > largestAmount: @@ -454,7 +454,7 @@ def test_draw_prize_multiple_donors_norandom_nosum(self): event=self.event, min_amount=Decimal('1000.01'), max_amount=Decimal('2000.00'), - max_time=prize.start_draw_time() + max_time=prize.start_draw_time(self.runsList) - datetime.timedelta(seconds=1), ) else: @@ -466,7 +466,8 @@ def test_draw_prize_multiple_donors_norandom_nosum(self): max_amount=max( Decimal('1000.01'), prize.minimumbid - Decimal('2000.00') ), - min_time=prize.end_draw_time() + datetime.timedelta(seconds=1), + min_time=prize.end_draw_time(self.runsList) + + datetime.timedelta(seconds=1), ) donation.save() eligibleDonors = prize.eligible_donors() @@ -486,8 +487,8 @@ def test_draw_prize_multiple_donors_norandom_nosum(self): event=self.event, min_amount=Decimal('1000.01'), max_amount=Decimal('2000.00'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) newDonation.save() eligibleDonors = prize.eligible_donors() @@ -523,8 +524,8 @@ def test_draw_prize_multiple_donors_norandom_sum(self): event=self.event, min_amount=Decimal('0.01'), max_amount=Decimal('100.00'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) donation.save() donationDonors[donor] += donation.amount @@ -537,7 +538,7 @@ def test_draw_prize_multiple_donors_norandom_sum(self): event=self.event, min_amount=Decimal('1000.01'), max_amount=Decimal('2000.00'), - max_time=prize.start_draw_time() + max_time=prize.start_draw_time(self.runsList) - datetime.timedelta(seconds=1), ) else: @@ -549,7 +550,8 @@ def test_draw_prize_multiple_donors_norandom_sum(self): max_amount=max( Decimal('1000.01'), prize.minimumbid - Decimal('2000.00') ), - min_time=prize.end_draw_time() + datetime.timedelta(seconds=1), + min_time=prize.end_draw_time(self.runsList) + + datetime.timedelta(seconds=1), ) donation.save() maxDonor = max(donationDonors, key=lambda x: donationDonors[x]) @@ -573,8 +575,8 @@ def test_draw_prize_multiple_donors_norandom_sum(self): event=self.event, min_amount=diff + Decimal('0.01'), max_amount=diff + Decimal('100.00'), - min_time=prize.start_draw_time(), - max_time=prize.end_draw_time(), + min_time=prize.start_draw_time(self.runsList), + max_time=prize.end_draw_time(self.runsList), ) newDonation.save() donationDonors[maxDonor] += newDonation.amount @@ -981,244 +983,6 @@ def test_prev_next_backfilled(self): self.assertEqual(prize3.next_run_id, None, 'prize 3 next run incorrect') -class TestPrizeSignals(TestCase): - def setUp(self): - self.rand = random.Random(None) - self.event = randgen.generate_event(self.rand) - self.event.save() - self.runs = randgen.generate_runs(self.rand, self.event, 4, ordered=True) - self.event_prize = models.Prize.objects.create( - name='Event Wide Prize', startrun=self.runs[0], endrun=self.runs[3] - ) - self.start_prize = models.Prize.objects.create( - name='Start Prize', startrun=self.runs[0], endrun=self.runs[0] - ) - self.middle_prize = models.Prize.objects.create( - name='Middle Prize', startrun=self.runs[1], endrun=self.runs[1] - ) - self.end_prize = models.Prize.objects.create( - name='End Prize', startrun=self.runs[3], endrun=self.runs[3] - ) - self.start_span_prize = models.Prize.objects.create( - name='Start Span Prize', startrun=self.runs[0], endrun=self.runs[1] - ) - self.middle_span_prize = models.Prize.objects.create( - name='Middle Span Prize', startrun=self.runs[1], endrun=self.runs[2] - ) - self.end_span_prize = models.Prize.objects.create( - name='End Span Prize', startrun=self.runs[2], endrun=self.runs[3] - ) - - def refresh_all(self): - for model in [ - self.event, - self.event_prize, - self.start_prize, - self.middle_prize, - self.end_prize, - self.start_span_prize, - self.middle_span_prize, - self.end_span_prize, - ] + self.runs: - try: - model.refresh_from_db() - except ObjectDoesNotExist: - pass # deleted as part of test - - def test_initial_state(self): - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.runs[2]) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.runs[2]) - self.assertEqual(self.middle_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_span_prize.next_run, self.runs[3]) - self.assertEqual(self.end_span_prize.prev_run, self.runs[1]) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_run_inserted(self): - self.runs[3].order = self.runs[2].order = None - models.SpeedRun.objects.bulk_update((self.runs[2], self.runs[3]), ['order']) - self.runs[3].order = 5 - self.runs[2].order = 4 - models.SpeedRun.objects.bulk_update((self.runs[2], self.runs[3]), ['order']) - self.new_run = models.SpeedRun( - event=self.event, name='New Run', run_time='0:05:00', order=3 - ) - self.new_run.save() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.new_run) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.new_run) - self.assertEqual(self.middle_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_span_prize.next_run, self.runs[3]) - self.assertEqual(self.end_span_prize.prev_run, self.new_run) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_first_run_removed_from_order(self): - self.runs[0].order = None - self.runs[0].save() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, None) - self.assertEqual(self.middle_prize.prev_run, None) - self.assertEqual(self.middle_prize.next_run, self.runs[2]) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, None) - self.assertEqual(self.middle_span_prize.prev_run, None) - self.assertEqual(self.middle_span_prize.next_run, self.runs[3]) - self.assertEqual(self.end_span_prize.prev_run, self.runs[1]) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_first_run_deleted(self): - self.event_prize.startrun = self.runs[1] - self.event_prize.save() - self.start_prize.delete() - self.start_span_prize.delete() - self.runs[0].delete() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.middle_prize.prev_run, None) - self.assertEqual(self.middle_prize.next_run, self.runs[2]) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.middle_span_prize.prev_run, None) - self.assertEqual(self.middle_span_prize.next_run, self.runs[3]) - self.assertEqual(self.end_span_prize.prev_run, self.runs[1]) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_second_run_removed_from_order(self): - self.runs[1].order = None - self.runs[1].save() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[2]) - self.assertEqual(self.middle_prize.prev_run, None) - self.assertEqual(self.middle_prize.next_run, None) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, None) - self.assertEqual(self.middle_span_prize.prev_run, None) - self.assertEqual(self.middle_span_prize.next_run, None) - self.assertEqual(self.end_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_second_run_deleted(self): - self.start_span_prize.delete() - self.middle_prize.delete() - self.middle_span_prize.delete() - self.runs[1].delete() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[2]) - self.assertEqual(self.end_prize.prev_run, self.runs[2]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.end_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_third_run_removed_from_order(self): - self.runs[2].order = None - self.runs[2].save() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.runs[3]) - self.assertEqual(self.end_prize.prev_run, self.runs[1]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.runs[3]) - self.assertEqual(self.middle_span_prize.prev_run, None) - self.assertEqual(self.middle_span_prize.next_run, None) - self.assertEqual(self.end_span_prize.prev_run, None) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_third_run_deleted(self): - self.middle_span_prize.delete() - self.end_span_prize.delete() - self.runs[2].delete() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.runs[3]) - self.assertEqual(self.end_prize.prev_run, self.runs[1]) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.runs[3]) - - def test_fourth_run_removed_from_order(self): - self.runs[3].order = None - self.runs[3].save() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.runs[2]) - self.assertEqual(self.end_prize.prev_run, None) - self.assertEqual(self.end_prize.next_run, None) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.runs[2]) - self.assertEqual(self.middle_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_span_prize.next_run, None) - self.assertEqual(self.end_span_prize.prev_run, None) - self.assertEqual(self.end_span_prize.next_run, None) - - def test_fourth_run_deleted(self): - self.end_prize.delete() - self.end_span_prize.delete() - self.event_prize.endrun = self.runs[2] - self.event_prize.save() - self.runs[3].delete() - self.refresh_all() - self.assertEqual(self.event_prize.prev_run, None) - self.assertEqual(self.event_prize.next_run, None) - self.assertEqual(self.start_prize.prev_run, None) - self.assertEqual(self.start_prize.next_run, self.runs[1]) - self.assertEqual(self.middle_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_prize.next_run, self.runs[2]) - self.assertEqual(self.start_span_prize.prev_run, None) - self.assertEqual(self.start_span_prize.next_run, self.runs[2]) - self.assertEqual(self.middle_span_prize.prev_run, self.runs[0]) - self.assertEqual(self.middle_span_prize.next_run, None) - - -class TestPrizeTimeRange(TestCase): - def setUp(self): - self.rand = random.Random(None) - self.event = randgen.generate_event(self.rand) - self.event.save() - self.runs = randgen.generate_runs(self.rand, self.event, 4, ordered=True) - - class TestPrizeKey(TestCase): def setUp(self): self.rand = random.Random(None) @@ -2598,6 +2362,7 @@ def test_smoke(self): 'extrainfo': 'I made this with pink Himalayan sea salt.', 'estimatedvalue': '5.00', 'imageurl': 'https://example.com/deadbeef.jpg', + 'provider': 'Alex Doe', 'creatorname': 'Jesse Doe', 'creatoremail': 'jesse@example.com', 'creatorwebsite': 'https://example.com/jesse', @@ -2606,6 +2371,7 @@ def test_smoke(self): ) self.assertEqual(resp.status_code, 200) self.assertTrue(resp.context['form'].is_valid()) + self.assertEqual(resp.context['form'].initial['provider'], self.user.username) prize = models.Prize.objects.get(name='Test Prize') self.assertDictContainsSubset( dict( @@ -2615,9 +2381,12 @@ def test_smoke(self): extrainfo='I made this with pink Himalayan sea salt.', estimatedvalue=Decimal('5.00'), image='https://example.com/deadbeef.jpg', + provider='Alex Doe', creator='Jesse Doe', creatoremail='jesse@example.com', creatorwebsite='https://example.com/jesse', ), prize.__dict__, ) + + # TODO: validation failures and duplicates diff --git a/tests/type_template.ts b/tests/type_template.ts index 38815747f..f5c1bdf6d 100644 --- a/tests/type_template.ts +++ b/tests/type_template.ts @@ -7,6 +7,7 @@ import { BidGet, BidPatch, BidPost, + DonationBidPost, DonationCommentPatch, DonationGet, DonationPost, @@ -65,4 +66,5 @@ let talentPost: TalentPost; let talentPatch: TalentPatch; let donationGet: DonationGet; let donatePost: DonationPost; +let donationBidPost: DonationBidPost; let commentPatch: DonationCommentPatch; diff --git a/tests/util.py b/tests/util.py index 54e5b92fb..aa85cfdb1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -685,7 +685,7 @@ def post_noun( /, *, model_name=None, - status_code=200, + status_code=201, data=None, kwargs=None, expected_error_codes=None, diff --git a/tracker/__init__.py b/tracker/__init__.py index dfa07983e..49f29a857 100644 --- a/tracker/__init__.py +++ b/tracker/__init__.py @@ -1,4 +1,5 @@ import json +import logging import os try: @@ -14,7 +15,7 @@ __bare_version__ = '3.3.1.dev0' if __tag__ := os.environ.get('BUILD_NUMBER', ''): - __version__ = __bare_version__.replace('dev0', __tag__) + __version__ = __bare_version__.replace('dev0', f'{__tag__}.dev0') else: __version__ = __bare_version__ @@ -23,3 +24,9 @@ assert json.load(pkg)['version'] == __bare_version__ except IOError: pass + + +class DBTrace(logging.Handler): + def emit(self, record): + # put a breakpoint here when trying to debug N+1 queries + pass diff --git a/tracker/admin/bid.py b/tracker/admin/bid.py index 10b413654..5cd3e0d5e 100644 --- a/tracker/admin/bid.py +++ b/tracker/admin/bid.py @@ -1,6 +1,7 @@ from django.contrib import messages from django.contrib.admin import display, register from django.contrib.auth.decorators import permission_required +from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import path, reverse @@ -130,7 +131,7 @@ def get_inlines(self, request, obj): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - if obj and obj.parent: + if obj and obj.parent and obj.parent_id != 20634: if 'state' in form.base_fields: form.base_fields['state'].choices = [ (obj.parent.state, 'Inherit Parent State'), @@ -150,7 +151,7 @@ def get_form(self, request, obj=None, **kwargs): def get_readonly_fields(self, request, obj=None): readonly_fields = super().get_readonly_fields(request, obj) if obj and obj.parent: - if not obj.parent.allowuseroptions: + if not obj.parent.allowuseroptions and obj.parent_id != 20634: readonly_fields = readonly_fields + ('state',) if obj.chain: readonly_fields = readonly_fields + ('istarget',) @@ -267,13 +268,13 @@ def bid_set_state_action(self, request, queryset, value, recursive=False): ) queryset = queryset.filter(event__archived=False) if not recursive: - unchanged = queryset.filter(level__gt=0) + unchanged = queryset.filter(level__gt=0).exclude(parent=20634) if unchanged.exists(): messages.warning( request, f'{unchanged.count()} bid(s) possibly unchanged because you can only use the dropdown on top level bids.', ) - queryset = queryset.filter(level=0) + queryset = queryset.filter(Q(level=0) | Q(parent=20634)) total = queryset.count() for b in queryset: b.state = value diff --git a/tracker/admin/donation.py b/tracker/admin/donation.py index a44984192..6b380242c 100644 --- a/tracker/admin/donation.py +++ b/tracker/admin/donation.py @@ -547,4 +547,22 @@ def get_search_fields(self, request): return tuple(search_fields) +@admin.register(models.TwitchDonation) +class TwitchDonationAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(models.BcauseDonation) +class BcauseDonationAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + admin.site.register(models.DonationGroup, AbstractTagAdmin) diff --git a/tracker/admin/event.py b/tracker/admin/event.py index 737861fad..acfc07331 100644 --- a/tracker/admin/event.py +++ b/tracker/admin/event.py @@ -279,12 +279,6 @@ def send_volunteer_emails_view(request, pk): # donation groups 'add_donationgroup', 'delete_donationgroup', - # donors - 'add_donor', - 'change_donor', - 'view_donor', - 'view_emails', - 'view_full_names', # needed for 'Start Run' 'change_speedrun', 'view_speedrun', @@ -734,6 +728,7 @@ def prize_report(self, request, queryset): 'End Time', ] ) + runs = tracker.models.SpeedRun.objects.filter(event=event).exclude(order=None) prizes = tracker.models.Prize.objects.filter( state='ACCEPTED', event=event ).iterator() @@ -745,8 +740,8 @@ def prize_report(self, request, queryset): p.name, len(eligible), len([d for d, a in eligible.items() if a == p.minimumbid]), - p.start_draw_time(), - p.end_draw_time(), + p.start_draw_time(runs), + p.end_draw_time(runs), ] ) return response diff --git a/tracker/admin/inlines.py b/tracker/admin/inlines.py index 180366a34..8120156d2 100644 --- a/tracker/admin/inlines.py +++ b/tracker/admin/inlines.py @@ -1,3 +1,5 @@ +from functools import partial + from django.contrib import admin from django.utils.safestring import mark_safe @@ -54,7 +56,7 @@ def get_fieldsets(self, request, obj=None): def get_readonly_fields(self, request, obj=None): readonly_fields = super().get_readonly_fields(request, obj) - if obj and not obj.allowuseroptions: + if obj and not obj.allowuseroptions and obj.id != 20634: readonly_fields = readonly_fields + ('state',) return readonly_fields @@ -65,14 +67,27 @@ class BidOptionInline(BidInline): fk_name = 'parent' def get_formset(self, request, obj=None, **kwargs): - formset = super().get_formset(request, obj, **kwargs) - if obj and obj.allowuseroptions: - formset.form.base_fields['state'].choices = [ - (obj.state, 'Inherit Parent State'), - ('PENDING', 'Pending'), - ('DENIED', 'Denied'), - ] - return formset + kwargs['formfield_callback'] = partial( + self.formfield_for_dbfield, request=request, obj=obj + ) + return super().get_formset(request, obj, **kwargs) + + def formfield_for_dbfield(self, db_field, request, obj=None, **kwargs): + field = super().formfield_for_dbfield(db_field, request, **kwargs) + if db_field.name == 'state' and obj: + if obj.allowuseroptions: + field.choices = [ + (obj.state, 'Inherit Parent State'), + ('PENDING', 'Pending'), + ('DENIED', 'Denied'), + ] + elif obj.id == 20634: + field.choices = [ + ('OPENED', 'Opened'), + ('CLOSED', 'Closed'), + ('HIDDEN', 'Hidden'), + ] + return field def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) @@ -87,7 +102,7 @@ def get_fieldsets(self, request, obj=None): def get_readonly_fields(self, request, obj=None): readonly_fields = super().get_readonly_fields(request, obj) - if not (obj and obj.allowuseroptions): + if not (obj and obj.allowuseroptions) and obj.id != 20634: readonly_fields += ('state',) return readonly_fields diff --git a/tracker/admin/prize.py b/tracker/admin/prize.py index 414097e99..c90e781d8 100644 --- a/tracker/admin/prize.py +++ b/tracker/admin/prize.py @@ -205,8 +205,8 @@ class PrizeAdmin(EventArchivedMixin, RelatedUserMixin, CustomModelAdmin): 'tags_', 'minimumbid', 'games', - 'start_draw_time', - 'end_draw_time', + '_start_draw_time', + '_end_draw_time', 'sumdonations', 'randomdraw', 'event', @@ -289,19 +289,27 @@ def get_queryset(self, request): if request.user.has_perm('tracker.view_prizeclaim'): queryset = queryset.time_annotation().claim_annotations() - return queryset.prefetch_related('tags', 'claims__winner').select_related( + return queryset.prefetch_related( + 'tags', 'claims__winner', 'event__speedrun_set' + ).select_related( 'event', 'startrun', 'endrun', 'handler', - 'prev_run', - 'next_run', ) @admin.display(description='Tags') def tags_(self, obj): return ', '.join(t.name for t in obj.tags.all()) or None + @admin.display(description='Start Draw Time') + def _start_draw_time(self, obj): + return obj.start_draw_time(obj.event.speedrun_set.all()) + + @admin.display(description='End Draw Time') + def _end_draw_time(self, obj): + return obj.end_draw_time(obj.event.speedrun_set.all()) + def handler_email(self, obj): return obj.handler.email @@ -668,7 +676,7 @@ def _prize_winner_context( @method_decorator( permission_required( ( - 'tracker.change_prizewinner', + 'tracker.change_prizeclaim', 'tracker.view_donor', 'tracker.change_prize', ), @@ -767,7 +775,7 @@ def automail_prize_winners(self, request, event=None): @method_decorator( permission_required( ( - 'tracker.change_prizewinner', + 'tracker.change_prizeclaim', 'tracker.view_donor', 'tracker.change_prize', ), @@ -835,7 +843,7 @@ def _prize_accept_context( @method_decorator( permission_required( - ('tracker.change_prizewinner', 'tracker.change_prize'), raise_exception=True + ('tracker.change_prizeclaim', 'tracker.change_prize'), raise_exception=True ) ) def automail_prize_accept_notifications(self, request, event=None): @@ -922,7 +930,7 @@ def automail_prize_accept_notifications(self, request, event=None): @method_decorator( permission_required( - ('tracker.change_prizewinner', 'tracker.change_prize'), raise_exception=True + ('tracker.change_prizeclaim', 'tracker.change_prize'), raise_exception=True ) ) def preview_prize_accept_mail(self, request, claim, template): @@ -993,7 +1001,7 @@ def _prize_shipped_context( @method_decorator( permission_required( ( - 'tracker.change_prizewinner', + 'tracker.change_prizeclaim', 'tracker.view_donor', 'tracker.change_prize', ), @@ -1073,7 +1081,7 @@ def automail_prize_shipping_notifications(self, request, event=None): @method_decorator( permission_required( ( - 'tracker.change_prizewinner', + 'tracker.change_prizeclaim', 'tracker.view_donor', 'tracker.change_prize', ), diff --git a/tracker/api/permissions.py b/tracker/api/permissions.py index fd54c2564..34fbf5d80 100644 --- a/tracker/api/permissions.py +++ b/tracker/api/permissions.py @@ -5,6 +5,7 @@ SAFE_METHODS, BasePermission, DjangoModelPermissions, + DjangoModelPermissionsOrAnonReadOnly, ) from rest_framework.request import Request @@ -223,6 +224,11 @@ def has_object_permission(self, request, view, obj): ) and obj.user_can_send_to_reader(request.user) +class DonationBidPermission(DjangoModelPermissionsOrAnonReadOnly): + def _queryset(self, view): + return models.DonationBid.objects.all() + + # noinspection PyPep8Naming def PrivateListGenericPermission(model_name: str): """ diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index 6e8ff18f7..80aac0c40 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -1,6 +1,7 @@ """Define serialization of the Django models into the REST framework.""" import datetime +import functools import logging import re from collections import defaultdict @@ -12,7 +13,8 @@ from django.core.exceptions import NON_FIELD_ERRORS, ObjectDoesNotExist from django.core.exceptions import ValidationError as DjangoValidationError -from django.db.models import QuerySet +from django.db import transaction +from django.db.models import Q, QuerySet, Sum from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ErrorDetail, ValidationError @@ -743,6 +745,8 @@ class DonationBidSerializer(SerializerWithPermissionsMixin, TrackerModelSerializ bid_state = serializers.SerializerMethodField() bid_count = serializers.SerializerMethodField() bid_total = serializers.SerializerMethodField() + parent = serializers.IntegerField(required=True) # only for creating new options + name = serializers.CharField(required=True) # only for creating new options class Meta: model = DonationBid @@ -756,6 +760,8 @@ class Meta: 'bid_count', 'bid_total', 'amount', + 'parent', + 'name', ) def __init__(self, *args, **kwargs): @@ -810,11 +816,106 @@ def prefetch_bids(self, donation_bids): self._bids = [donation_bids.bid] self._bid_serializer = BidSerializer(self._bids) + def get_fields(self): + fields = super().get_fields() + if ( + (request := self.context.get('request', None)) + and request.method.upper() == 'POST' + and 'parent' in self.initial_data + ): + fields['bid'].required = False + fields['bid'].null = True + else: + fields.pop('parent', None) + fields.pop('name', None) + return fields + + def to_internal_value(self, data): + if ( + (request := self.context.get('request', None)) + and request.method.upper() == 'POST' + and (view := self.context.get('view', None)) + ): + if 'donation' not in data and (donation := view.donation): + data['donation'] = donation.id + if 'parent' in data and 'name' in data: + with transaction.atomic(): + parent = ( + Bid.objects.filter(parent=data['parent']) + .select_for_update() + .first() + ) + if parent: + bid = Bid.objects.filter( + parent=data['parent'], name__iexact=data['name'].strip() + ).first() + if bid: + data['bid'] = bid.pk + data.pop('parent') + data.pop('name') + if 'bid' not in data and (bid := view.bid): + data['bid'] = bid.id + return super().to_internal_value(data) + + def validate(self, attrs): + if 'parent' in attrs: + errors = defaultdict(list) + if 'name' in attrs: + parent = Bid.objects.filter( + id=attrs['parent'], allowuseroptions=True + ).first() + if parent is None: + errors['parent'].append( + ErrorDetail( + 'Specified bid does not exist or does not accept user options.', + code='invalid', + ) + ) + else: + try: + Bid(parent=parent, name=attrs['name'].strip()).full_clean() + except DjangoValidationError as exc: + errors['name'].append(ErrorDetail(str(exc), code='invalid')) + existing = ( + DonationBid.objects.filter( + donation=attrs['donation'] + ).aggregate(amount=Sum('amount'))['amount'] + or 0 + ) + if existing + attrs['amount'] > attrs['donation'].amount: + errors['donation'].append( + ErrorDetail('Total amount is too high.', code='invalid') + ) + else: + errors['name'].append( + ErrorDetail( + '`name` is required when `parent` is supplied.', code='required' + ) + ) + with _coalesce_validation_errors(errors): + return attrs + else: + return super().validate(attrs) + + def create(self, validated_data): + if 'parent' in validated_data: + validated_data['bid'] = Bid.objects.create( + parent_id=validated_data['parent'], + name=validated_data['name'], + state='PENDING', + istarget=True, + ) + validated_data.pop('parent') + validated_data.pop('name') + return super().create(validated_data) + def to_representation(self, instance): # final check assert self._has_permission( instance ), f'tried to serialize a hidden donation bid without permission {self.root_permissions}' + self.fields.pop('parent', None) + self.fields.pop('name', None) return super().to_representation(instance) @@ -1342,6 +1443,8 @@ class PrizeSerializer( # TODO: when I figure out a better way to be selective about nested fields # startrun = SpeedRunSerializer() # endrun = SpeedRunSerializer() + start_draw_time = serializers.SerializerMethodField() + end_draw_time = serializers.SerializerMethodField() def __init__(self, *args, lifecycle=False, **kwargs): self.lifecycle = lifecycle @@ -1391,3 +1494,26 @@ def validate(self, data): ): data['handler'] = self.context['request'].user return super().validate(data) + + @cached_property + def _extra_runs(self): + prizes = self.instance + if not isinstance(prizes, list): + prizes = [prizes] + args = set() + for p in prizes: + if p.startrun: + args.add((p.event_id, p.startrun.order - 1)) + args.add((p.event_id, p.endrun.order + 1)) + if args: + return SpeedRun.objects.filter( + functools.reduce(lambda a, b: a | Q(event=b[0], order=b[1]), args, Q()) + ) + else: + return SpeedRun.objects.none() + + def get_start_draw_time(self, instance): + return instance.start_draw_time(self._extra_runs) + + def get_end_draw_time(self, instance): + return instance.end_draw_time(self._extra_runs) diff --git a/tracker/api/views/bids.py b/tracker/api/views/bids.py index a1f46047a..d72cb9a4d 100644 --- a/tracker/api/views/bids.py +++ b/tracker/api/views/bids.py @@ -1,6 +1,7 @@ import contextlib import logging +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from rest_framework.decorators import action from rest_framework.exceptions import ( @@ -25,6 +26,7 @@ TrackerFullViewSet, WithSerializerPermissionsMixin, ) +from tracker.api.views.decorators import cache_page_for_public from tracker.api.views.donation_bids import DonationBidViewSet from tracker.models import Bid, SpeedRun @@ -102,6 +104,7 @@ def create(self, request, *args, **kwargs): ) return super().create(request, *args, **kwargs) + @method_decorator(cache_page_for_public(60)) @action(detail=False) def tree(self, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset().filter(level=0)) diff --git a/tracker/api/views/decorators.py b/tracker/api/views/decorators.py new file mode 100644 index 000000000..17bfd8512 --- /dev/null +++ b/tracker/api/views/decorators.py @@ -0,0 +1,18 @@ +from functools import wraps + +from django.views.decorators.cache import cache_page + + +def cache_page_for_public(*cache_args, **cache_kwargs): + def inner(func): + @wraps(func) + def inner_method(request, *args, **kwargs): + if not request.user.is_authenticated: + return cache_page(*cache_args, *cache_kwargs)(func)( + request, *args, **kwargs + ) + return func(request, *args, **kwargs) + + return inner_method + + return inner diff --git a/tracker/api/views/donate.py b/tracker/api/views/donate.py index 924778412..c3b138893 100644 --- a/tracker/api/views/donate.py +++ b/tracker/api/views/donate.py @@ -1,4 +1,6 @@ import contextlib +import hmac +import io import logging import secrets import time @@ -11,14 +13,19 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.core.signing import BadSignature, TimestampSigner from django.db import transaction -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.template.response import SimpleTemplateResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_protect from paypal.standard.forms import PayPalPaymentsForm from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import ErrorDetail, PermissionDenied, ValidationError +from rest_framework.exceptions import ( + ErrorDetail, + ParseError, + PermissionDenied, + ValidationError, +) from rest_framework.fields import ( BooleanField, CharField, @@ -26,16 +33,27 @@ EmailField, IntegerField, ) +from rest_framework.parsers import JSONParser from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.serializers import Serializer, as_serializer_error +from rest_framework.serializers import ModelSerializer, Serializer, as_serializer_error from rest_framework.viewsets import GenericViewSet from tracker import settings from tracker.api.serializers import DonationSerializer, EnsureSerializableMixin from tracker.compat import reverse -from tracker.models import Bid, Donation, Event -from tracker.models.donation import Donor +from tracker.eventutil import post_donation_to_postbacks +from tracker.models import ( + BcauseDonation, + Bid, + Donation, + DonationBid, + Donor, + Event, + SpeedRun, + TwitchDonation, +) +from tracker.viewutil import tracker_log logger = logging.getLogger(__file__) @@ -161,6 +179,38 @@ def to_representation(self, instance): return ret +class TwitchDonationSerializer(EnsureSerializableMixin, ModelSerializer): + class Meta: + model = TwitchDonation + fields = ( + 'campaign_id', + 'broadcaster_user_id', + 'broadcaster_user_name', + 'broadcaster_user_login', + 'user_id', + 'user_login', + 'user_name', + 'charity_name', + 'charity_description', + 'charity_logo', + 'charity_website', + ) + + def to_internal_value(self, data): + if 'id' in data: + data['twitch_id'] = data.pop('id') + if 'amount' in data: + amount = data.pop('amount', {}) + data['amount_value'] = amount.get('value', None) + data['amount_decimal_places'] = amount.get('decimal_places', None) + data['amount_currency'] = amount.get('currency', None) + return super().to_internal_value(data) + + def validate(self, attrs): + attrs['twitch_id'] = attrs.get('id', None) + return super().validate(attrs) + + class NewDonationSerializer(EnsureSerializableMixin, Serializer): amount = DecimalField( max_digits=20, @@ -170,9 +220,10 @@ class NewDonationSerializer(EnsureSerializableMixin, Serializer): bids = NewDonationBidSerializer(many=True) comment = CharField(allow_blank=True, max_length=5000) domain = CharField(required=False) - domainId = CharField(read_only=True) + domain_id = CharField(required=False) donor_id = IntegerField(required=False) donor_email = EmailField(required=False) + donor_twitch_id = IntegerField(required=False) email_optin = BooleanField() event = IntegerField() requested_alias = CharField( @@ -183,10 +234,27 @@ class NewDonationSerializer(EnsureSerializableMixin, Serializer): allow_blank=True, max_length=Donation._meta.get_field('requestedemail').max_length, ) + twitch = TwitchDonationSerializer(required=False) def to_internal_value(self, data): if isinstance(data.get('amount'), float): data['amount'] = _trim(data['amount']) + if 'twitch' in data: + data['twitch'] = TwitchDonationSerializer().to_internal_value( + data['twitch'] + ) + data['domain'] = 'TWITCH' + data['domain_id'] = data['twitch']['twitch_id'] + data['donor_twitch_id'] = data['twitch']['user_id'] + data['requested_alias'] = data['twitch']['user_name'] + data['amount'] = _trim( + data['twitch']['amount_value'] + / 10.0 ** data['twitch']['amount_decimal_places'] + ) + data['bids'] = [] + data['comment'] = '' + data['email_optin'] = False + data['requested_email'] = f"{data['donor_twitch_id']}@fake.users.twitch.tv" return super().to_internal_value(data) def validate(self, attrs): @@ -209,7 +277,7 @@ def validate(self, attrs): code='invalid', ) ) - if attrs['amount'] < event.minimumdonation: + if attrs['domain'] != 'TWITCH' and attrs['amount'] < event.minimumdonation: errors['amount'].append( ErrorDetail( 'Donation amount is below event minimum.', code='invalid' @@ -270,14 +338,14 @@ def validate(self, attrs): ErrorDetail('Specified donor does not exist.', code='invalid') ) elif 'donor_email' in attrs: - if not Donor.objects.filter( - email__iexact=attrs['donor_email'] - ).exists(): + donor = Donor.objects.filter(email__iexact=attrs['donor_email']).first() + if not donor: errors['donor_email'].append( ErrorDetail( 'Specified donor email could not be found.', code='invalid' ) ) + attrs['donor_id'] = donor.id else: errors['domain'].append( ErrorDetail( @@ -287,6 +355,33 @@ def validate(self, attrs): ) elif domain == 'PAYPAL': pass + elif domain == 'TWITCH': + if 'donor_twitch_id' in attrs: + attrs['donor_id'] = Donor.objects.get_or_create( + twitch_id=attrs['donor_twitch_id'], + defaults={ + 'email': attrs.get( + 'donor_email', + f'{attrs["donor_twitch_id"]}@users.twitch.tv.fake', + ), + 'alias': attrs['requested_alias'], + 'visibility': 'ALIAS', + }, + )[0].id + else: + errors['donor_twitch_id'].append( + ErrorDetail( + 'Twitch donations require `donor_twitch_id` field.', + code='invalid', + ) + ) + if 'domain_id' not in attrs: + errors['domain_id'].append( + ErrorDetail( + 'Twitch donations require `domain_id` field.', + code='invalid', + ) + ) else: errors['domain'].append( ErrorDetail( @@ -304,11 +399,42 @@ def validate(self, attrs): if errors: raise ValidationError(errors) - attrs['domainId'] = f'{int(time.time())}-{secrets.token_hex(16)}' + attrs.setdefault('domain_id', f'{int(time.time())}-{secrets.token_hex(16)}') return attrs +class BcauseSerializer(EnsureSerializableMixin, ModelSerializer): + class Meta: + model = BcauseDonation + fields = ( + 'amount_cents', + 'beneficiary_id', + 'currency_code', + 'date_valuta_utc', + 'donor_name', + 'email', + 'fee_cents', + 'metadata', + 'sandbox', + 'status', + 'transaction_id', + 'user_id', + ) + + +class SaveRawBodyJSONParser(JSONParser): + def parse(self, stream, media_type=None, parser_context=None): + parser_context = parser_context or {} + request = parser_context.get('request') + + if request is not None: + request.raw = stream.read() + stream = io.BytesIO(request.raw) + + return super().parse(stream, media_type, parser_context) + + class DonateViewSet(GenericViewSet): serializer_class = NewDonationSerializer @@ -326,8 +452,9 @@ def _create_donation(self, serializer): event = Event.objects.get(id=data['event']) query = dict( event=event, - domain='PAYPAL', - domainId=data['domainId'], + donor_id=data.get('donor_id', None), + domain=data['domain'], + domainId=data['domain_id'], currency=event.paypalcurrency, amount=_trim(data['amount']), comment=data['comment'], @@ -342,29 +469,49 @@ def _create_donation(self, serializer): donation = Donation(**query) donation.full_clean() donation.save() - # get_or_create isn't usable in transactions, so for new suggestions we lock the parents instead to assure atomicity - Bid.objects.filter( - id__in=(b['parent'] for b in data['bids'] if 'parent' in b) - ).select_for_update() + # get_or_create isn't usable in transactions, so for new suggestions we lock the parents instead to + # assure atomicity + # this gracefully handles the case where two people input the same option at the same time (unlikely, + # but it's happened! or maybe it was somebody hitting back/refresh) as well as somebody putting an + # existing name in as a "new" suggestion, either from not paying attention or because the option is + # pending/denied and thus not visible + + parents = ( + Bid.objects.filter( + id__in=(b['parent'] for b in data['bids'] if 'parent' in b) + ) + .select_for_update() + .prefetch_related('options') + ) for bid_data in data['bids']: if 'id' in bid_data: - bid = Bid.objects.get(id=bid_data['id']) + option = Bid.objects.get(id=bid_data['id']) else: - try: - bid = Bid.objects.get( - parent_id=bid_data['parent'], - name__iexact=bid_data['name'], + if not ( + parent := next( + (p for p in parents if p.id == bid_data['parent']), None + ) + ): + raise Bid.DoesNotExist + if not ( + option := next( + ( + o + for o in parent.options.all() + if o.name == bid_data['name'] + ), + None, ) - except Bid.DoesNotExist: - bid = Bid.objects.create( + ): + option = Bid.objects.create( parent_id=bid_data['parent'], name=bid_data['name'], state='PENDING', istarget=True, ) - bid.full_clean() + option.full_clean() donation.bids.create( - bid=bid, + bid=option, amount=_trim(bid_data['amount']), ) donation.full_clean() @@ -431,6 +578,164 @@ def paypal_confirm(self, request, *args, **kwargs): exc, self.get_exception_handler_context() ) + @action( + url_name='bcause-confirm', + detail=False, + methods=['post'], + authentication_classes=[], + serializer_class=BcauseSerializer, + parser_classes=[ + SaveRawBodyJSONParser + ], # needed for signature verification for Bcause payloads + renderer_classes=[JSONRenderer], + ) + def bcause_confirm(self, request, *args, **kwargs): + try: + secret = getattr(settings, 'TRACKER_BCAUSE_SIGNING_SECRET', b'') + event = getattr(settings, 'TRACKER_BCAUSE_EVENT_ID', 0) + + if not secret or not (event := Event.objects.filter(id=event).first()): + tracker_log( + 'bcause', + 'Received a Bcause payload but Bcause settings are not set properly.', + ) + raise Http404 + + sig = request.META.get('HTTP_X_BCAUSE_SIGNATURE', '') + + if not sig.startswith('sha256='): + raise ParseError('invalid signature') + + data = request.data.get('data', {}) + + h = hmac.new(secret, request.raw, 'sha-256').hexdigest() + + if not hmac.compare_digest(sig[7:].lower(), h.lower()): + raise ParseError('invalid signature') + + bd = BcauseDonation.objects.filter( + transaction_id=data.get('transaction_id', '') + ).first() + + serializer = self.get_serializer(data=data, instance=bd) + serializer.is_valid(raise_exception=True) + if serializer.validated_data['transaction_id'] == 'txn-test': + return HttpResponse(status=204) + bd = serializer.save() + d = Donation.objects.get_or_create( + domain='BCAUSE', + domainId=bd.transaction_id, + defaults={ + 'amount': bd.amount_cents / Decimal('100.00'), + 'currency': bd.currency_code.upper(), + 'event': event, + 'transactionstate': bd.status.upper(), + }, + )[0] + d.amount = (bd.amount_cents + bd.fee_cents) / Decimal('100.00') + d.currency = bd.currency_code.upper() + + if bd.user_id: + donor = Donor.objects.get_or_create(bcause_id=bd.user_id)[0] + donor.email = bd.email or 'bcause-anonymous@not-a-real-email.nope' + else: + donor = Donor.objects.get_or_create( + email=bd.email or 'bcause-anonymous@not-a-real-email.nope' + )[0] + if bd.donor_name: + d.requestedalias = bd.donor_name + donor.alias = bd.donor_name + donor.visibility = 'ALIAS' + donor.ineligible = not bd.email + donor.save() + d.donor = donor + d.event = event + d.transactionstate = bd.status.upper() + d.comment = bd.metadata.get('Your comment', '') + d.clean() + d.save() + incentive = bd.metadata.get('Incentive', '') + if incentive: + if isinstance(incentive, list): + incentive = incentive[0] + bid = None + parts = incentive.split('-') + if len(parts) == 3: + run = SpeedRun.objects.filter( + event=event, + name__iexact=parts[0].strip(), + ).first() + if run: + parent = Bid.objects.filter( + speedrun=run, + name__iexact=parts[1].strip(), + istarget=False, + ).first() + if parent: + bid = Bid.objects.filter( + name__iexact=parts[2].strip(), + istarget=True, + parent=parent, + ).first() + elif len(parts) == 2: + parent = Bid.objects.filter( + event=event, + name__iexact=parts[0].strip(), + istarget=False, + ).first() + if parent: + bid = Bid.objects.filter( + name__iexact=parts[1].strip(), + istarget=True, + parent=parent, + ).first() + else: + run = SpeedRun.objects.filter( + event=event, + name__iexact=parts[0].strip(), + ).first() + if run: + bid = Bid.objects.filter( + speedrun=run, + name__iexact=parts[1].strip(), + istarget=True, + ).first() + elif len(parts) == 1: + bid = Bid.objects.filter( + event=event, name__iexact=parts[0].strip(), istarget=True + ).first() + if bid: + DonationBid.objects.get_or_create( + bid=bid, donation=d, defaults={'amount': d.amount} + ) + else: + tracker_log( + 'bcause', + f'Could not find incentive with name `{incentive}`, not attaching bid', + ) + bd.donation = d + bd.raw = request.raw.decode('utf-8') + bd.save() + post_donation_to_postbacks(d) + return HttpResponse(str(bd.id)) + except Exception as e: + try: + bd = BcauseDonation.objects.create( + error=True, + raw=request.raw.decode('utf-8'), + ) + tracker_log( + 'bcause', + f"Error processing BcauseDonation: {e}, raw payload saved with id {bd.id}", + ) + except Exception as e2: + print(e2) + tracker_log( + 'bcause', + f"Couldn't create BcauseDonation with payload {request.raw.decode('utf-8')}: {e2}, {e}", + ) + raise + def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/tracker/api/views/donation_bids.py b/tracker/api/views/donation_bids.py index 2db72e760..d36c0a06c 100644 --- a/tracker/api/views/donation_bids.py +++ b/tracker/api/views/donation_bids.py @@ -4,10 +4,16 @@ from tracker.api.pagination import TrackerPagination from tracker.api.permissions import DonationBidStatePermission from tracker.api.serializers import DonationBidSerializer -from tracker.api.views import TrackerReadViewSet, WithSerializerPermissionsMixin +from tracker.api.views import ( + TrackerCreateMixin, + TrackerReadViewSet, + WithSerializerPermissionsMixin, +) -class DonationBidViewSet(WithSerializerPermissionsMixin, TrackerReadViewSet): +class DonationBidViewSet( + WithSerializerPermissionsMixin, TrackerReadViewSet, TrackerCreateMixin +): serializer_class = DonationBidSerializer pagination_class = TrackerPagination permission_classes = [DonationBidStatePermission] diff --git a/tracker/api/views/donations.py b/tracker/api/views/donations.py index cd78c1af5..b4ceec62a 100644 --- a/tracker/api/views/donations.py +++ b/tracker/api/views/donations.py @@ -11,6 +11,7 @@ from tracker.api.pagination import TrackerPagination from tracker.api.permissions import ( CanSendToReader, + DonationBidPermission, DonationQueryPermission, tracker_permission, ) @@ -429,8 +430,17 @@ def groups(self, request, pk, group, *args, **kwargs): return Response([g.name for g in donation.groups.all()]) - @action(detail=True, methods=['get']) + @action( + detail=True, + methods=['get', 'post'], + permission_classes=[DonationBidPermission], + include_tracker_permissions=False, + ) def bids(self, request, *args, **kwargs): viewset = DonationBidViewSet(request=request, donation=self.get_object()) viewset.initial(request, *args, **kwargs) - return viewset.list(request, *args, **kwargs) + if request.method.upper() == 'GET': + return viewset.list(request, *args, **kwargs) + elif request.method.upper() == 'POST': + return viewset.create(request, *args, **kwargs) + assert False, 'what' diff --git a/tracker/api/views/events.py b/tracker/api/views/events.py index c3c335322..7f35eada9 100644 --- a/tracker/api/views/events.py +++ b/tracker/api/views/events.py @@ -1,8 +1,11 @@ +from django.utils.decorators import method_decorator + from tracker import models from tracker.api.filters import EventFilter from tracker.api.pagination import TrackerPagination from tracker.api.serializers import EventSerializer from tracker.api.views import FlatteningViewSetMixin, TrackerReadViewSet +from tracker.api.views.decorators import cache_page_for_public class EventViewSet(FlatteningViewSetMixin, TrackerReadViewSet): @@ -11,6 +14,10 @@ class EventViewSet(FlatteningViewSetMixin, TrackerReadViewSet): serializer_class = EventSerializer pagination_class = TrackerPagination + @method_decorator(cache_page_for_public(60)) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + def get_queryset(self): return super().get_queryset().with_cache() diff --git a/tracker/api/views/milestone.py b/tracker/api/views/milestone.py index c324c00a5..53b103fd0 100644 --- a/tracker/api/views/milestone.py +++ b/tracker/api/views/milestone.py @@ -1,3 +1,5 @@ +from django.utils.decorators import method_decorator + from tracker.api.pagination import TrackerPagination from tracker.api.permissions import PrivateGenericPermissions from tracker.api.serializers import MilestoneSerializer @@ -6,6 +8,7 @@ TrackerFullViewSet, WithSerializerPermissionsMixin, ) +from tracker.api.views.decorators import cache_page_for_public from tracker.models import Milestone @@ -21,6 +24,10 @@ class MilestoneViewSet( *PrivateGenericPermissions('milestone', lambda o: o.visible), ] + @method_decorator(cache_page_for_public(60)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def _include_hidden(self, instance=None): return ( isinstance(instance, Milestone) and instance.visible diff --git a/tracker/api/views/prize.py b/tracker/api/views/prize.py index 461fa5470..d4c194507 100644 --- a/tracker/api/views/prize.py +++ b/tracker/api/views/prize.py @@ -1,3 +1,5 @@ +from django.utils.decorators import method_decorator + from tracker.api.filters import PrizeFilter from tracker.api.pagination import TrackerPagination from tracker.api.permissions import ( @@ -11,6 +13,7 @@ TrackerFullViewSet, WithSerializerPermissionsMixin, ) +from tracker.api.views.decorators import cache_page_for_public from tracker.models import Prize @@ -19,9 +22,7 @@ class PrizeViewSet( EventNestedMixin, TrackerFullViewSet, ): - queryset = Prize.objects.select_related( - 'event', 'startrun', 'endrun', 'prev_run', 'next_run' - ) + queryset = Prize.objects.select_related('event', 'startrun', 'endrun') serializer_class = PrizeSerializer permission_classes = [ PrizeFeedPermission, @@ -31,6 +32,10 @@ class PrizeViewSet( filter_backends = [PrizeFilter] pagination_class = TrackerPagination + @method_decorator(cache_page_for_public(60)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def get_queryset(self): queryset = super().get_queryset() if 'lifecycle' in self.request.query_params: diff --git a/tracker/api/views/run.py b/tracker/api/views/run.py index b48ab41eb..b0e4a2a64 100644 --- a/tracker/api/views/run.py +++ b/tracker/api/views/run.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction +from django.utils.decorators import method_decorator from rest_framework.decorators import action from rest_framework.exceptions import NotFound, ValidationError from rest_framework.response import Response @@ -19,6 +20,7 @@ TrackerFullViewSet, WithSerializerPermissionsMixin, ) +from tracker.api.views.decorators import cache_page_for_public from tracker.models import Interstitial, SpeedRun @@ -40,6 +42,10 @@ class SpeedRunViewSet( *PrivateGenericPermissions('speedrun', lambda r: r.order is not None), ] + @method_decorator(cache_page_for_public(60)) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + def get_queryset(self): queryset = super().get_queryset() if not self.detail and 'all' not in self.request.query_params: @@ -170,7 +176,8 @@ def move(self, *args, **kwargs): # - if we cross an anchor boundary going backwards, every run between the old end point # and the next anchor (or the end of the event) will also adjust its time # - edge case: moving a flex block needs to ensure the new flex blocks get adjusted - checkpoints = set() + # - the run being moved needs to be saved as well to send prize save signals + needs_save = {moving} time_diff = datetime.timedelta(milliseconds=moving.total_time_ms) @@ -183,7 +190,7 @@ def move(self, *args, **kwargs): forward_runs = queryset.filter( order__gte=order, order__lt=first_anchor.order ) - checkpoints.add(forward_runs.last()) + needs_save.add(forward_runs.last()) if slot := reordered_runs.first(): moving.starttime = slot.starttime elif slot := queryset.last(): # end of the event @@ -201,14 +208,14 @@ def move(self, *args, **kwargs): ): # see edge case comment above if first_anchor.order == moving.order + 1: - checkpoints.add( + needs_save.add( queryset.filter(order=moving.order - 1).first() ) else: backward_runs = queryset.filter( order__gt=moving.order, order__lt=first_anchor.order ) - checkpoints.add(backward_runs.last()) + needs_save.add(backward_runs.last()) forward_runs = queryset.none() elif moving.order < order: # moving a run forward reordered_runs = queryset.filter( @@ -221,14 +228,14 @@ def move(self, *args, **kwargs): # see edge case comment above if first_anchor.order == moving.order + 1: backward_runs = queryset.none() - checkpoints.add( + needs_save.add( queryset.filter(order=moving.order - 1).first() ) else: backward_runs = queryset.filter( order__gt=moving.order, order__lt=first_anchor.order ) - checkpoints.add(backward_runs.last()) + needs_save.add(backward_runs.last()) forward_runs = queryset.filter(order__gt=order) if next_anchor := forward_runs.exclude( anchor_time=None @@ -236,7 +243,7 @@ def move(self, *args, **kwargs): forward_runs = forward_runs.filter( order__lt=next_anchor.order ) - checkpoints.add(forward_runs.last()) + needs_save.add(forward_runs.last()) else: backward_runs = reordered_runs forward_runs = queryset.none() @@ -258,14 +265,14 @@ def move(self, *args, **kwargs): .exclude(anchor_time=None) .exists() ): - checkpoints.add(forward_runs.last()) + needs_save.add(forward_runs.last()) if first_anchor := next( (r for r in reordered_runs if r.anchor_time is not None), None ): forward_runs = reordered_runs.filter( order__lt=first_anchor.order, anchor_time=None ) - checkpoints.add(forward_runs.last()) + needs_save.add(forward_runs.last()) backward_runs = queryset.filter(order__gt=moving.order) if next_anchor := backward_runs.exclude( anchor_time=None @@ -273,7 +280,7 @@ def move(self, *args, **kwargs): backward_runs = backward_runs.filter( order__lt=next_anchor.order ) - checkpoints.add(backward_runs.last()) + needs_save.add(backward_runs.last()) else: backward_runs = queryset.none() moving.starttime = reordered_runs.first().starttime @@ -281,7 +288,7 @@ def move(self, *args, **kwargs): set(reordered_runs) | set(forward_runs) | set(backward_runs) - | set((c for c in checkpoints if c is not None)) + | set((c for c in needs_save if c is not None)) ) changed.add(moving) # ensure we're working with the object from the set and not a copy @@ -289,7 +296,7 @@ def move(self, *args, **kwargs): reordered_runs = {r for r in changed if r in reordered_runs} forward_runs = {r for r in changed if r in forward_runs} backward_runs = {r for r in changed if r in backward_runs} - checkpoints = {r for r in changed if r in checkpoints} + needs_save = {r for r in changed if r in needs_save} for run in reordered_runs: run.order += order_diff @@ -315,8 +322,8 @@ def move(self, *args, **kwargs): queryset.filter(id__in=(c.id for c in changed)).update(order=None) queryset.bulk_update(changed, ['order', 'starttime', 'endtime']) - for run in checkpoints: - # update setup time + for run in needs_save: + # update setup time and prize boundaries run.full_clean() run.save() diff --git a/tracker/forms.py b/tracker/forms.py index 0f5391512..b76f16064 100644 --- a/tracker/forms.py +++ b/tracker/forms.py @@ -210,11 +210,16 @@ class PrizeSubmissionForm(forms.Form): 'Enter the URL of an image of the prize. Please see our notes regarding prize images at the bottom of the form. Images are now required for prize submissions.' ), ) + provider = forms.CharField( + max_length=64, + label='Prize Provider', + help_text=settings.TRACKER_PRIZE_PROVIDER_TEXT, + ) creatorname = forms.CharField( max_length=64, required=False, label='Prize Creator', - help_text='Name of the creator of the prize. This is for crediting/promoting the people who created this prize (please fill this in even if you are the creator).', + help_text='The entity that created the prize, and can be totally different from the Provider. This can also be left blank.', ) creatoremail = forms.EmailField( max_length=128, @@ -267,9 +272,6 @@ def clean(self): return self.cleaned_data def save(self, event, handler=None): - provider = '' - if handler and handler.username != handler.email: - provider = handler.username prize = models.Prize.objects.create( event=event, name=self.cleaned_data['name'], @@ -280,7 +282,7 @@ def save(self, event, handler=None): minimumbid=5, image=self.cleaned_data['imageurl'], handler=handler, - provider=provider, + provider=self.cleaned_data['provider'], creator=self.cleaned_data['creatorname'], creatoremail=self.cleaned_data['creatoremail'], creatorwebsite=self.cleaned_data['creatorwebsite'], diff --git a/tracker/migrations/0078_add_twitch_donations.py b/tracker/migrations/0078_add_twitch_donations.py new file mode 100644 index 000000000..c71e6e8f1 --- /dev/null +++ b/tracker/migrations/0078_add_twitch_donations.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.3 on 2025-07-02 19:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0077_alter_prize_imagefile'), + ] + + operations = [ + migrations.AddField( + model_name='donor', + name='twitch_id', + field=models.IntegerField( + blank=True, + help_text='The unique, stable numeric ID returned by the Twitch API', + null=True, + unique=True, + verbose_name='Twitch User ID', + ), + ), + migrations.AlterField( + model_name='donation', + name='domain', + field=models.CharField( + choices=[ + ('LOCAL', 'Local'), + ('CHIPIN', 'ChipIn'), + ('PAYPAL', 'PayPal'), + ('TWITCH', 'Twitch'), + ], + default='LOCAL', + max_length=255, + ), + ), + ] diff --git a/tracker/migrations/0079_add_twitch_donation_table.py b/tracker/migrations/0079_add_twitch_donation_table.py new file mode 100644 index 000000000..580c71edd --- /dev/null +++ b/tracker/migrations/0079_add_twitch_donation_table.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.3 on 2025-10-14 19:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0078_add_twitch_donations'), + ] + + operations = [ + migrations.CreateModel( + name='TwitchDonation', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ( + 'donation', + models.OneToOneField( + 'tracker.Donation', + on_delete=models.PROTECT, + ), + ), + ( + 'twitch_id', + models.CharField( + help_text='Unique id for this donation', + max_length=256, + unique=True, + ), + ), + ( + 'campaign_id', + models.CharField( + help_text='id for the charity campaign', max_length=128 + ), + ), + ( + 'broadcaster_user_id', + models.IntegerField(help_text='Broadcaster id'), + ), + ( + 'broadcaster_user_name', + models.CharField( + help_text="Broadcaster's login name", max_length=128 + ), + ), + ( + 'broadcaster_user_login', + models.CharField( + help_text="Broadcaster's display name", max_length=128 + ), + ), + ('user_id', models.IntegerField(help_text="Donor's id")), + ( + 'user_login', + models.CharField(help_text="Donor's login name", max_length=128), + ), + ( + 'user_name', + models.CharField(help_text="Donor's display name", max_length=128), + ), + ('charity_name', models.CharField(max_length=128)), + ('charity_description', models.CharField(max_length=256)), + ('charity_logo', models.URLField(max_length=128)), + ('charity_website', models.URLField(max_length=128)), + ( + 'amount_value', + models.IntegerField(help_text='Value in smallest monetary units'), + ), + ( + 'amount_decimal_places', + models.IntegerField( + help_text='actual value = value / 10^decimal_places' + ), + ), + ( + 'amount_currency', + models.CharField(help_text='ISO-4217 code', max_length=3), + ), + ], + options={ + 'ordering': ('created_at',), + }, + ), + ] diff --git a/tracker/migrations/0084_merge_20260618_2116.py b/tracker/migrations/0084_merge_20260618_2116.py new file mode 100644 index 000000000..4dd23bb2c --- /dev/null +++ b/tracker/migrations/0084_merge_20260618_2116.py @@ -0,0 +1,13 @@ +# Generated by Django 6.0.1 on 2026-06-18 21:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0079_add_twitch_donation_table'), + ('tracker', '0083_merge_0082_add_bid_tags_0082_add_original_estimate'), + ] + + operations = [] diff --git a/tracker/migrations/0085_add_bcause_donation.py b/tracker/migrations/0085_add_bcause_donation.py new file mode 100644 index 000000000..7588a666c --- /dev/null +++ b/tracker/migrations/0085_add_bcause_donation.py @@ -0,0 +1,72 @@ +# Generated by Django 6.0.1 on 2026-08-16 20:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0084_merge_20260618_2116'), + ] + + operations = [ + migrations.AlterField( + model_name='donation', + name='domain', + field=models.CharField( + choices=[ + ('LOCAL', 'Local'), + ('CHIPIN', 'ChipIn'), + ('PAYPAL', 'PayPal'), + ('TWITCH', 'Twitch'), + ('BCAUSE', 'bcause'), + ], + default='LOCAL', + max_length=255, + ), + ), + migrations.CreateModel( + name='BcauseDonation', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('amount_cents', models.IntegerField(default=0)), + ('beneficiary_id', models.CharField(blank=True, max_length=64)), + ('currency_code', models.CharField(blank=True, max_length=8)), + ('date_valuta_utc', models.DateTimeField(blank=True, null=True)), + ('donor_name', models.CharField(blank=True, max_length=64, null=True)), + ('email', models.EmailField(blank=True, max_length=64, null=True)), + ('fee_cents', models.IntegerField(default=0)), + ('metadata', models.JSONField(blank=True, null=True)), + ('raw', models.TextField()), + ('sandbox', models.BooleanField()), + ( + 'status', + models.CharField( + choices=[('completed', 'Completed')], max_length=64, null=True + ), + ), + ( + 'transaction_id', + models.CharField(max_length=64, null=True, unique=True), + ), + ('user_id', models.CharField(blank=True, max_length=64, null=True)), + ( + 'donation', + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='tracker.donation', + ), + ), + ], + ), + ] diff --git a/tracker/migrations/0086_more_bcause_fields.py b/tracker/migrations/0086_more_bcause_fields.py new file mode 100644 index 000000000..ef24df180 --- /dev/null +++ b/tracker/migrations/0086_more_bcause_fields.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.1 on 2026-08-16 21:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0085_add_bcause_donation'), + ] + + operations = [ + migrations.AddField( + model_name='bcausedonation', + name='error', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='donor', + name='bcause_id', + field=models.CharField( + blank=True, + help_text='The unique user_id returned by the Bcause API for non-anonymous donors', + max_length=64, + null=True, + unique=True, + ), + ), + migrations.AddField( + model_name='donor', + name='ineligible', + field=models.BooleanField( + default=False, help_text='Whether this donor is ineligible for prizes' + ), + preserve_default=False, + ), + migrations.AlterField( + model_name='bcausedonation', + name='sandbox', + field=models.BooleanField(default=False), + ), + ] diff --git a/tracker/models/__init__.py b/tracker/models/__init__.py index c6c9b19ba..965809d30 100644 --- a/tracker/models/__init__.py +++ b/tracker/models/__init__.py @@ -3,11 +3,13 @@ from tracker.models.bid import Bid, BidSuggestion, DonationBid from tracker.models.country import Country, CountryRegion from tracker.models.donation import ( + BcauseDonation, Donation, DonationGroup, Donor, DonorCache, Milestone, + TwitchDonation, ) from tracker.models.event import ( Event, @@ -37,6 +39,8 @@ 'Donor', 'DonorCache', 'Milestone', + 'TwitchDonation', + 'BcauseDonation', 'Prize', 'PrizeKey', 'PrizeClaim', diff --git a/tracker/models/bid.py b/tracker/models/bid.py index 65b1175b0..4400c2e66 100644 --- a/tracker/models/bid.py +++ b/tracker/models/bid.py @@ -549,7 +549,11 @@ def check_parent(self): if not self.speedrun and self.event != self.parent.event: self.event = self.parent.event changed = True - if self.state not in ['PENDING', 'DENIED'] and self.state != self.parent.state: + if ( + self.state not in ['PENDING', 'DENIED'] + and self.state != self.parent.state + and self.parent_id != 20634 + ): self.state = self.parent.state changed = True if self.chain != self.parent.chain: @@ -656,6 +660,10 @@ def clean(self): raise ValidationError( 'Target bid and target donation must be part of the same event' ) + if self.pk is None: + total = self.donation.bids.aggregate(total=Sum('amount'))['total'] or 0 + if total + self.amount > self.donation.amount: + raise ValidationError('Attached bid amount exceeds donation total.') def save(self, *args, **kwargs): is_creating = self.pk is None @@ -682,7 +690,7 @@ def save(self, *args, **kwargs): }, ) - if self.donation.domain == 'LOCAL': + if self.donation.domain in ['LOCAL', 'TWITCH']: from .. import settings, tasks if settings.TRACKER_HAS_CELERY: diff --git a/tracker/models/donation.py b/tracker/models/donation.py index ff25b76c0..895257a34 100644 --- a/tracker/models/donation.py +++ b/tracker/models/donation.py @@ -54,7 +54,13 @@ ('ANON', 'Anonymous'), ) -DonationDomainChoices = (('LOCAL', 'Local'), ('CHIPIN', 'ChipIn'), ('PAYPAL', 'PayPal')) +DonationDomainChoices = ( + ('LOCAL', 'Local'), + ('CHIPIN', 'ChipIn'), + ('PAYPAL', 'PayPal'), + ('TWITCH', 'Twitch'), + ('BCAUSE', 'bcause'), +) LanguageChoices = ( ('un', 'Unknown'), @@ -366,7 +372,7 @@ def clean(self): else: bids = [] - bidtotal = reduce(lambda a, b: a + b, (b.amount for b in bids), Decimal(0)) + bidtotal = sum((b.amount for b in bids), Decimal(0)) if self.amount and bidtotal > self.amount: errors['amount'].append( 'Bid total is greater than donation amount: %s > %s' @@ -399,6 +405,8 @@ def save(self, *args, **kwargs): if self.domain == 'LOCAL': # local donations are always complete, duh self.cleared_at = self.timereceived self.transactionstate = 'COMPLETED' + if self.domain == 'TWITCH': + self.transactionstate = 'COMPLETED' # reminder that this does not run during migrations tests, so you have to provide the domainId yourself if not self.domainId: self.domainId = f'{int(time.time())}-{random.getrandbits(128)}' @@ -412,7 +420,7 @@ def save(self, *args, **kwargs): # TODO: language detection again? self.commentlanguage = 'un' - post = self.id is None and self.domain == 'LOCAL' + post = self.id is None and self.domain in ['LOCAL', 'TWITCH'] super(Donation, self).save(*args, **kwargs) @@ -499,6 +507,24 @@ class Donor(models.Model): ), default='CURR', ) + twitch_id = models.IntegerField( + unique=True, + null=True, + blank=True, + verbose_name='Twitch User ID', + help_text='The unique, stable numeric ID returned by the Twitch API', + ) + ineligible = models.BooleanField( + default=False, + help_text='Whether this donor is ineligible for prizes', + ) + bcause_id = models.CharField( + max_length=64, + unique=True, + null=True, + blank=True, + help_text='The unique user_id returned by the Bcause API for non-anonymous donors', + ) class Meta: app_label = 'tracker' @@ -822,3 +848,67 @@ class Meta: app_label = 'tracker' ordering = ('event', 'amount') unique_together = ('event', 'amount') + + +class TwitchDonation(models.Model): + class Meta: + app_label = 'tracker' + ordering = ('created_at',) + + created_at = models.DateTimeField(auto_now_add=True) + donation = models.OneToOneField('tracker.Donation', on_delete=models.PROTECT) + twitch_id = models.CharField( + max_length=256, unique=True, help_text='Unique id for this donation' + ) + campaign_id = models.CharField( + max_length=128, help_text='id for the charity campaign' + ) + broadcaster_user_id = models.IntegerField(help_text='Broadcaster id') + broadcaster_user_name = models.CharField( + max_length=128, help_text='Broadcaster\'s login name' + ) + broadcaster_user_login = models.CharField( + max_length=128, help_text='Broadcaster\'s display name' + ) + user_id = models.IntegerField(help_text='Donor\'s id') + user_login = models.CharField(max_length=128, help_text='Donor\'s login name') + user_name = models.CharField(max_length=128, help_text='Donor\'s display name') + charity_name = models.CharField(max_length=128) + charity_description = models.CharField( + max_length=256 + ) # TODO: what's the actual size limit here? + charity_logo = models.URLField(max_length=128) + charity_website = models.URLField(max_length=128) + amount_value = models.IntegerField(help_text='Value in smallest monetary units') + amount_decimal_places = models.IntegerField( + help_text='actual value = value / 10^decimal_places' + ) + amount_currency = models.CharField(max_length=3, help_text='ISO-4217 code') + + def __str__(self): + return f'Twitch Donation: f{self.twitch_id}' + + +class BcauseDonation(models.Model): + amount_cents = models.IntegerField(default=0) + beneficiary_id = models.CharField(max_length=64, blank=True) + currency_code = models.CharField(max_length=8, blank=True) + date_valuta_utc = models.DateTimeField(blank=True, null=True) + donation = models.OneToOneField( + 'tracker.Donation', null=True, on_delete=models.SET_NULL + ) + donor_name = models.CharField(max_length=64, blank=True, null=True) + email = models.EmailField(max_length=64, blank=True, null=True) + error = models.BooleanField(default=False) + fee_cents = models.IntegerField(default=0) + metadata = models.JSONField(blank=True, null=True) + raw = models.TextField() + sandbox = models.BooleanField(default=False) + status = models.CharField( + max_length=64, choices=(('completed', 'Completed'),), null=True + ) + transaction_id = models.CharField(max_length=64, unique=True, null=True) + user_id = models.CharField(max_length=64, blank=True, null=True) + + def __str__(self): + return 'Error' if self.error else self.transaction_id or 'Unknown' diff --git a/tracker/models/event.py b/tracker/models/event.py index 826d0edd9..758046742 100644 --- a/tracker/models/event.py +++ b/tracker/models/event.py @@ -670,13 +670,16 @@ def clean(self): else: self.starttime = self.anchor_time if self.id and ( - self.prize_start.exclude(endrun=self) - .filter(endrun__order__lt=self.order) - .exists() - or self.prize_end.exclude(startrun=self) - .filter(startrun__order__gt=self.order) - .exists() + ( + self.prize_start.exclude(endrun=self).filter( + endrun__order__lt=self.order + ) + | self.prize_end.exclude(startrun=self).filter( + startrun__order__gt=self.order + ) + ).exists() ): + errors['order'].append( 'Desired order would invert at least one prize span' ) diff --git a/tracker/models/prize.py b/tracker/models/prize.py index 9f1e0eba7..0d721cf64 100644 --- a/tracker/models/prize.py +++ b/tracker/models/prize.py @@ -3,7 +3,7 @@ import operator from collections import defaultdict from decimal import Decimal -from functools import reduce +from functools import cached_property, reduce from django.contrib.auth.models import User from django.core.exceptions import ( @@ -14,7 +14,7 @@ from django.db import models from django.db.models import Case, Count, F, Q, Sum, When from django.db.models.functions import Coalesce -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse @@ -590,10 +590,11 @@ def save(self, *args, **kwargs): self.claims.update(acceptemailsentcount=F('acceptcount')) super(Prize, self).save(*args, **kwargs) - def eligible_donors(self) -> dict[models.Model, Decimal]: + def eligible_donors(self, runs=None) -> dict[models.Model, Decimal]: donations = Donation.objects.filter( - event=self.event, transactionstate='COMPLETED' + event=self.event, transactionstate='COMPLETED', donor__ineligible=False ).select_related('donor') + runs = runs or self._runs # Apply the country/region filter to the drawing if self.custom_country_filter: @@ -617,10 +618,10 @@ def eligible_donors(self) -> dict[models.Model, Decimal]: ) donations = donations.exclude(donor__in=[w.winner for w in self.claims.all()]) - if self.has_draw_time(): + if self._has_draw_time(): donations = donations.filter( - timereceived__gte=self.start_draw_time(), - timereceived__lte=self.end_draw_time(), + timereceived__gte=self.start_draw_time(runs), + timereceived__lte=self.end_draw_time(runs), ) donors = defaultdict(lambda: Decimal('0.0')) for donation in donations: @@ -679,15 +680,22 @@ def is_country_region_disallowed(self, country, region): return True return False - def has_draw_time(self): - return self.start_draw_time() and self.end_draw_time() + def _has_draw_time(self): + return self.startrun_id or self.starttime - def start_draw_time(self): - if self.startrun_id: - if self.prev_run: + @cached_property + def _runs(self): + return SpeedRun.objects.filter(event=self.event_id) + + def start_draw_time(self, runs): + if self.startrun: + prev_run = next( + (r for r in runs if r.order == self.startrun.order - 1), None + ) + if prev_run: # allow some slop into the previous run's setup time in case the run starts 'late' - return self.prev_run.endtime - datetime.timedelta( - milliseconds=self.prev_run.setup_time_ms + return prev_run.endtime - datetime.timedelta( + milliseconds=prev_run.setup_time_ms ) return self.startrun.start_time_utc elif self.starttime: @@ -695,9 +703,10 @@ def start_draw_time(self): else: return None - def end_draw_time(self): - if self.endrun_id: - if not self.next_run: + def end_draw_time(self, runs): + if self.endrun: + next_run = next((r for r in runs if r.order == self.endrun.order + 1), None) + if not next_run: # covers finale speeches return self.endrun.end_time_utc + datetime.timedelta(hours=1) return self.endrun.end_time_utc @@ -706,11 +715,6 @@ def end_draw_time(self): else: return None - def contains_draw_time(self, time): - return not self.has_draw_time() or ( - self.start_draw_time() <= time <= self.end_draw_time() - ) - def current_win_count(self): return sum( x.pendingcount + x.acceptcount @@ -751,45 +755,6 @@ def get_winners(self): return [w.winner for w in self.get_prize_claims()] -@receiver(post_save, sender=SpeedRun) -def fix_prev_and_next_run_save(sender, instance, created, raw, using, **kwargs): - if raw: - return - fix_prev_and_next_run(instance, using) - - -@receiver(post_delete, sender=SpeedRun) -def fix_prev_and_next_run_delete(sender, instance, using, **kwargs): - fix_prev_and_next_run(instance, using) - - -def fix_prev_and_next_run(instance, using): - prev_run = instance.order and ( - SpeedRun.objects.filter(event=instance.event_id, order__lt=instance.order) - .using(using) - .order_by('order') - .last() - ) - next_run = instance.order and ( - SpeedRun.objects.filter(event=instance.event_id, order__gt=instance.order) - .using(using) - .order_by('order') - .first() - ) - prizes = Prize.objects.using(using).filter( - Q(prev_run=instance) - | Q(next_run=instance) - | Q(startrun=instance) - | Q(endrun=instance) - ) - if prev_run: - prizes = prizes | Prize.objects.using(using).filter( - Q(startrun=next_run) | Q(endrun=prev_run) - ) - for prize in prizes: - prize.save(using=using) - - class PrizeKey(models.Model): prize = models.ForeignKey( 'Prize', on_delete=models.PROTECT, related_name='prize_keys' diff --git a/tracker/settings.py b/tracker/settings.py index bf2ea2b47..7ce49886f 100644 --- a/tracker/settings.py +++ b/tracker/settings.py @@ -102,6 +102,22 @@ def TRACKER_PUBLIC_SITE_ID(self): else: return None + @property + def TRACKER_PRIZE_PROVIDER_TEXT(self): + return getattr( + settings, + 'TRACKER_PRIZE_PROVIDER_TEXT', + 'The prize will be credited to this entity on the event stream and the website. This can be one or more individuals. If you wish to remain anonymous, please write "Anonymous" here.', + ) + + @property + def TRACKER_BCAUSE_SIGNING_SECRET(self): + return getattr(settings, 'TRACKER_BCAUSE_SIGNING_SECRET', b'') + + @property + def TRACKER_BCAUSE_EVENT_ID(self): + return getattr(settings, 'TRACKER_BCAUSE_EVENT_ID', 0) + # pass everything else through for convenience def __getattr__(self, item): return getattr(settings, item) @@ -309,4 +325,12 @@ def tracker_settings_checks(app_configs, **kwargs): id='tracker.I118', ) ) + if not isinstance(TrackerSettings().TRACKER_BCAUSE_SIGNING_SECRET, bytes): + messages.append( + Error('TRACKER_BCAUSE_SIGNING_SECRET should be a bytes.', id='tracker.E119') + ) + if not isinstance(TrackerSettings().TRACKER_BCAUSE_EVENT_ID, int): + messages.append( + Error('TRACKER_BCAUSE_EVENT_ID should be an int.', id='tracker.E120') + ) return messages diff --git a/tracker/templates/base.html b/tracker/templates/base.html index 1ff4a46ff..df1819f21 100644 --- a/tracker/templates/base.html +++ b/tracker/templates/base.html @@ -102,7 +102,7 @@ {% if user and user.is_authenticated %}
  • {% trans "Self Service" %}
  • -
  • {% trans "Log Out" %}
  • +
  • {% trans "Log Out" %}
  • {% endif %} diff --git a/tracker/templates/tracker/logout.html b/tracker/templates/tracker/logout.html deleted file mode 100644 index 2c68028b8..000000000 --- a/tracker/templates/tracker/logout.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - - -{% block title %} - Tracker User Logout -{% endblock %} - -{% block nav %} -{% endblock %} - -{% block content %} -Logged out. -{% endblock %} diff --git a/tracker/templates/tracker/logout_form.html b/tracker/templates/tracker/logout_form.html new file mode 100644 index 000000000..9167a56e1 --- /dev/null +++ b/tracker/templates/tracker/logout_form.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% load i18n %} + + +{% block title %} + Tracker User Logout +{% endblock %} + +{% block nav %} +{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/tracker/templates/tracker/user_index.html b/tracker/templates/tracker/user_index.html index 4c666b887..0420d2dc6 100644 --- a/tracker/templates/tracker/user_index.html +++ b/tracker/templates/tracker/user_index.html @@ -51,7 +51,7 @@
    {{ event.name }} {% {% endfor %}
    -
    Log Out +Log Out
    {% endblock %} diff --git a/tracker/urls.py b/tracker/urls.py index 8596e0d71..25914ceff 100644 --- a/tracker/urls.py +++ b/tracker/urls.py @@ -80,6 +80,11 @@ LogoutView.as_view(next_page='tracker:login'), name='logout', ), + path( + 'user/logout_form/', + user.logout_form, + name='logout_form', + ), path( 'user/password_reset/', PasswordResetView.as_view( diff --git a/tracker/views/user.py b/tracker/views/user.py index 28a42b5a9..c122d222e 100644 --- a/tracker/views/user.py +++ b/tracker/views/user.py @@ -81,6 +81,13 @@ def user_prize(request, prize): ) +def logout_form(request): + return views_common.tracker_response( + request, + 'tracker/logout_form.html', + ) + + def prize_winner(request, prize_win): auth_code = request.GET.get('auth_code', None) try: @@ -134,8 +141,14 @@ def submit_prize(request, event): if event.archived: raise Http404 + initial = {'event': event.id} + if request.user.username != getattr( + request.user, request.user.get_email_field_name(), '' + ): + initial['provider'] = request.user.username + form = forms.PrizeSubmissionForm( - initial={'event': event.id}, + initial=initial, data=request.POST if request.method == 'POST' else None, ) diff --git a/ts_api_check.py b/ts_api_check.py index 3e7188b7b..b817e92f3 100644 --- a/ts_api_check.py +++ b/ts_api_check.py @@ -41,6 +41,12 @@ def ts_check(): parts = parts[:-1] if parts[-2] == 'donations' and parts[-1] in {'flagged', 'unprocessed'}: parts = parts[:-1] + if ( + parts[-3] == 'donations' + and parts[-1] == 'bids' + and r['method'] == 'POST' + ): + parts = ['donationBid'] method = r['method'].lower().capitalize() if parts[-1][-1] == 's': parts[-1] = parts[-1][:-1]