From dec91d991b85dc86c8a586a0a2db266795f6b031 Mon Sep 17 00:00:00 2001 From: Nicolas Dontigny <45007853+NicolasDontigny@users.noreply.github.com> Date: Thu, 3 Oct 2024 19:14:49 -0400 Subject: [PATCH] Feature/243 calculate sponsored percentage (#255) --------- Co-authored-by: Nicolas Dontigny Co-authored-by: Samuel T. Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../commands/initialize_database.py | 56 ++++++++++++++----- .../migrations/0001_initial.py | 9 ++- canopeum_backend/canopeum_backend/models.py | 26 +++++++-- .../canopeum_backend/serializers.py | 28 +++++----- .../analytics/AnalyticsSiteHeader.tsx | 2 +- .../analytics/SiteSuccessRatesChart.tsx | 6 +- .../components/analytics/SiteSummaryCard.tsx | 2 +- canopeum_frontend/src/services/api.ts | 30 +++++----- 8 files changed, 100 insertions(+), 59 deletions(-) diff --git a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py index 93105ede8..648120c19 100644 --- a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py +++ b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py @@ -1,6 +1,7 @@ # flake8: noqa: S311 -- Accept random int generation for database seeding import random +from collections.abc import Iterable from datetime import timedelta from pathlib import Path @@ -29,6 +30,7 @@ Role, Site, Siteadmin, + Sitetreespecies, Sitetype, SitetypeInternationalization, TreespeciestypeInternationalization, @@ -269,12 +271,32 @@ def create_sponsor_for_batch(): ) +def create_species_for_site(site: Site, batches: Iterable[Batch]): + already_added_tree_type: dict[int, Sitetreespecies] = {} + for batch in batches: + for batch_specie in BatchSpecies.objects.filter(batch=batch): + quantity = batch_specie.quantity + # Add more to the site's quantity than the batches' quantity + # so they don't appear at 100%. Except Canopeum, let's use it as a 100% example + if site.name != "Canopeum": + quantity += random.randint(0, 50) + if batch_specie.tree_type.pk in already_added_tree_type: + site_tree_specie = already_added_tree_type[batch_specie.tree_type.pk] + site_tree_specie.quantity += quantity + site_tree_specie.save() + else: + site_tree_specie = Sitetreespecies.objects.create( + site=site, tree_type=batch_specie.tree_type, quantity=quantity + ) + already_added_tree_type[batch_specie.tree_type.pk] = site_tree_specie + + def create_batches_for_site(site): num_batches = random.randint(3, 8) for i in range(num_batches): number_of_seed = random.randint(50, 200) - plant_count = random.randint(0, number_of_seed) - survived_count = random.randint(0, plant_count) + survived_count = random.randint(100, 200) + replace_count = random.randint(0, 50) sponsor = create_sponsor_for_batch() @@ -284,9 +306,8 @@ def create_batches_for_site(site): size=random.randint(20, 150), sponsor=sponsor, soil_condition="Good", - plant_count=plant_count, survived_count=survived_count, - replace_count=plant_count - survived_count, + replace_count=replace_count, total_number_seed=number_of_seed, total_propagation=random.randint(0, number_of_seed), ) @@ -297,6 +318,7 @@ def create_batches_for_site(site): batch=batch, fertilizer_type=fertilizer_type, ) + yield batch class Command(BaseCommand): @@ -352,8 +374,7 @@ def handle(self, *args, **kwargs): self.create_roles() self.create_users() - self.create_canopeum_site() - self.create_other_sites() + self.create_sites() self.create_siteadmins() self.stdout.write(self.style.SUCCESS("Data Generated")) @@ -479,8 +500,9 @@ def create_users(self): role=Role.objects.get(name="User"), ) - def create_canopeum_site(self): - site = Site.objects.create( + def create_sites(self): + # Canopeum's site + site1 = Site.objects.create( name="Canopeum", is_public=True, site_type=Sitetype.objects.get( @@ -511,15 +533,16 @@ def create_canopeum_site(self): link="https://www.canopeum-pos.com", ), ) - create_batches_for_site(site) + batches = create_batches_for_site(site1) + create_species_for_site(site1, batches) post = Post.objects.create( - site=site, + site=site1, body="The season is officially started; " + "new plants are starting to grow and our volunteers are very dedicated!", share_count=5, ) post.media.add(*Asset.objects.filter(asset__contains="canopeum_post_img")) - create_posts_for_site(site) + create_posts_for_site(site1) Comment.objects.create( body="Wow, I'm very excited to join the team!", user=User.objects.get(email="tyrion@lannister.com"), @@ -530,8 +553,8 @@ def create_canopeum_site(self): user=User.objects.get(email="normal@user.com"), post=post, ) + # end of Canopeum's site - def create_other_sites(self): site_2 = Site.objects.create( name="Maple Grove Retreat", is_public=True, @@ -564,7 +587,8 @@ def create_other_sites(self): link="https://www.maplegroveretreat.com/events/maple-syrup-festival", ), ) - create_batches_for_site(site_2) + batches = create_batches_for_site(site_2) + create_species_for_site(site_2, batches) create_posts_for_site(site_2) site_3 = Site.objects.create( @@ -600,7 +624,8 @@ def create_other_sites(self): link="https://www.lakesideoasis.com/winter-getaway", ), ) - create_batches_for_site(site_3) + batches = create_batches_for_site(site_3) + create_species_for_site(site_3, batches) create_posts_for_site(site_3) site_4 = Site.objects.create( @@ -636,7 +661,8 @@ def create_other_sites(self): link="https://www.evergreentrail.com/guided-walks", ), ) - create_batches_for_site(site_4) + batches = create_batches_for_site(site_4) + create_species_for_site(site_4, batches) create_posts_for_site(site_4) def create_siteadmins(self): diff --git a/canopeum_backend/canopeum_backend/migrations/0001_initial.py b/canopeum_backend/canopeum_backend/migrations/0001_initial.py index af3c926aa..365ee675b 100644 --- a/canopeum_backend/canopeum_backend/migrations/0001_initial.py +++ b/canopeum_backend/canopeum_backend/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1 on 2024-09-26 20:15 +# Generated by Django 5.1 on 2024-09-30 16:29 import canopeum_backend.models import django.contrib.auth.models @@ -232,7 +232,6 @@ class Migration(migrations.Migration): ('name', models.TextField(blank=True, null=True)), ('size', models.IntegerField(blank=True, null=True)), ('soil_condition', models.TextField(blank=True, null=True)), - ('plant_count', models.IntegerField(blank=True, null=True)), ('survived_count', models.IntegerField(blank=True, null=True)), ('replace_count', models.IntegerField(blank=True, null=True)), ('total_number_seed', models.IntegerField(blank=True, null=True)), @@ -316,9 +315,9 @@ class Migration(migrations.Migration): name='Sitetreespecies', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.IntegerField(blank=True, null=True)), - ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.site')), - ('tree_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.treetype')), + ('quantity', models.IntegerField()), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.site')), + ('tree_type', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.treetype')), ], options={ 'constraints': [models.UniqueConstraint(fields=('site', 'tree_type'), name='unique_tree_species_per_site')], diff --git a/canopeum_backend/canopeum_backend/models.py b/canopeum_backend/canopeum_backend/models.py index 56f667d28..7dc62d509 100644 --- a/canopeum_backend/canopeum_backend/models.py +++ b/canopeum_backend/canopeum_backend/models.py @@ -156,6 +156,21 @@ class Site(models.Model): announcement = models.ForeignKey(Announcement, models.SET_NULL, blank=True, null=True) image = models.ForeignKey(Asset, models.SET_NULL, blank=True, null=True) + def get_plant_count(self) -> int: + site_species = Sitetreespecies.objects.filter(site=self) + return sum(specie.quantity for specie in site_species) + + def get_sponsor_progress(self) -> float: + total_plant_count = self.get_plant_count() + if total_plant_count == 0: + return 0 + + batches = Batch.objects.filter(site=self) + sponsored_plant_count = sum(batch.plant_count() for batch in batches) + + # Note: We don't cap the progress at 100% so it's obvious if there's a data issue + return sponsored_plant_count / total_plant_count * 100 + @override def delete(self, using=None, keep_parents=False): # Coordinate @@ -191,7 +206,6 @@ class Batch(models.Model): sponsor = models.ForeignKey(BatchSponsor, models.CASCADE) size = models.IntegerField(blank=True, null=True) soil_condition = models.TextField(blank=True, null=True) - plant_count = models.IntegerField(blank=True, null=True) survived_count = models.IntegerField(blank=True, null=True) replace_count = models.IntegerField(blank=True, null=True) total_number_seed = models.IntegerField(blank=True, null=True) @@ -218,6 +232,10 @@ def add_supported_specie_by_id(self, pk: int): tree_type = Treetype.objects.get(pk=pk) return BatchSupportedSpecies.objects.create(tree_type=tree_type, batch=self) + def plant_count(self) -> int: + batch_species = BatchSpecies.objects.filter(batch=self) + return sum(specie.quantity for specie in batch_species) + class FertilizertypeInternationalization(models.Model): en = models.TextField(db_column="EN", blank=True, null=True) @@ -362,9 +380,9 @@ class SiteFollower(models.Model): class Sitetreespecies(models.Model): - site = models.ForeignKey(Site, models.CASCADE, blank=True, null=True) - tree_type = models.ForeignKey(Treetype, models.DO_NOTHING, blank=True, null=True) - quantity = models.IntegerField(blank=True, null=True) + site = models.ForeignKey(Site, models.CASCADE) + tree_type = models.ForeignKey(Treetype, models.DO_NOTHING) + quantity = models.IntegerField() class Meta: constraints = ( diff --git a/canopeum_backend/canopeum_backend/serializers.py b/canopeum_backend/canopeum_backend/serializers.py index a1f96cbcc..47572696e 100644 --- a/canopeum_backend/canopeum_backend/serializers.py +++ b/canopeum_backend/canopeum_backend/serializers.py @@ -543,9 +543,9 @@ class SiteSummarySerializer(serializers.ModelSerializer[Site]): site_type = SiteTypeSerializer() coordinate = CoordinatesSerializer() plant_count = serializers.SerializerMethodField() + sponsor_progress = serializers.SerializerMethodField() survived_count = serializers.SerializerMethodField() propagation_count = serializers.SerializerMethodField() - progress = serializers.SerializerMethodField() admins = SiteAdminSerializer(source="siteadmin_set", many=True) batches = serializers.SerializerMethodField() @@ -557,16 +557,19 @@ class Meta: "coordinate", "site_type", "plant_count", + "sponsor_progress", "survived_count", "propagation_count", "visitor_count", - "progress", "admins", "batches", ) - def get_plant_count(self, obj) -> int: - return random.randint(100, 200) # noqa: S311 + def get_plant_count(self, obj: Site) -> int: + return obj.get_plant_count() + + def get_sponsor_progress(self, obj: Site) -> float: + return obj.get_sponsor_progress() def get_survived_count(self, obj) -> int: return random.randint(50, 100) # noqa: S311 @@ -574,9 +577,6 @@ def get_survived_count(self, obj) -> int: def get_propagation_count(self, obj) -> int: return random.randint(5, 50) # noqa: S311 - def get_progress(self, obj) -> float: - return random.randint(0, 10000) / 100 # noqa: S311 - @extend_schema_field(BatchDetailSerializer(many=True)) def get_batches(self, obj): batches = obj.batch_set.all().order_by("-updated_at") @@ -587,9 +587,9 @@ class SiteSummaryDetailSerializer(serializers.ModelSerializer[Site]): site_type = SiteTypeSerializer() coordinate = CoordinatesSerializer() plant_count = serializers.SerializerMethodField() + sponsor_progress = serializers.SerializerMethodField() survived_count = serializers.SerializerMethodField() propagation_count = serializers.SerializerMethodField() - progress = serializers.SerializerMethodField() sponsors = serializers.SerializerMethodField() admins = SiteAdminSerializer(source="siteadmin_set", many=True) batches = serializers.SerializerMethodField() @@ -603,18 +603,21 @@ class Meta: "coordinate", "site_type", "plant_count", + "sponsor_progress", "survived_count", "propagation_count", "visitor_count", "sponsors", - "progress", "admins", "batches", "weather", ) - def get_plant_count(self, obj) -> int: - return random.randint(100, 200) # noqa: S311 + def get_plant_count(self, obj: Site) -> int: + return obj.get_plant_count() + + def get_sponsor_progress(self, obj: Site) -> float: + return obj.get_sponsor_progress() def get_survived_count(self, obj) -> int: return random.randint(50, 100) # noqa: S311 @@ -622,9 +625,6 @@ def get_survived_count(self, obj) -> int: def get_propagation_count(self, obj) -> int: return random.randint(5, 50) # noqa: S311 - def get_progress(self, obj) -> float: - return random.randint(0, 10000) / 100 # noqa: S311 - @extend_schema_field(BatchSponsorSerializer(many=True)) def get_sponsors(self, obj): batches = Batch.objects.filter(site=obj) diff --git a/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx b/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx index 7d01c6937..75f41e49c 100644 --- a/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx +++ b/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx @@ -99,7 +99,7 @@ const AnalyticsSiteHeader = ({ siteSummary }: Props) => {
- +
diff --git a/canopeum_frontend/src/components/analytics/SiteSuccessRatesChart.tsx b/canopeum_frontend/src/components/analytics/SiteSuccessRatesChart.tsx index 978848907..6f8d8db87 100644 --- a/canopeum_frontend/src/components/analytics/SiteSuccessRatesChart.tsx +++ b/canopeum_frontend/src/components/analytics/SiteSuccessRatesChart.tsx @@ -22,7 +22,7 @@ const buildChartOptions = (siteSummaries: SiteSummary[]) => { // total-functions/no-partial-division -- length checked above options.average = siteSummaries.reduce( - (accumulator, current) => accumulator + current.progress, + (accumulator, current) => accumulator + current.sponsorProgress, 0, ) / siteSummaries.length @@ -34,10 +34,10 @@ const buildChartOptions = (siteSummaries: SiteSummary[]) => { // However, bars will appear really thin, so we use stacked bars to stack // 0-height bars on top of each other. const strackedSerie = Array.from({ length: siteSummaries.length }).fill(0) - strackedSerie[siteIndex] = site.progress + strackedSerie[siteIndex] = site.sponsorProgress options.colors.push( - site.progress > options.average + site.sponsorProgress > options.average ? 'var(--bs-primary)' : 'var(--bs-secondary)', ) diff --git a/canopeum_frontend/src/components/analytics/SiteSummaryCard.tsx b/canopeum_frontend/src/components/analytics/SiteSummaryCard.tsx index 1985f7b63..c97c0dbb7 100644 --- a/canopeum_frontend/src/components/analytics/SiteSummaryCard.tsx +++ b/canopeum_frontend/src/components/analytics/SiteSummaryCard.tsx @@ -115,7 +115,7 @@ const SiteSummaryCard = ({ site, admins, onSiteChange, onSiteEdit }: Props) => {
- +
diff --git a/canopeum_frontend/src/services/api.ts b/canopeum_frontend/src/services/api.ts index b137419bf..6422223af 100644 --- a/canopeum_frontend/src/services/api.ts +++ b/canopeum_frontend/src/services/api.ts @@ -3251,14 +3251,13 @@ export class BatchDetail implements IBatchDetail { readonly supportedSpecies!: TreeType[] readonly seeds!: BatchSeed[] readonly species!: BatchSpecies[] - sponsor!: BatchSponsor + readonly sponsor!: BatchSponsor image?: Asset readonly createdAt!: Date | undefined readonly updatedAt!: Date | undefined name?: string | undefined size?: number | undefined soilCondition?: string | undefined - plantCount?: number | undefined survivedCount?: number | undefined replaceCount?: number | undefined totalNumberSeed?: number | undefined @@ -3323,7 +3322,9 @@ export class BatchDetail implements IBatchDetail { ;( this).species!.push(BatchSpecies.fromJS(item)) } } - this.sponsor = _data['sponsor'] ? BatchSponsor.fromJS(_data['sponsor']) : new BatchSponsor() + ;( this).sponsor = _data['sponsor'] + ? BatchSponsor.fromJS(_data['sponsor']) + : new BatchSponsor() this.image = _data['image'] ? Asset.fromJS(_data['image']) : undefined ;( this).createdAt = _data['createdAt'] ? new Date(_data['createdAt'].toString()) @@ -3334,7 +3335,6 @@ export class BatchDetail implements IBatchDetail { this.name = _data['name'] this.size = _data['size'] this.soilCondition = _data['soilCondition'] - this.plantCount = _data['plantCount'] this.survivedCount = _data['survivedCount'] this.replaceCount = _data['replaceCount'] this.totalNumberSeed = _data['totalNumberSeed'] @@ -3395,7 +3395,6 @@ export class BatchDetail implements IBatchDetail { data['name'] = this.name data['size'] = this.size data['soilCondition'] = this.soilCondition - data['plantCount'] = this.plantCount data['survivedCount'] = this.survivedCount data['replaceCount'] = this.replaceCount data['totalNumberSeed'] = this.totalNumberSeed @@ -3419,7 +3418,6 @@ export interface IBatchDetail { name?: string | undefined size?: number | undefined soilCondition?: string | undefined - plantCount?: number | undefined survivedCount?: number | undefined replaceCount?: number | undefined totalNumberSeed?: number | undefined @@ -5424,10 +5422,10 @@ export class SiteSummary implements ISiteSummary { coordinate!: Coordinates siteType!: SiteType readonly plantCount!: number + readonly sponsorProgress!: number readonly survivedCount!: number readonly propagationCount!: number visitorCount?: number | undefined - readonly progress!: number admins!: SiteAdmin[] readonly batches!: BatchDetail[]; @@ -5463,10 +5461,10 @@ export class SiteSummary implements ISiteSummary { : new Coordinates() this.siteType = _data['siteType'] ? SiteType.fromJS(_data['siteType']) : new SiteType() ;( this).plantCount = _data['plantCount'] + ;( this).sponsorProgress = _data['sponsorProgress'] ;( this).survivedCount = _data['survivedCount'] ;( this).propagationCount = _data['propagationCount'] this.visitorCount = _data['visitorCount'] - ;( this).progress = _data['progress'] if (Array.isArray(_data['admins'])) { this.admins = [] as any for (let item of _data['admins']) { @@ -5501,10 +5499,10 @@ export class SiteSummary implements ISiteSummary { data['coordinate'] = this.coordinate ? this.coordinate.toJSON() : undefined data['siteType'] = this.siteType ? this.siteType.toJSON() : undefined data['plantCount'] = this.plantCount + data['sponsorProgress'] = this.sponsorProgress data['survivedCount'] = this.survivedCount data['propagationCount'] = this.propagationCount data['visitorCount'] = this.visitorCount - data['progress'] = this.progress if (Array.isArray(this.admins)) { data['admins'] = [] for (let item of this.admins) { @@ -5527,10 +5525,10 @@ export interface ISiteSummary { coordinate: Coordinates siteType: SiteType plantCount: number + sponsorProgress: number survivedCount: number propagationCount: number visitorCount?: number | undefined - progress: number admins: SiteAdmin[] batches: BatchDetail[] @@ -5543,11 +5541,11 @@ export class SiteSummaryDetail implements ISiteSummaryDetail { coordinate!: Coordinates siteType!: SiteType readonly plantCount!: number + readonly sponsorProgress!: number readonly survivedCount!: number readonly propagationCount!: number visitorCount?: number | undefined readonly sponsors!: BatchSponsor[] - readonly progress!: number admins!: SiteAdmin[] readonly batches!: BatchDetail[] readonly weather!: Weather; @@ -5586,6 +5584,7 @@ export class SiteSummaryDetail implements ISiteSummaryDetail { : new Coordinates() this.siteType = _data['siteType'] ? SiteType.fromJS(_data['siteType']) : new SiteType() ;( this).plantCount = _data['plantCount'] + ;( this).sponsorProgress = _data['sponsorProgress'] ;( this).survivedCount = _data['survivedCount'] ;( this).propagationCount = _data['propagationCount'] this.visitorCount = _data['visitorCount'] @@ -5595,7 +5594,6 @@ export class SiteSummaryDetail implements ISiteSummaryDetail { ;( this).sponsors!.push(BatchSponsor.fromJS(item)) } } - ;( this).progress = _data['progress'] if (Array.isArray(_data['admins'])) { this.admins = [] as any for (let item of _data['admins']) { @@ -5631,6 +5629,7 @@ export class SiteSummaryDetail implements ISiteSummaryDetail { data['coordinate'] = this.coordinate ? this.coordinate.toJSON() : undefined data['siteType'] = this.siteType ? this.siteType.toJSON() : undefined data['plantCount'] = this.plantCount + data['sponsorProgress'] = this.sponsorProgress data['survivedCount'] = this.survivedCount data['propagationCount'] = this.propagationCount data['visitorCount'] = this.visitorCount @@ -5640,7 +5639,6 @@ export class SiteSummaryDetail implements ISiteSummaryDetail { data['sponsors'].push(item.toJSON()) } } - data['progress'] = this.progress if (Array.isArray(this.admins)) { data['admins'] = [] for (let item of this.admins) { @@ -5664,11 +5662,11 @@ export interface ISiteSummaryDetail { coordinate: Coordinates siteType: SiteType plantCount: number + sponsorProgress: number survivedCount: number propagationCount: number visitorCount?: number | undefined sponsors: BatchSponsor[] - progress: number admins: SiteAdmin[] batches: BatchDetail[] weather: Weather @@ -5737,7 +5735,7 @@ export interface ISiteType { export class Sitetreespecies implements ISitetreespecies { readonly id!: number - quantity?: number | undefined + quantity!: number readonly en!: string readonly fr!: string; @@ -5791,7 +5789,7 @@ export class Sitetreespecies implements ISitetreespecies { export interface ISitetreespecies { id: number - quantity?: number | undefined + quantity: number en: string fr: string