diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0110be27f..b882de3c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,6 +15,11 @@ repos: rev: 7.1.0 hooks: - id: flake8 +- repo: https://github.com/pycqa/autoflake + rev: v2.3.1 + hooks: + - id: autoflake + args: [--remove-all-unused-imports, --in-place] - repo: local hooks: - id: prettier diff --git a/tests/apiv2/test_donation_bids.py b/tests/apiv2/test_donation_bids.py new file mode 100644 index 000000000..8b01781a6 --- /dev/null +++ b/tests/apiv2/test_donation_bids.py @@ -0,0 +1,257 @@ +from decimal import Decimal + +from tests import randgen +from tests.util import APITestCase +from tracker import models +from tracker.api.serializers import DonationBidSerializer + + +class TestDonationBids(APITestCase): + model_name = 'donationbid' + serializer_class = DonationBidSerializer + extra_serializer_kwargs = {'with_permissions': 'tracker.view_hidden_bid'} + view_user_permissions = ['view_bid'] + + def _format_donation_bid(self, bid): + bid.refresh_from_db() + return { + 'type': 'donationbid', + 'id': bid.id, + 'donation': bid.donation_id, + 'bid': bid.bid_id, + 'bid_name': bid.bid.fullname(), + 'bid_state': bid.bid.state, + 'amount': str(bid.amount), + } + + def setUp(self): + super().setUp() + self.donor = randgen.generate_donor(self.rand) + self.donor.save() + self.donation = randgen.generate_donation( + self.rand, min_amount=10, max_amount=25 + ) + self.donation.save() + self.other_donation = randgen.generate_donation(self.rand) + self.other_donation.save() + self.pending_donation = randgen.generate_donation( + self.rand, domain='PAYPAL', transactionstate='PENDING' + ) + self.pending_donation.save() + self.opened_bid = randgen.generate_bid( + self.rand, + event=self.event, + state='OPENED', + allow_children=True, + allowuseroptions=True, + )[0] + self.opened_bid.save() + self.opened_child = randgen.generate_bid( + self.rand, parent=self.opened_bid, state='OPENED', allow_children=False + )[0] + self.opened_child.save() + self.denied_child = randgen.generate_bid( + self.rand, parent=self.opened_bid, state='DENIED', allow_children=False + )[0] + self.denied_child.save() + self.opened_child_bid = models.DonationBid.objects.create( + donation=self.donation, + bid=self.opened_child, + amount=Decimal(self.rand.uniform(5.0, 9.5)), + ) + self.denied_child_bid = models.DonationBid.objects.create( + donation=self.donation, + bid=self.denied_child, + amount=self.donation.amount - self.opened_child_bid.amount, + ) + self.other_child_bid = models.DonationBid.objects.create( + donation=self.other_donation, + bid=self.opened_child, + amount=self.other_donation.amount / 3, + ) + self.hidden_bid, hidden_children = randgen.generate_bid( + self.rand, + event=self.event, + min_children=1, + max_children=1, + state='HIDDEN', + allow_children=True, + allowuseroptions=True, + ) + self.hidden_child = hidden_children[0][0] + self.hidden_bid.save() + self.hidden_child.save() + self.hidden_denied_child = randgen.generate_bid( + self.rand, parent=self.hidden_bid, state='DENIED', allow_children=False + )[0] + self.hidden_denied_child.save() + self.hidden_child_bid = models.DonationBid.objects.create( + donation=self.other_donation, + bid=self.hidden_child, + amount=self.other_donation.amount / 3, + ) + self.hidden_denied_child_bid = models.DonationBid.objects.create( + donation=self.other_donation, + bid=self.hidden_denied_child, + amount=self.other_donation.amount / 3, + ) + self.pending_donation_bid = models.DonationBid.objects.create( + donation=self.pending_donation, + bid=self.opened_child, + amount=self.pending_donation.amount, + ) + + def test_fetch(self): + with self.saveSnapshot(): + with self.subTest('via donation'): + data = self.get_noun( + 'bids', self.donation, model_name='donations', user=self.view_user + ) + self.assertExactV2Models([self.opened_child_bid], data['results']) + + data = self.get_noun( + 'bids', + self.donation, + data={'all': ''}, + model_name='donations', + user=self.view_user, + ) + self.assertExactV2Models( + [self.opened_child_bid, self.denied_child_bid], data['results'] + ) + + data = self.get_noun( + 'bids', + self.other_donation, + data={'all': ''}, + model_name='donations', + user=self.view_user, + ) + self.assertExactV2Models( + [ + self.other_child_bid, + self.hidden_child_bid, + self.hidden_denied_child_bid, + ], + data['results'], + ) + + with self.subTest('via parent bid'): + data = self.get_noun( + 'donations', + self.opened_bid, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models( + [self.opened_child_bid, self.other_child_bid], data['results'] + ) + + data = self.get_noun( + 'donations', + self.opened_bid, + data={'all': ''}, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models( + [ + self.opened_child_bid, + self.denied_child_bid, + self.other_child_bid, + ], + data['results'], + ) + + data = self.get_noun( + 'donations', + self.hidden_bid, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models( + [self.hidden_child_bid, self.hidden_denied_child_bid], + data['results'], + ) + + with self.subTest('via child bid'): + data = self.get_noun( + 'donations', + self.opened_child, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models( + [self.opened_child_bid, self.other_child_bid], data['results'] + ) + + data = self.get_noun( + 'donations', + self.denied_child, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models([self.denied_child_bid], data['results']) + + data = self.get_noun( + 'donations', + self.hidden_child, + model_name='bid', + user=self.view_user, + ) + self.assertExactV2Models([self.hidden_child_bid], data['results']) + + with self.subTest('error cases'): + # strictly speaking, 403, but easier to write the permission check this way + self.get_noun( + 'bids', + self.donation, + data={'all': ''}, + model_name='donations', + user=None, + status_code=404, + ) + self.get_noun( + 'bids', + self.pending_donation, + model_name='donations', + user=None, + status_code=404, + ) + self.get_noun( + 'donations', + self.opened_bid, + data={'all': ''}, + model_name='bid', + user=None, + status_code=404, + ) + self.get_noun( + 'donations', + self.denied_child, + model_name='bid', + user=None, + status_code=404, + ) + self.get_noun( + 'donations', + self.hidden_bid, + model_name='bid', + user=None, + status_code=404, + ) + + def test_serializer(self): + with self.assertRaises(AssertionError): + print(DonationBidSerializer(self.hidden_child_bid).data) + + data = DonationBidSerializer( + self.hidden_child_bid, with_permissions=('tracker.view_hidden_bid') + ).data + self.assertEqual(data, self._format_donation_bid(self.hidden_child_bid)) + + data = DonationBidSerializer( + [self.opened_child_bid, self.other_child_bid], many=True + ).data + self.assertEqual(data[0], self._format_donation_bid(self.opened_child_bid)) + self.assertEqual(data[1], self._format_donation_bid(self.other_child_bid)) diff --git a/tests/randgen.py b/tests/randgen.py index 1718eb4ab..805c853f9 100644 --- a/tests/randgen.py +++ b/tests/randgen.py @@ -284,6 +284,7 @@ def generate_bid( rand, *, allow_children=None, + allowuseroptions=None, min_children=2, max_children=5, max_depth=2, @@ -299,6 +300,8 @@ def generate_bid( bid = Bid() bid.description = random_bid_description(rand, bid.name) assert run or event or parent, 'Need at least one of run, event, or parent' + if allowuseroptions is not None: + bid.allowuseroptions = allowuseroptions if parent: bid.parent = parent bid.speedrun = parent.speedrun @@ -411,12 +414,12 @@ def generate_donation( ), 'Local donations must be specified as COMPLETED' if not no_donor: - if not donor: + if donor is None: if donors: donor = rand.choice(donors) else: + assert Donor.objects.exists(), 'No donor provided and none exist' donor = rand.choice(Donor.objects.all()) - assert donor, 'No donor provided and none exist' donation.donor = donor donation.clean() return donation diff --git a/tests/util.py b/tests/util.py index b7eca2078..0b5090f1e 100644 --- a/tests/util.py +++ b/tests/util.py @@ -223,6 +223,7 @@ class APITestCase(TransactionTestCase, AssertionHelpers): fixtures = ['countries'] model_name = None serializer_class = None + extra_serializer_kwargs = {} format_model = None view_user_permissions = [] # trickles to add_user and locked_user add_user_permissions = [] # trickles to locked_user @@ -705,7 +706,8 @@ def assertV2ModelPresent( ), 'no serializer_class provided and raw model was passed' expected_model.refresh_from_db() expected_model = self.serializer_class( - expected_model, **serializer_kwargs or {} + expected_model, + **{**(serializer_kwargs or {}), **self.extra_serializer_kwargs}, ).data # FIXME: gross hack from tracker.api.serializers import EventNestedSerializerMixin @@ -717,8 +719,8 @@ def assertV2ModelPresent( ( m for m in data - if expected_model['type'] == m['type'] - and expected_model['id'] == m['id'] + if expected_model['type'] == m.get('type', None) + and expected_model['id'] == m.get('id', None) ), None, ) @@ -748,15 +750,17 @@ def assertV2ModelNotPresent(self, unexpected_model, data): assert hasattr( self, 'serializer_class' ), 'no serializer_class provided and raw model was passed' - unexpected_model = self.serializer_class(unexpected_model).data + unexpected_model = self.serializer_class( + unexpected_model, **self.extra_serializer_kwargs + ).data if ( next( ( model for model in data if ( - model['id'] == unexpected_model['id'] - and model['type'] == unexpected_model['type'] + model.get('id', None) == unexpected_model['id'] + and model.get('type', None) == unexpected_model['type'] ) ), None, @@ -768,6 +772,46 @@ def assertV2ModelNotPresent(self, unexpected_model, data): % (unexpected_model['type'], unexpected_model['id']) ) + def assertExactV2Models( + self, + expected_models, + unexpected_models, + data=None, + *, + exact_count=True, + msg=None, + **kwargs, + ): + """similar to V2ModelPresent, but allows you to explicitly check that certain models are not present + by default it also asserts exact count, but you can turn that off if you want + """ + problems = [] + if data is None: + data = unexpected_models + unexpected_models = [] + if exact_count and len(data) != len(expected_models): + problems.append( + f'Data length mismatch, expected {len(expected_models)}, got {len(data)}' + ) + for m in expected_models: + try: + self.assertV2ModelPresent(m, data, **kwargs) + except AssertionError as e: + problems.append(str(e)) + for m in unexpected_models: + try: + self.assertV2ModelNotPresent(m, data) + except AssertionError as e: + problems.append(str(e)) + if problems: + parts = [] + if msg: + parts.append(msg) + parts.append(f'Had {len(problems)} problem(s) asserting model data:') + parts.extend(problems) + + self.fail('\n'.join(parts)) + def assertLogEntry(self, model_name: str, pk: int, change_type, message: str): from django.contrib.admin.models import LogEntry diff --git a/tracker/api/permissions.py b/tracker/api/permissions.py index 98728e9cd..45cc2b7f4 100644 --- a/tracker/api/permissions.py +++ b/tracker/api/permissions.py @@ -82,6 +82,26 @@ def has_object_permission(self, request: Request, view: t.Callable, obj: t.Any): ) +class DonationBidStatePermission(BasePermission): + PUBLIC_STATES = models.Bid.PUBLIC_STATES + message = messages.GENERIC_NOT_FOUND + code = messages.UNAUTHORIZED_OBJECT_CODE + + def has_permission(self, request, view): + has_perm = any( + request.user.has_perm(f'tracker.{p}') + for p in ('view_hidden_bid', 'change_bid', 'view_bid') + ) + return ( + super().has_permission(request, view) + and has_perm + or ( + ((view.bid is None or view.bid.state in self.PUBLIC_STATES)) + and ('all' not in request.query_params) + ) + ) + + class TechNotesPermission(BasePermission): message = messages.UNAUTHORIZED_FIELD code = messages.UNAUTHORIZED_FIELD_CODE diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index 98831efa7..feecbaa19 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -77,7 +77,7 @@ def _coalesce_validation_errors(errors, ignored=None): raise ValidationError(all_errors) -class WithPermissionsSerializerMixin: +class SerializerWithPermissionsMixin: def __init__(self, *args, with_permissions=(), **kwargs): if isinstance(with_permissions, str): with_permissions = (with_permissions,) @@ -360,7 +360,7 @@ def validate(self, data): class BidSerializer( - WithPermissionsSerializerMixin, EventNestedSerializerMixin, TrackerModelSerializer + SerializerWithPermissionsMixin, EventNestedSerializerMixin, TrackerModelSerializer ): type = ClassNameField() @@ -505,19 +505,39 @@ def validate(self, attrs): return super().validate(attrs) -class DonationBidSerializer(serializers.ModelSerializer): +class DonationBidSerializer(SerializerWithPermissionsMixin, TrackerModelSerializer): type = ClassNameField() bid_name = serializers.SerializerMethodField() + bid_state = serializers.SerializerMethodField() class Meta: model = DonationBid - fields = ('type', 'id', 'donation', 'bid', 'bid_name', 'amount') + fields = ('type', 'id', 'donation', 'bid', 'bid_name', 'bid_state', 'amount') def get_bid_name(self, donation_bid: DonationBid): return donation_bid.bid.fullname() + def get_bid_state(self, donation_bid: DonationBid): + return donation_bid.bid.state -class DonationSerializer(WithPermissionsSerializerMixin, serializers.ModelSerializer): + def _has_permission(self, instance): + return ( + any( + f'tracker.{p}' in self.permissions + for p in ('view_hidden_bid', 'change_bid', 'view_bid') + ) + or instance.bid.state in Bid.PUBLIC_STATES + ) + + def to_representation(self, instance): + # final check + assert self._has_permission( + instance + ), f'tried to serialize a hidden donation bid without permission {self.permissions}' + return super().to_representation(instance) + + +class DonationSerializer(SerializerWithPermissionsMixin, serializers.ModelSerializer): type = ClassNameField() donor_name = serializers.SerializerMethodField() bids = DonationBidSerializer(many=True, read_only=True) @@ -612,7 +632,7 @@ def get_amount(self, obj): class TalentSerializer( PrimaryOrNaturalKeyLookup, TrackerModelSerializer, - WithPermissionsSerializerMixin, + SerializerWithPermissionsMixin, EventNestedSerializerMixin, ): type = ClassNameField() @@ -688,7 +708,7 @@ def to_internal_value(self, data): class SpeedRunSerializer( PrimaryOrNaturalKeyLookup, - WithPermissionsSerializerMixin, + SerializerWithPermissionsMixin, EventNestedSerializerMixin, TrackerModelSerializer, ): @@ -862,7 +882,7 @@ class Meta: class MilestoneSerializer( - WithPermissionsSerializerMixin, EventNestedSerializerMixin, TrackerModelSerializer + SerializerWithPermissionsMixin, EventNestedSerializerMixin, TrackerModelSerializer ): type = ClassNameField() event = EventSerializer() diff --git a/tracker/api/views/bids.py b/tracker/api/views/bids.py index e47194a9c..bc3e96bb9 100644 --- a/tracker/api/views/bids.py +++ b/tracker/api/views/bids.py @@ -15,6 +15,7 @@ TrackerFullViewSet, WithSerializerPermissionsMixin, ) +from tracker.api.views.donation_bids import DonationBidViewSet from tracker.models import Bid, SpeedRun logger = logging.getLogger(__name__) @@ -86,3 +87,9 @@ def tree(self, *args, **kwargs): page = self.paginate_queryset(queryset) serializer = self.get_serializer(page, tree=True, many=True) return self.get_paginated_response(serializer.data) + + @action(detail=True, methods=['get']) + def donations(self, request, *args, **kwargs): + viewset = DonationBidViewSet(request=request, bid=self.get_object()) + viewset.initial(request, *args, **kwargs) + return viewset.list(request, *args, **kwargs) diff --git a/tracker/api/views/donation_bids.py b/tracker/api/views/donation_bids.py new file mode 100644 index 000000000..5b10bfdcc --- /dev/null +++ b/tracker/api/views/donation_bids.py @@ -0,0 +1,43 @@ +from django.db.models import Q + +from tracker import models +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 + + +class DonationBidViewSet(WithSerializerPermissionsMixin, TrackerReadViewSet): + serializer_class = DonationBidSerializer + pagination_class = TrackerPagination + permission_classes = [DonationBidStatePermission] + queryset = models.DonationBid.objects.select_related('bid') + + def __init__(self, *args, donation=None, bid=None, **kwargs): + self.donation = donation + self.bid = bid + super().__init__(*args, **kwargs) + + def filter_queryset(self, queryset): + # this can be filtered in multiple ways + # - by donation, which excludes hidden children unless explicitly asked for + # - by exact bid, which includes the descendant tree if there is one, excluding + # hidden descendants unless explicitly asked for, or if the parent itself is hidden + # note that we NEVER return bids attached to pending donations from this endpoint, if + # you really need that, you can look at the admin page + queryset = queryset.filter(donation__transactionstate='COMPLETED') + assert self.donation or self.bid, 'did not get either donation or bid' + state_filter = Q() + if 'all' not in self.request.query_params: + state_filter = Q(bid__state__in=models.Bid.PUBLIC_STATES) + if self.donation: + queryset = queryset.filter(Q(donation=self.donation) & state_filter) + if self.bid: + if self.bid.state not in models.Bid.PUBLIC_STATES: + # if we're requesting a specific bid that's not public, just assume we want to view all states, + # since we would have gotten a 404 by now if we didn't already have permission + state_filter = Q() + queryset = queryset.filter( + (Q(bid=self.bid) | Q(bid__in=self.bid.get_descendants()) & state_filter) + ) + return queryset diff --git a/tracker/api/views/donations.py b/tracker/api/views/donations.py index 81b7f514d..e738fd980 100644 --- a/tracker/api/views/donations.py +++ b/tracker/api/views/donations.py @@ -12,6 +12,7 @@ from tracker.api.permissions import CanSendToReader, tracker_permission from tracker.api.serializers import DonationSerializer from tracker.api.views import EventNestedMixin +from tracker.api.views.donation_bids import DonationBidViewSet from tracker.consumers.processing import broadcast_processing_action from tracker.models import Donation @@ -349,3 +350,9 @@ def comment(self, request, pk): data = self.get_serializer(donation).data return Response(data) + + @action(detail=True, methods=['get']) + 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)