diff --git a/README.md b/README.md index ae386ef0f..9e90c352b 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ ## Requirements -- Python 3.9 to 3.13 +- Python 3.10 to 3.14 - Django 4.2, 5.1, or 5.2 Additionally, if you are planning on developing, and/or building the JS bundles yourself: -- Node (only LTS versions are officially supported, currently 18, 20, and 22) +- Node (only LTS versions are officially supported, currently 20, 22, and 24) - `yarn` (`npm i -g yarn`) - `pre-commit` (`pip install pre-commit`) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c8e18c571..ccffaef78 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: Oldest: - PYTHON_VERSION: '3.9' + PYTHON_VERSION: '3.10' DJANGO_VERSION: '4.2' Django42: PYTHON_VERSION: '3.12' @@ -20,18 +20,18 @@ jobs: Django51: PYTHON_VERSION: '3.13' DJANGO_VERSION: '5.1' - Python3A: - PYTHON_VERSION: '3.10' - DJANGO_VERSION: '5.2' Python3B: PYTHON_VERSION: '3.11' DJANGO_VERSION: '5.2' Python3C: PYTHON_VERSION: '3.12' DJANGO_VERSION: '5.2' - Latest: + Python3D: PYTHON_VERSION: '3.13' DJANGO_VERSION: '5.2' + Latest: + PYTHON_VERSION: '3.14' + DJANGO_VERSION: '5.2' steps: - task: UsePythonVersion@0 @@ -165,12 +165,12 @@ jobs: YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn strategy: matrix: - Node18: - NODE_VERSION: 18 Node20: NODE_VERSION: 20 Node22: NODE_VERSION: 22 + Node24: + NODE_VERSION: 24 steps: - task: NodeTool@0 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/public/apiv2/APITypes.ts b/bundles/public/apiv2/APITypes.ts index 654dec6ef..408ec8a25 100644 --- a/bundles/public/apiv2/APITypes.ts +++ b/bundles/public/apiv2/APITypes.ts @@ -189,8 +189,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/Models.ts b/bundles/public/apiv2/Models.ts index b14d4f1b8..0d97fa862 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/pyproject.toml b/pyproject.toml index ab92f0e3b..e8d982f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,11 @@ classifiers = [ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Python Modules', ] @@ -44,7 +44,7 @@ dynamic = ['version'] license-files = ['LICENSE', 'tracker/static/gen/**/*.LICENSE.txt'] name = 'django-donation-tracker' readme = 'README.md' -requires-python = '>= 3.9' +requires-python = '>= 3.10' [project.optional-dependencies] development = [ 'daphne~=4.0', 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 110d5ec13..ff0823cea 100644 --- a/tests/apiv2/test_runs.py +++ b/tests/apiv2/test_runs.py @@ -1,10 +1,11 @@ import datetime +from itertools import pairwise from typing import Iterable, List, Optional, Union from django.db.models import F import tracker.models.tag -from tracker import compat, models +from tracker import models from tracker.api import messages from tracker.api.serializers import ( EventSerializer, @@ -582,12 +583,12 @@ def assertRunsInOrder( for r in all_runs: r.refresh_from_db() - for a, b in compat.pairwise(all_runs): + for a, b in pairwise(all_runs): self.assertEqual( a.event_id, b.event_id, msg='Runs are from different events' ) - for n, (a, b) in enumerate(compat.pairwise(ordered), start=1): + for n, (a, b) in enumerate(pairwise(ordered), start=1): with self.subTest(f'ordered {n}: {a}, {b}'): self.assertEqual( a.order, n, msg=f'Order was wrong {n},{a.order},{b.order}' diff --git a/tests/randgen.py b/tests/randgen.py index bf83a7c6d..9fb547767 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: 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/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 b1536f37e..aa85cfdb1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import contextlib import csv import datetime @@ -687,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/donation.py b/tracker/admin/donation.py index a44984192..60028fd30 100644 --- a/tracker/admin/donation.py +++ b/tracker/admin/donation.py @@ -547,4 +547,13 @@ 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.site.register(models.DonationGroup, AbstractTagAdmin) diff --git a/tracker/admin/prize.py b/tracker/admin/prize.py index 859c0dafb..414097e99 100644 --- a/tracker/admin/prize.py +++ b/tracker/admin/prize.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import datetime from itertools import groupby from typing import Any, Collection, Iterable, Mapping, Optional, Sequence diff --git a/tracker/api/permissions.py b/tracker/api/permissions.py index e85878447..34fbf5d80 100644 --- a/tracker/api/permissions.py +++ b/tracker/api/permissions.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import typing as t from django.http import Http404 @@ -7,6 +5,7 @@ SAFE_METHODS, BasePermission, DjangoModelPermissions, + DjangoModelPermissionsOrAnonReadOnly, ) from rest_framework.request import Request @@ -225,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 6f64a544e..b71f99e2d 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -12,7 +12,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 QuerySet, Sum from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ErrorDetail, ValidationError @@ -739,6 +740,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 @@ -752,6 +755,8 @@ class Meta: 'bid_count', 'bid_total', 'amount', + 'parent', + 'name', ) def __init__(self, *args, **kwargs): @@ -806,11 +811,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) diff --git a/tracker/api/util.py b/tracker/api/util.py index ac4e72e31..d520aba2a 100644 --- a/tracker/api/util.py +++ b/tracker/api/util.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from datetime import datetime from rest_framework.exceptions import ParseError diff --git a/tracker/api/views/donate.py b/tracker/api/views/donate.py index 3bef928a1..13c5f4f52 100644 --- a/tracker/api/views/donate.py +++ b/tracker/api/views/donate.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import contextlib import logging import secrets @@ -7,6 +5,7 @@ from collections import defaultdict from decimal import Decimal from functools import reduce +from itertools import pairwise from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError as DjangoValidationError @@ -29,14 +28,14 @@ ) 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 pairwise, reverse +from tracker.compat import reverse from tracker.models import Bid, Donation, Event -from tracker.models.donation import Donor +from tracker.models.donation import Donor, TwitchDonation logger = logging.getLogger(__file__) @@ -162,6 +161,26 @@ def to_representation(self, instance): return ret +class TwitchDonationSerializer(EnsureSerializableMixin, ModelSerializer): + class Meta: + model = TwitchDonation + exclude = ('donation',) + + 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, @@ -171,9 +190,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( @@ -184,10 +204,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): @@ -210,7 +247,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' @@ -271,14 +308,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( @@ -288,6 +325,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( @@ -305,7 +369,7 @@ 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 @@ -327,8 +391,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'], @@ -343,29 +408,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() 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/compat.py b/tracker/compat.py index 6c2a2f834..b17219bf5 100644 --- a/tracker/compat.py +++ b/tracker/compat.py @@ -1,20 +1,8 @@ from inspect import signature -try: - from itertools import pairwise -except ImportError: - # TODO: remove when 3.10 is oldest supported version - - def pairwise(iterable): - import itertools - - # pairwise('ABCDEFG') --> AB BC CD DE EF FG - a, b = itertools.tee(iterable) - next(b, None) - return zip(a, b) - def reverse(*args, query=None, fragment=None, **kwargs): + # TODO: remove when 5.2 is the oldest supported Django version from django.urls import reverse sig = signature(reverse) diff --git a/tracker/forms.py b/tracker/forms.py index 68f6d958f..0f5391512 100644 --- a/tracker/forms.py +++ b/tracker/forms.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import datetime import re from collections import defaultdict 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/0082_merge_20251014_1910.py b/tracker/migrations/0082_merge_20251014_1910.py new file mode 100644 index 000000000..ac94db9cc --- /dev/null +++ b/tracker/migrations/0082_merge_20251014_1910.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.3 on 2025-10-14 19:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0079_add_twitch_donation_table'), + ('tracker', '0081_merge_20250731_2009'), + ] + + operations = [] diff --git a/tracker/models/__init__.py b/tracker/models/__init__.py index c6c9b19ba..a5e122a32 100644 --- a/tracker/models/__init__.py +++ b/tracker/models/__init__.py @@ -8,6 +8,7 @@ Donor, DonorCache, Milestone, + TwitchDonation, ) from tracker.models.event import ( Event, @@ -37,6 +38,7 @@ 'Donor', 'DonorCache', 'Milestone', + 'TwitchDonation', 'Prize', 'PrizeKey', 'PrizeClaim', diff --git a/tracker/models/bid.py b/tracker/models/bid.py index b32b01077..77ac1674e 100644 --- a/tracker/models/bid.py +++ b/tracker/models/bid.py @@ -655,6 +655,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 @@ -681,7 +685,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..d386dd7ab 100644 --- a/tracker/models/donation.py +++ b/tracker/models/donation.py @@ -54,7 +54,12 @@ ('ANON', 'Anonymous'), ) -DonationDomainChoices = (('LOCAL', 'Local'), ('CHIPIN', 'ChipIn'), ('PAYPAL', 'PayPal')) +DonationDomainChoices = ( + ('LOCAL', 'Local'), + ('CHIPIN', 'ChipIn'), + ('PAYPAL', 'PayPal'), + ('TWITCH', 'Twitch'), +) LanguageChoices = ( ('un', 'Unknown'), @@ -366,7 +371,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 +404,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 +419,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 +506,13 @@ 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', + ) class Meta: app_label = 'tracker' @@ -822,3 +836,42 @@ 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}' diff --git a/tracker/models/event.py b/tracker/models/event.py index 4265b5d04..691abef02 100644 --- a/tracker/models/event.py +++ b/tracker/models/event.py @@ -13,7 +13,7 @@ from django.urls import reverse from timezone_field import TimeZoneField -from tracker import compat, settings, util +from tracker import settings, util from tracker.validators import nonzero, positive, validate_locale from .fields import TimestampField @@ -626,7 +626,7 @@ def clean(self): 'Next anchor in the order would occur before this one' ) else: - for c, n in compat.pairwise( + for c, n in itertools.pairwise( itertools.chain( [self], SpeedRun.objects.filter( diff --git a/tracker/templatetags/donation_tags.py b/tracker/templatetags/donation_tags.py index 759702d4b..2f4cde592 100644 --- a/tracker/templatetags/donation_tags.py +++ b/tracker/templatetags/donation_tags.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import urllib.parse from decimal import Decimal diff --git a/tracker/util.py b/tracker/util.py index 160a9b421..391f21492 100644 --- a/tracker/util.py +++ b/tracker/util.py @@ -6,9 +6,6 @@ can use it in migrations, or inside the `model` files """ -# TODO: remove when 3.10 is lowest supported version -from __future__ import annotations - import collections.abc import datetime import itertools 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]