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]