Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions docs/developer/individual-donations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Individual Donations Tracking

## Overview

The sponsorship system now supports separating individual donations from corporate sponsorships. This allows for better tracking and visualization of different funding sources.

## Features

### Model Changes

A new boolean field `is_individual_donation` has been added to the `SponsorshipProfile` model:

```python
is_individual_donation = models.BooleanField(
default=False,
help_text="Check if this is an individual donation (not a corporate sponsorship)",
)
```

### New Stats Available

The following new statistics are now calculated and cached:

- **Individual Donations Count**: Number of paid individual donations
- **Individual Donations Amount**: Total amount raised from individual donations
- **Corporate Sponsors Count**: Number of paid corporate sponsors (excludes individual donations)
- **Corporate Sponsors Amount**: Total amount raised from corporate sponsors (excludes individual donations)
- **Total Funds Raised**: Sum of corporate sponsors and individual donations

### Cache Keys

New cache keys in `portal/constants.py`:

```python
CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT = "individual_donations_count"
CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT = "individual_donations_amount"
CACHE_KEY_CORPORATE_SPONSORS_COUNT = "corporate_sponsors_count"
CACHE_KEY_CORPORATE_SPONSORS_AMOUNT = "corporate_sponsors_amount"
CACHE_KEY_TOTAL_FUNDS_RAISED = "total_funds_raised"
```

## Usage

### Admin Interface

1. Navigate to the Django admin panel
2. Go to Sponsorship Profiles
3. When creating or editing a profile, check the "Is individual donation" checkbox for individual donations
4. The field is also available as a filter in the admin list view

### Viewing Stats

The public stats page (`/stats/`) now displays a "Fundraising Breakdown" section showing:

- Individual Donations (count and amount)
- Corporate Sponsors (count and amount)
- Total Funds Raised
- Progress to Goal

The original "Sponsorship Pipeline" section remains available for detailed tracking.

### API/Code Usage

To access these stats in your code:

```python
from portal.common import (
get_individual_donations_count_cache,
get_individual_donations_amount_cache,
get_corporate_sponsors_count_cache,
get_corporate_sponsors_amount_cache,
get_total_funds_raised_cache,
)

# Get individual donations stats
individual_count = get_individual_donations_count_cache()
individual_amount = get_individual_donations_amount_cache()

# Get corporate sponsors stats
corporate_count = get_corporate_sponsors_count_cache()
corporate_amount = get_corporate_sponsors_amount_cache()

# Get total funds raised
total_funds = get_total_funds_raised_cache()
```

## Implementation Details

### Calculation Logic

- **Paid Status Only**: All calculations only count sponsorship profiles with `progress_status=PAID`
- **Override Handling**: Properly handles both `sponsorship_tier.amount` and `sponsorship_override_amount`
- **Separation**: Uses `is_individual_donation` to filter between individual and corporate

### Template Integration

The stats are automatically available in the `stats` context variable in templates that use `get_sponsorships_stats_dict()`.

Example template usage:

```django
{{ stats.individual_donations_count }}
{{ stats.individual_donations_amount|as_currency }}
{{ stats.corporate_sponsors_count }}
{{ stats.corporate_sponsors_amount|as_currency }}
{{ stats.total_funds_raised|as_currency }}
```

## Testing

Tests are available in `tests/portal/test_common.py`:

```bash
# Run specific test
docker compose run --rm web pytest tests/portal/test_common.py::TestGetStatsCachedValues::test_individual_donations_stats -v

# Run all common tests
docker compose run --rm web pytest tests/portal/test_common.py -v
```

## Migration

The migration `sponsorship/migrations/0005_sponsorshipprofile_is_individual_donation.py` adds the new field with a default value of `False`, so existing sponsorship profiles are automatically treated as corporate sponsorships (not individual donations).

To mark existing profiles as individual donations, update them via:

1. Django admin interface
2. Django shell:
```python
from sponsorship.models import SponsorshipProfile

# Mark a specific profile as individual donation
profile = SponsorshipProfile.objects.get(organization_name="John Doe")
profile.is_individual_donation = True
profile.save()
```

## Cache Management

All stats use Django's cache system with a 5-minute timeout (`STATS_CACHE_TIMEOUT`). To clear the cache:

```python
from django.core.cache import cache
from portal.constants import (
CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT,
CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT,
CACHE_KEY_CORPORATE_SPONSORS_COUNT,
CACHE_KEY_CORPORATE_SPONSORS_AMOUNT,
CACHE_KEY_TOTAL_FUNDS_RAISED,
)

cache.delete(CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT)
cache.delete(CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT)
cache.delete(CACHE_KEY_CORPORATE_SPONSORS_COUNT)
cache.delete(CACHE_KEY_CORPORATE_SPONSORS_AMOUNT)
cache.delete(CACHE_KEY_TOTAL_FUNDS_RAISED)
```

## Related Issues

- Fixes #247 - Separate the "Individual Donations" stats
139 changes: 139 additions & 0 deletions portal/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
from django.db.models import Count, Sum

from portal.constants import (
CACHE_KEY_CORPORATE_SPONSORS_AMOUNT,
CACHE_KEY_CORPORATE_SPONSORS_COUNT,
CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT,
CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT,
CACHE_KEY_SPONSORSHIP_BREAKDOWN,
CACHE_KEY_SPONSORSHIP_COMMITTED,
CACHE_KEY_SPONSORSHIP_COMMITTED_COUNT,
Expand All @@ -12,6 +16,7 @@
CACHE_KEY_SPONSORSHIP_PENDING_COUNT,
CACHE_KEY_SPONSORSHIP_TOWARDS_GOAL_PERCENT,
CACHE_KEY_TEAMS_COUNT,
CACHE_KEY_TOTAL_FUNDS_RAISED,
CACHE_KEY_TOTAL_SPONSORSHIPS,
CACHE_KEY_VOLUNTEER_BREAKDOWN,
CACHE_KEY_VOLUNTEER_LANGUAGES,
Expand Down Expand Up @@ -79,6 +84,21 @@ def get_sponsorships_stats_dict():
)
stats_dict[CACHE_KEY_SPONSORSHIP_BREAKDOWN] = get_sponsorship_breakdown()

# Individual Donations and Corporate Sponsors (separate breakdown)
stats_dict[CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT] = (
get_individual_donations_count_cache()
)
stats_dict[CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT] = (
get_individual_donations_amount_cache()
)
stats_dict[CACHE_KEY_CORPORATE_SPONSORS_COUNT] = (
get_corporate_sponsors_count_cache()
)
stats_dict[CACHE_KEY_CORPORATE_SPONSORS_AMOUNT] = (
get_corporate_sponsors_amount_cache()
)
stats_dict[CACHE_KEY_TOTAL_FUNDS_RAISED] = get_total_funds_raised_cache()

return stats_dict


Expand Down Expand Up @@ -378,6 +398,125 @@ def get_sponsorship_paid_percent_cache():
return sponsorship_paid_percent


def get_individual_donations_count_cache():
"""Returns count of individual donations (paid only)"""
individual_donations_count = cache.get(CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT)
if not individual_donations_count:
individual_donations_count = SponsorshipProfile.objects.filter(
progress_status=SponsorshipProgressStatus.PAID, is_individual_donation=True
).count()
cache.set(
CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT,
individual_donations_count,
STATS_CACHE_TIMEOUT,
)
return individual_donations_count


def get_individual_donations_amount_cache():
"""Returns total amount from individual donations (paid only)"""
individual_donations_amount = cache.get(CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT)
if not individual_donations_amount:
individual_donations = SponsorshipProfile.objects.filter(
progress_status=SponsorshipProgressStatus.PAID, is_individual_donation=True
)

# Calculate donations with no override (using tier amount)
donations_no_override_qs = individual_donations.filter(
sponsorship_override_amount__isnull=True, sponsorship_tier__isnull=False
)
if donations_no_override_qs:
donations_no_override = donations_no_override_qs.aggregate(
Sum("sponsorship_tier__amount")
)["sponsorship_tier__amount__sum"]
else:
donations_no_override = 0

# Calculate donations with override amount
donations_with_override_qs = individual_donations.filter(
sponsorship_override_amount__isnull=False
)
if donations_with_override_qs:
donations_with_override = donations_with_override_qs.aggregate(
Sum("sponsorship_override_amount")
)["sponsorship_override_amount__sum"]
else:
donations_with_override = 0

individual_donations_amount = donations_no_override + donations_with_override
cache.set(
CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT,
individual_donations_amount,
STATS_CACHE_TIMEOUT,
)
return individual_donations_amount


def get_corporate_sponsors_count_cache():
"""Returns count of corporate sponsors (paid only, excludes individual donations)"""
corporate_sponsors_count = cache.get(CACHE_KEY_CORPORATE_SPONSORS_COUNT)
if not corporate_sponsors_count:
corporate_sponsors_count = SponsorshipProfile.objects.filter(
progress_status=SponsorshipProgressStatus.PAID, is_individual_donation=False
).count()
cache.set(
CACHE_KEY_CORPORATE_SPONSORS_COUNT,
corporate_sponsors_count,
STATS_CACHE_TIMEOUT,
)
return corporate_sponsors_count


def get_corporate_sponsors_amount_cache():
"""Returns total amount from corporate sponsors (paid only, excludes individual donations)"""
corporate_sponsors_amount = cache.get(CACHE_KEY_CORPORATE_SPONSORS_AMOUNT)
if not corporate_sponsors_amount:
corporate_sponsors = SponsorshipProfile.objects.filter(
progress_status=SponsorshipProgressStatus.PAID, is_individual_donation=False
)

# Calculate sponsors with no override (using tier amount)
sponsors_no_override_qs = corporate_sponsors.filter(
sponsorship_override_amount__isnull=True, sponsorship_tier__isnull=False
)
if sponsors_no_override_qs:
sponsors_no_override = sponsors_no_override_qs.aggregate(
Sum("sponsorship_tier__amount")
)["sponsorship_tier__amount__sum"]
else:
sponsors_no_override = 0

# Calculate sponsors with override amount
sponsors_with_override_qs = corporate_sponsors.filter(
sponsorship_override_amount__isnull=False
)
if sponsors_with_override_qs:
sponsors_with_override = sponsors_with_override_qs.aggregate(
Sum("sponsorship_override_amount")
)["sponsorship_override_amount__sum"]
else:
sponsors_with_override = 0

corporate_sponsors_amount = sponsors_no_override + sponsors_with_override
cache.set(
CACHE_KEY_CORPORATE_SPONSORS_AMOUNT,
corporate_sponsors_amount,
STATS_CACHE_TIMEOUT,
)
return corporate_sponsors_amount


def get_total_funds_raised_cache():
"""Returns total funds raised (corporate sponsors + individual donations)"""
total_funds_raised = cache.get(CACHE_KEY_TOTAL_FUNDS_RAISED)
if not total_funds_raised:
corporate_amount = get_corporate_sponsors_amount_cache()
individual_amount = get_individual_donations_amount_cache()
total_funds_raised = corporate_amount + individual_amount
cache.set(CACHE_KEY_TOTAL_FUNDS_RAISED, total_funds_raised, STATS_CACHE_TIMEOUT)
return total_funds_raised


def get_sponsorship_breakdown():
sponsorship_breakdown = cache.get(CACHE_KEY_SPONSORSHIP_BREAKDOWN)
if not sponsorship_breakdown:
Expand Down
7 changes: 7 additions & 0 deletions portal/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
CACHE_KEY_SPONSORSHIP_TOWARDS_GOAL_PERCENT = "sponsorship_towards_goal_percent"
CACHE_KEY_SPONSORSHIP_BREAKDOWN = "sponsorship_breakdown"

# Individual Donations (separate from corporate sponsorships)
CACHE_KEY_INDIVIDUAL_DONATIONS_COUNT = "individual_donations_count"
CACHE_KEY_INDIVIDUAL_DONATIONS_AMOUNT = "individual_donations_amount"
CACHE_KEY_CORPORATE_SPONSORS_COUNT = "corporate_sponsors_count"
CACHE_KEY_CORPORATE_SPONSORS_AMOUNT = "corporate_sponsors_amount"
CACHE_KEY_TOTAL_FUNDS_RAISED = "total_funds_raised"

CACHE_KEY_VOLUNTEER_BREAKDOWN = "volunteer_breakdown"

SPONSORSHIP_GOAL_AMOUNT = 15000
3 changes: 3 additions & 0 deletions sponsorship/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ class SponsorshipProfileAdmin(ImportExportModelAdmin):
"sponsorship_tier",
"progress_status",
"sponsorship_override_amount",
"is_individual_donation",
"main_contact_user",
)
list_filter = (
"progress_status",
"sponsorship_tier",
"is_individual_donation",
)
search_fields = ("organization_name",)
fields = (
Expand All @@ -57,6 +59,7 @@ class SponsorshipProfileAdmin(ImportExportModelAdmin):
"logo",
"company_description",
"progress_status",
"is_individual_donation",
"main_contact_user",
)
resource_classes = [SponsorshipProfileResource]
1 change: 1 addition & 0 deletions sponsorship/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Meta:
"logo",
"company_description",
"progress_status",
"is_individual_donation",
]
widgets = {
"company_description": forms.Textarea(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2025-11-02 17:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("sponsorship", "0004_sponsorshipprofile_organization_address_and_more"),
]

operations = [
migrations.AddField(
model_name="sponsorshipprofile",
name="is_individual_donation",
field=models.BooleanField(
default=False,
help_text="Check if this is an individual donation (not a corporate sponsorship)",
),
),
]
Loading
Loading