From a7656ecfbac2a6a4bf9860ce556feeffdd7cfcd7 Mon Sep 17 00:00:00 2001 From: bes-alielzein Date: Thu, 26 Sep 2024 11:09:08 -0400 Subject: [PATCH 01/30] Batch Actions (#230) * create batch actions component * update batch table to use batch actions * fix wrong translations * customize rsuitejs dropdown menu * remove fixed comment --------- Co-authored-by: Samuel T. --- canopeum_frontend/src/App.scss | 1 + .../src/assets/styles/DropdownMenu.scss | 8 ++ .../src/assets/styles/GlobalStyles.scss | 5 +- .../src/components/analytics/BatchActions.tsx | 103 ++++++++++++++++++ .../src/components/analytics/BatchTable.tsx | 29 ++--- .../settings/SettingsTab.module.scss | 3 +- .../src/locale/en/analytics.json | 6 +- .../src/locale/en/analyticsSite.json | 7 ++ .../src/locale/fr/analytics.json | 6 +- .../src/locale/fr/analyticsSite.json | 7 ++ 10 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 canopeum_frontend/src/assets/styles/DropdownMenu.scss create mode 100644 canopeum_frontend/src/components/analytics/BatchActions.tsx diff --git a/canopeum_frontend/src/App.scss b/canopeum_frontend/src/App.scss index 3159719e8..02b12b497 100644 --- a/canopeum_frontend/src/App.scss +++ b/canopeum_frontend/src/App.scss @@ -5,6 +5,7 @@ $secondary: #F18200; $border-radius: 0.5rem; $link-decoration: none; $green: #06C270; +$lightgreen: #E8F3E9; $font-family-sans-serif: -apple-system, BlinkMacSystemFont, 'Lato', Roboto, 'Helvetica Neue', Arial, sans-serif; $font-family-monospace: 'Helvetica Neue', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; diff --git a/canopeum_frontend/src/assets/styles/DropdownMenu.scss b/canopeum_frontend/src/assets/styles/DropdownMenu.scss new file mode 100644 index 000000000..d23c62d7a --- /dev/null +++ b/canopeum_frontend/src/assets/styles/DropdownMenu.scss @@ -0,0 +1,8 @@ +.rs-dropdown-item-submenu:hover > .rs-dropdown-item-toggle { + background-color: $lightgreen; + color: inherit; +} + +.rs-dropdown-item.rs-dropdown-item-focus { + background-color: $lightgreen; +} diff --git a/canopeum_frontend/src/assets/styles/GlobalStyles.scss b/canopeum_frontend/src/assets/styles/GlobalStyles.scss index 07275c15b..2673aef9c 100644 --- a/canopeum_frontend/src/assets/styles/GlobalStyles.scss +++ b/canopeum_frontend/src/assets/styles/GlobalStyles.scss @@ -5,6 +5,7 @@ @import "./Icons.scss"; @import "./Navbar.scss"; @import "./Transitions.scss"; +@import "./DropdownMenu.scss"; $cream: #FFFAF5; @@ -49,11 +50,11 @@ body { } .lightgreen-color { - color: #E8F3E9; + color: $lightgreen; } .bg-lightgreen { - background: #E8F3E9; + background: $lightgreen; } .bg-cream { diff --git a/canopeum_frontend/src/components/analytics/BatchActions.tsx b/canopeum_frontend/src/components/analytics/BatchActions.tsx new file mode 100644 index 000000000..e7eaf5b0e --- /dev/null +++ b/canopeum_frontend/src/components/analytics/BatchActions.tsx @@ -0,0 +1,103 @@ +import { useContext, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown, Popover, Whisper } from 'rsuite' +import type { OverlayTriggerHandle } from 'rsuite/esm/internals/Picker' + +import EditBatchModal from '@components/analytics/batch-modal/EditBatchModal' +import { SnackbarContext } from '@components/context/SnackbarContext' +import ConfirmationDialog from '@components/dialogs/ConfirmationDialog' +import useApiClient from '@hooks/ApiClientHook' +import type { BatchDetail } from '@services/api' + +type Props = { + readonly onEdit: () => void, + readonly onDelete: () => void, + readonly batchDetail: BatchDetail, +} + +const BatchActions = ({ onEdit, onDelete, batchDetail }: Props) => { + const { t: translate } = useTranslation() + const { openAlertSnackbar } = useContext(SnackbarContext) + const { getApiClient } = useApiClient() + + const whisperRef = useRef(null) + + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) + const [batchToEdit, setBatchToEdit] = useState(null) + + const deleteBatch = async () => { + whisperRef.current?.close() + try { + await getApiClient().batchClient.delete(batchDetail.id) + openAlertSnackbar( + translate('analyticsSite.delete-batch.success', { batchName: batchDetail.name }), + ) + onDelete() + } catch { + openAlertSnackbar( + translate('analyticsSite.delete-batch.error', { batchName: batchDetail.name }), + { severity: 'error' }, + ) + } + } + + const handleConfirmDeleteClose = (proceed: boolean) => { + setConfirmDeleteOpen(false) + if (proceed) void deleteBatch() + } + + return ( + <> + + + setBatchToEdit(batchDetail)}> + {translate('analyticsSite.edit-batch')} + + setConfirmDeleteOpen(true)}> + {translate('analyticsSite.delete-batch.title')} + + + + } + trigger='click' + > + + + {batchToEdit && ( + { + setBatchToEdit(null) + if (reason === 'edit') onEdit() + }} + /> + )} + + {translate('analyticsSite.delete-batch.message', { + batchName: batchDetail.name, + })} + + + ) +} + +export default BatchActions diff --git a/canopeum_frontend/src/components/analytics/BatchTable.tsx b/canopeum_frontend/src/components/analytics/BatchTable.tsx index d58fe405a..ea529acd8 100644 --- a/canopeum_frontend/src/components/analytics/BatchTable.tsx +++ b/canopeum_frontend/src/components/analytics/BatchTable.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import EditBatchModal from '@components/analytics/batch-modal/EditBatchModal' +import BatchActions from '@components/analytics/BatchActions' import { LanguageContext } from '@components/context/LanguageContext' import useApiClient from '@hooks/ApiClientHook' import type { BatchDetail } from '@services/api' @@ -23,7 +23,6 @@ const BatchTable = (props: Props) => { const { getApiClient } = useApiClient() const [batches, setBatches] = useState(props.batches) - const [batchToEdit, setBatchToEdit] = useState(null) const fetchBatch = useCallback( async (siteId: number) => { @@ -66,14 +65,15 @@ const BatchTable = (props: Props) => { scope='col' style={{ width: '17.5rem' }} > - {batch.name} - +
+ {batch.name} + + setBatches(previous => previous.filter(b => b.id !== batch.id))} + onEdit={() => void fetchBatch(props.siteId)} + /> +
))} @@ -309,15 +309,6 @@ const BatchTable = (props: Props) => { - {batchToEdit && ( - { - setBatchToEdit(null) - if (reason === 'edit') void fetchBatch(props.siteId) - }} - /> - )} ) } diff --git a/canopeum_frontend/src/components/settings/SettingsTab.module.scss b/canopeum_frontend/src/components/settings/SettingsTab.module.scss index 14a9e89b9..3e6fbc867 100644 --- a/canopeum_frontend/src/components/settings/SettingsTab.module.scss +++ b/canopeum_frontend/src/components/settings/SettingsTab.module.scss @@ -8,6 +8,5 @@ .selectedSettingsTab { border-left: 6px solid map-get($theme-colors, primary); - // TODO(NicolasDontigny): Export as variable in theme-variables - background: #E8F3E9; + background: $lightgreen; } diff --git a/canopeum_frontend/src/locale/en/analytics.json b/canopeum_frontend/src/locale/en/analytics.json index 148dc564d..791511856 100644 --- a/canopeum_frontend/src/locale/en/analytics.json +++ b/canopeum_frontend/src/locale/en/analytics.json @@ -26,8 +26,6 @@ "unnamed-site": "Unnamed site", "site-save-success": "Site saved successfully", "site-save-error": "Error saving Site", - "site-deleted": "Site {{siteName}} was successfully deleted.", - "site-deleted-error": "There was a problem while trying to delete the site {{siteName}}...", "success-rate-chart": { "title": "Average Annual Success Rate Per Site" }, @@ -44,7 +42,9 @@ "unknown": "Unknown", "admins-saved": "Admins saved successfully for {{siteName}}!", "delete-site-confirmation-title": "Delete Site", - "delete-site-confirmation-message": "Are you sure you want to delete the site {{siteName}}?" + "delete-site-confirmation-message": "Are you sure you want to delete the site {{siteName}}?", + "site-deleted": "Site {{siteName}} was successfully deleted.", + "site-deleted-error": "There was a problem while trying to delete the site {{siteName}}..." }, "site-modal": { "site-name": "site name", diff --git a/canopeum_frontend/src/locale/en/analyticsSite.json b/canopeum_frontend/src/locale/en/analyticsSite.json index 1638db5df..a34ba84c3 100644 --- a/canopeum_frontend/src/locale/en/analyticsSite.json +++ b/canopeum_frontend/src/locale/en/analyticsSite.json @@ -4,6 +4,13 @@ "social-page": "Social Page", "sponsors": "Sponsors", "add-new-batch": "Add a New Batch", + "edit-batch": "Edit Batch", + "delete-batch": { + "title": "Delete Batch", + "message": "Are you sure you want to delete {{batchName}} batch?", + "success": "Batch {{batchName}} was successfully deleted.", + "error": "There was a problem while trying to delete the batch {{batchName}}..." + }, "batch-modal": { "create-title": "Create a New Batch", "edit-title": "Edit a Batch", diff --git a/canopeum_frontend/src/locale/fr/analytics.json b/canopeum_frontend/src/locale/fr/analytics.json index 95a045b71..6237b31af 100644 --- a/canopeum_frontend/src/locale/fr/analytics.json +++ b/canopeum_frontend/src/locale/fr/analytics.json @@ -29,8 +29,6 @@ "unnamed-site": "Site sans nom", "site-save-success": "Site saved successfully", "site-save-error": "Error saving Site", - "site-deleted": "Le site {{siteName}} a été supprimé avec succès.", - "site-deleted-error": "Un problème est survenu lors de la tentative de suppression du site {{siteName}}...", "success-rate-chart": { "title": "Taux De Réussite Annuel Moyen Par Site" }, @@ -47,7 +45,9 @@ "unknown": "Inconnu", "admins-saved": "Administrateurs sauvegardés avec succès pour {{siteName}}!", "delete-site-confirmation-title": "Suppression du Site", - "delete-site-confirmation-message": "Etes vous sur de vouloir supprimer le site {{siteName}}?" + "delete-site-confirmation-message": "Êtes vous sur de vouloir supprimer le site {{siteName}}?", + "site-deleted": "Le site {{siteName}} a été supprimé avec succès.", + "site-deleted-error": "Un problème est survenu lors de la tentative de suppression du site {{siteName}}..." }, "site-modal": { "site-name": "nom du site", diff --git a/canopeum_frontend/src/locale/fr/analyticsSite.json b/canopeum_frontend/src/locale/fr/analyticsSite.json index e8141f672..7aaf87d0c 100644 --- a/canopeum_frontend/src/locale/fr/analyticsSite.json +++ b/canopeum_frontend/src/locale/fr/analyticsSite.json @@ -4,6 +4,13 @@ "social-page": "Page Sociale", "sponsors": "Sponsors", "add-new-batch": "Ajouter un Nouveau Lot", + "edit-batch": "Modifier un Lot", + "delete-batch": { + "title": "Suppression du Lot", + "message": "Êtes vous sur de vouloir supprimer le lot {{batchName}}?", + "success": "Lot {{batchName}} supprimé avec succès.", + "error": "Un problème est survenu lors de la tentative de suppression du lot {{batchName}}..." + }, "batch-modal": { "create-title": "Créer un Nouveau Lot", "edit-title": "Modifier un Lot", From e535b4a78057925eabf6de046046e36b36b40c7a Mon Sep 17 00:00:00 2001 From: Nicolas Dontigny <45007853+NicolasDontigny@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:00:47 -0400 Subject: [PATCH 02/30] Changed referencing models from string to model classes (#246) Co-authored-by: Nicolas Dontigny --- canopeum_backend/canopeum_backend/models.py | 221 ++++++++++---------- 1 file changed, 111 insertions(+), 110 deletions(-) diff --git a/canopeum_backend/canopeum_backend/models.py b/canopeum_backend/canopeum_backend/models.py index 56a879c53..887c6655a 100644 --- a/canopeum_backend/canopeum_backend/models.py +++ b/canopeum_backend/canopeum_backend/models.py @@ -70,8 +70,115 @@ class Asset(models.Model): asset = models.FileField(upload_to=upload_to, null=False) +class Contact(models.Model): + address = models.TextField(blank=True, null=True) + email = models.EmailField(blank=True, null=True) + phone = models.CharField(max_length=20, blank=True, null=True) + facebook_link = models.URLField(blank=True, null=True) + x_link = models.URLField(blank=True, null=True) + instagram_link = models.URLField(blank=True, null=True) + linkedin_link = models.URLField(blank=True, null=True) + + +class Coordinate(models.Model): + dms_latitude = models.TextField(blank=True, null=True) + dms_longitude = models.TextField(blank=True, null=True) + dd_latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + dd_longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) + address = models.TextField(blank=True, null=True) + + @classmethod + def from_dms_lat_long(cls, dms_latitude: str, dms_longitude: str): + dms_latitude_split = re.split(LAT_LONG_SEP, dms_latitude) + dd_latitude = ( + float(dms_latitude_split[0]) + + float(dms_latitude_split[1]) / 60 + + float(dms_latitude_split[2]) / 3600 + ) + if dms_latitude_split[3] == "S": + dd_latitude *= -1 + + dms_longitude_split = re.split(LAT_LONG_SEP, dms_longitude) + dd_longitude = ( + float(dms_longitude_split[0]) + + float(dms_longitude_split[1]) / 60 + + float(dms_longitude_split[2]) / 3600 + ) + if dms_longitude_split[3] == "W": + dd_longitude *= -1 + + if gmaps is not None: + data_retrieved = gmaps.reverse_geocode( # pyright: ignore[reportAttributeAccessIssue] -- No type stub currently exists + (dd_latitude, dd_longitude), result_type="street_address" + ) + formatted_address = ( + data_retrieved[0]["formatted_address"] if data_retrieved else "Custom address" + ) + else: + formatted_address = "Unknown address" + + return cls.objects.create( + dms_latitude=dms_latitude, + dms_longitude=dms_longitude, + dd_latitude=dd_latitude, + dd_longitude=dd_longitude, + address=formatted_address, + ) + + +class SitetypeInternationalization(models.Model): + en = models.TextField(db_column="EN", blank=True, null=True) + fr = models.TextField(db_column="FR", blank=True, null=True) + + +class Sitetype(models.Model): + name = models.ForeignKey(SitetypeInternationalization, models.DO_NOTHING, blank=True, null=True) + + @override + def delete(self, using=None, keep_parents=False): + # TODO: FIXME, should Sitetype subclass Asset + # or should it have a foreignkey asset like PostAsset??? + self.asset.delete() # type:ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue] + return super().delete(using, keep_parents) + + +class Site(models.Model): + name = models.TextField() + is_public = models.BooleanField(blank=False, null=False, default=False) + site_type = models.ForeignKey(Sitetype, models.DO_NOTHING, blank=True, null=True) + coordinate = models.ForeignKey(Coordinate, models.SET_NULL, blank=True, null=True) + description = models.TextField(blank=True, null=True) + size = models.TextField(blank=True, null=True) + research_partnership = models.BooleanField(blank=True, null=True) + visible_map = models.BooleanField(blank=True, null=True) + visitor_count = models.IntegerField(blank=True, null=True) + contact = models.ForeignKey(Contact, models.SET_NULL, blank=True, null=True) + announcement = models.ForeignKey(Announcement, models.SET_NULL, blank=True, null=True) + image = models.ForeignKey(Asset, models.SET_NULL, blank=True, null=True) + + @override + def delete(self, using=None, keep_parents=False): + # Coordinate + if self.coordinate: + self.coordinate.delete() + + # Contact + if self.contact: + self.contact.delete() + + # Announcement + if self.announcement: + self.announcement.delete() + + # Image + if self.image: + self.image.delete() + + return super().delete(using, keep_parents) + + class Batch(models.Model): - site = models.ForeignKey("Site", models.CASCADE, blank=True, null=True) + site = models.ForeignKey(Site, models.CASCADE, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, null=True) updated_at = models.DateTimeField(auto_now=True, null=True) name = models.TextField(blank=True, null=True) @@ -197,113 +304,6 @@ class Meta: ) -class Contact(models.Model): - address = models.TextField(blank=True, null=True) - email = models.EmailField(blank=True, null=True) - phone = models.CharField(max_length=20, blank=True, null=True) - facebook_link = models.URLField(blank=True, null=True) - x_link = models.URLField(blank=True, null=True) - instagram_link = models.URLField(blank=True, null=True) - linkedin_link = models.URLField(blank=True, null=True) - - -class Coordinate(models.Model): - dms_latitude = models.TextField(blank=True, null=True) - dms_longitude = models.TextField(blank=True, null=True) - dd_latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) - dd_longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True) - address = models.TextField(blank=True, null=True) - - @classmethod - def from_dms_lat_long(cls, dms_latitude: str, dms_longitude: str): - dms_latitude_split = re.split(LAT_LONG_SEP, dms_latitude) - dd_latitude = ( - float(dms_latitude_split[0]) - + float(dms_latitude_split[1]) / 60 - + float(dms_latitude_split[2]) / 3600 - ) - if dms_latitude_split[3] == "S": - dd_latitude *= -1 - - dms_longitude_split = re.split(LAT_LONG_SEP, dms_longitude) - dd_longitude = ( - float(dms_longitude_split[0]) - + float(dms_longitude_split[1]) / 60 - + float(dms_longitude_split[2]) / 3600 - ) - if dms_longitude_split[3] == "W": - dd_longitude *= -1 - - if gmaps is not None: - data_retrieved = gmaps.reverse_geocode( # pyright: ignore[reportAttributeAccessIssue] -- No type stub currently exists - (dd_latitude, dd_longitude), result_type="street_address" - ) - formatted_address = ( - data_retrieved[0]["formatted_address"] if data_retrieved else "Custom address" - ) - else: - formatted_address = "Unknown address" - - return cls.objects.create( - dms_latitude=dms_latitude, - dms_longitude=dms_longitude, - dd_latitude=dd_latitude, - dd_longitude=dd_longitude, - address=formatted_address, - ) - - -class SitetypeInternationalization(models.Model): - en = models.TextField(db_column="EN", blank=True, null=True) - fr = models.TextField(db_column="FR", blank=True, null=True) - - -class Sitetype(models.Model): - name = models.ForeignKey(SitetypeInternationalization, models.DO_NOTHING, blank=True, null=True) - - @override - def delete(self, using=None, keep_parents=False): - # TODO: FIXME, should Sitetype subclass Asset - # or should it have a foreignkey asset like PostAsset??? - self.asset.delete() # type:ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue] - return super().delete(using, keep_parents) - - -class Site(models.Model): - name = models.TextField() - is_public = models.BooleanField(blank=False, null=False, default=False) - site_type = models.ForeignKey("Sitetype", models.DO_NOTHING, blank=True, null=True) - coordinate = models.ForeignKey(Coordinate, models.SET_NULL, blank=True, null=True) - description = models.TextField(blank=True, null=True) - size = models.TextField(blank=True, null=True) - research_partnership = models.BooleanField(blank=True, null=True) - visible_map = models.BooleanField(blank=True, null=True) - visitor_count = models.IntegerField(blank=True, null=True) - contact = models.ForeignKey(Contact, models.SET_NULL, blank=True, null=True) - announcement = models.ForeignKey(Announcement, models.SET_NULL, blank=True, null=True) - image = models.ForeignKey(Asset, models.SET_NULL, blank=True, null=True) - - @override - def delete(self, using=None, keep_parents=False): - # Coordinate - if self.coordinate: - self.coordinate.delete() - - # Contact - if self.contact: - self.contact.delete() - - # Announcement - if self.announcement: - self.announcement.delete() - - # Image - if self.image: - self.image.delete() - - return super().delete(using, keep_parents) - - # Note: PostAsset must be defined before Post because of a limitation with ManyToManyField type # inference using string annotations: https://github.com/typeddjango/django-stubs/issues/1802 # Can't manually annotate because of: https://github.com/typeddjango/django-stubs/issues/760 @@ -318,10 +318,11 @@ def delete(self, using=None, keep_parents=False): class Post(models.Model): - site = models.ForeignKey("Site", models.CASCADE, blank=False, null=False) + site = models.ForeignKey(Site, models.CASCADE, blank=False, null=False) body = models.TextField(blank=False, null=False) share_count = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True, blank=False, null=False) + # TODO(NicolasDontigny): Add created by user? # created_by = models.ForeignKey(User, models.DO_NOTHING, blank=True, null=True) media = models.ManyToManyField(Asset, through=PostAsset, blank=True) @@ -360,7 +361,7 @@ 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) + tree_type = models.ForeignKey(Treetype, models.DO_NOTHING, blank=True, null=True) quantity = models.IntegerField(blank=True, null=True) class Meta: From 4b54c4c4791ae54d6067df5973cbf6324e096591 Mon Sep 17 00:00:00 2001 From: Nicolas Dontigny <45007853+NicolasDontigny@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:19:19 -0400 Subject: [PATCH 03/30] Simplified Batch properties serializers (#248) * Simplified Batch properties serializers Removed 3 serializers that were not necessary, they were just copies of other serializers. Modified other serializers for batchseed and batchspecies to include a reference of the tree type to prevent id mismatch --------- Co-authored-by: Nicolas Dontigny --- .../commands/initialize_database.py | 10 +- ...tch_join_tables_properties_non_nullable.py | 74 +++ canopeum_backend/canopeum_backend/models.py | 24 +- .../canopeum_backend/serializers.py | 117 ++-- .../analytics/AnalyticsSiteHeader.tsx | 4 +- .../src/components/analytics/BatchTable.tsx | 10 +- .../analytics/FertilizersSelector.tsx | 6 +- .../analytics/MulchLayersSelector.tsx | 7 +- .../analytics/SupportSpeciesSelector.tsx | 9 +- .../batch-modal/CreateBatchModal.tsx | 8 +- .../analytics/batch-modal/EditBatchModal.tsx | 17 +- canopeum_frontend/src/pages/AnalyticsSite.tsx | 4 +- canopeum_frontend/src/services/api.ts | 535 ++++++------------ 13 files changed, 344 insertions(+), 481 deletions(-) create mode 100644 canopeum_backend/canopeum_backend/migrations/0005_made_batch_join_tables_properties_non_nullable.py diff --git a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py index fc3d20310..fd7f5276b 100644 --- a/canopeum_backend/canopeum_backend/management/commands/initialize_database.py +++ b/canopeum_backend/canopeum_backend/management/commands/initialize_database.py @@ -256,10 +256,12 @@ def create_batches_for_site(site): total_propagation=random.randint(0, number_of_seed), ) create_batch_species_for_batch(batch) - Batchfertilizer.objects.create( - batch=batch, - fertilizer_type=Fertilizertype.objects.first(), - ) + fertilizer_type = Fertilizertype.objects.first() + if fertilizer_type is not None: + Batchfertilizer.objects.create( + batch=batch, + fertilizer_type=fertilizer_type, + ) class Command(BaseCommand): diff --git a/canopeum_backend/canopeum_backend/migrations/0005_made_batch_join_tables_properties_non_nullable.py b/canopeum_backend/canopeum_backend/migrations/0005_made_batch_join_tables_properties_non_nullable.py new file mode 100644 index 000000000..8bf59e2fb --- /dev/null +++ b/canopeum_backend/canopeum_backend/migrations/0005_made_batch_join_tables_properties_non_nullable.py @@ -0,0 +1,74 @@ +# Generated by Django 5.1 on 2024-09-26 18:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('canopeum_backend', '0004_remove_setting_blank_with_date_auto'), + ] + + operations = [ + migrations.AlterField( + model_name='batchfertilizer', + name='batch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.batch'), + ), + migrations.AlterField( + model_name='batchfertilizer', + name='fertilizer_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.fertilizertype'), + ), + migrations.AlterField( + model_name='batchmulchlayer', + name='batch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.batch'), + ), + migrations.AlterField( + model_name='batchmulchlayer', + name='mulch_layer_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.mulchlayertype'), + ), + migrations.AlterField( + model_name='batchseed', + name='batch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.batch'), + ), + migrations.AlterField( + model_name='batchseed', + name='quantity', + field=models.IntegerField(), + ), + migrations.AlterField( + model_name='batchseed', + name='tree_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.treetype'), + ), + migrations.AlterField( + model_name='batchspecies', + name='batch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.batch'), + ), + migrations.AlterField( + model_name='batchspecies', + name='quantity', + field=models.IntegerField(), + ), + migrations.AlterField( + model_name='batchspecies', + name='tree_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.treetype'), + ), + migrations.AlterField( + model_name='batchsupportedspecies', + name='batch', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.batch'), + ), + migrations.AlterField( + model_name='batchsupportedspecies', + name='tree_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='canopeum_backend.treetype'), + ), + ] diff --git a/canopeum_backend/canopeum_backend/models.py b/canopeum_backend/canopeum_backend/models.py index 887c6655a..83cdd090c 100644 --- a/canopeum_backend/canopeum_backend/models.py +++ b/canopeum_backend/canopeum_backend/models.py @@ -225,8 +225,8 @@ class Fertilizertype(models.Model): class Batchfertilizer(models.Model): - batch = models.ForeignKey(Batch, models.CASCADE, blank=True, null=True) - fertilizer_type = models.ForeignKey(Fertilizertype, models.DO_NOTHING, blank=True, null=True) + batch = models.ForeignKey(Batch, models.CASCADE) + fertilizer_type = models.ForeignKey(Fertilizertype, models.DO_NOTHING) class Meta: constraints = ( @@ -248,8 +248,8 @@ class Mulchlayertype(models.Model): class Batchmulchlayer(models.Model): - batch = models.ForeignKey(Batch, models.CASCADE, blank=True, null=True) - mulch_layer_type = models.ForeignKey(Mulchlayertype, models.DO_NOTHING, blank=True, null=True) + batch = models.ForeignKey(Batch, models.CASCADE) + mulch_layer_type = models.ForeignKey(Mulchlayertype, models.DO_NOTHING) class Meta: constraints = ( @@ -271,9 +271,9 @@ class Treetype(models.Model): class BatchSpecies(models.Model): - batch = models.ForeignKey(Batch, 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) + batch = models.ForeignKey(Batch, models.CASCADE) + tree_type = models.ForeignKey(Treetype, models.DO_NOTHING) + quantity = models.IntegerField() class Meta: constraints = ( @@ -282,9 +282,9 @@ class Meta: class BatchSeed(models.Model): - batch = models.ForeignKey(Batch, 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) + batch = models.ForeignKey(Batch, models.CASCADE) + tree_type = models.ForeignKey(Treetype, models.DO_NOTHING) + quantity = models.IntegerField() class Meta: constraints = ( @@ -293,8 +293,8 @@ class Meta: class BatchSupportedSpecies(models.Model): - batch = models.ForeignKey(Batch, models.CASCADE, blank=True, null=True) - tree_type = models.ForeignKey(Treetype, models.DO_NOTHING, blank=True, null=True) + batch = models.ForeignKey(Batch, models.CASCADE) + tree_type = models.ForeignKey(Treetype, models.DO_NOTHING) class Meta: constraints = ( diff --git a/canopeum_backend/canopeum_backend/serializers.py b/canopeum_backend/canopeum_backend/serializers.py index 211bbe910..f385dee8c 100644 --- a/canopeum_backend/canopeum_backend/serializers.py +++ b/canopeum_backend/canopeum_backend/serializers.py @@ -425,85 +425,28 @@ def get_fr(self, obj: Batchmulchlayer): ) -class BatchSupportedSpeciesSerializer(serializers.ModelSerializer[BatchSupportedSpecies]): - id = serializers.SerializerMethodField() - en = serializers.SerializerMethodField() - fr = serializers.SerializerMethodField() - - class Meta: - model = BatchSupportedSpecies - fields = ("id", "en", "fr") - - def get_id(self, obj: BatchSupportedSpecies): - return TreeTypeSerializer(obj.tree_type).data.get("id", None) - - def get_en(self, obj: BatchSupportedSpecies): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("en", None) - if obj.tree_type - else None - ) - - def get_fr(self, obj: BatchSupportedSpecies): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("fr", None) - if obj.tree_type - else None - ) - - class BatchSeedSerializer(serializers.ModelSerializer[BatchSeed]): - id = serializers.SerializerMethodField() - en = serializers.SerializerMethodField() - fr = serializers.SerializerMethodField() + tree_type = serializers.SerializerMethodField() class Meta: model = BatchSeed - fields = ("id", "quantity", "en", "fr") + fields = ("id", "quantity", "tree_type") - def get_id(self, obj: BatchSeed): - return TreeTypeSerializer(obj.tree_type).data.get("id", None) - - def get_en(self, obj: BatchSeed): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("en", None) - if obj.tree_type - else None - ) - - def get_fr(self, obj: BatchSeed): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("fr", None) - if obj.tree_type - else None - ) + @extend_schema_field(TreeTypeSerializer) + def get_tree_type(self, obj: BatchSeed): + return TreeTypeSerializer(obj.tree_type).data class BatchSpeciesSerializer(serializers.ModelSerializer[BatchSpecies]): - id = serializers.SerializerMethodField() - en = serializers.SerializerMethodField() - fr = serializers.SerializerMethodField() + tree_type = serializers.SerializerMethodField() class Meta: model = BatchSpecies - fields = ("id", "quantity", "en", "fr") + fields = ("id", "quantity", "tree_type") - def get_id(self, obj: BatchSpecies): - return TreeTypeSerializer(obj.tree_type).data.get("id", None) - - def get_en(self, obj: BatchSpecies): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("en", None) - if obj.tree_type - else None - ) - - def get_fr(self, obj: BatchSpecies): - return ( - InternationalizationSerializer(obj.tree_type.name).data.get("fr", None) - if obj.tree_type - else None - ) + @extend_schema_field(TreeTypeSerializer) + def get_tree_type(self, obj: BatchSpecies): + return TreeTypeSerializer(obj.tree_type).data class BatchDetailSerializer(serializers.ModelSerializer[Batch]): @@ -520,25 +463,39 @@ class Meta: model = Batch fields = "__all__" - @extend_schema_field(BatchfertilizerSerializer(many=True)) - def get_fertilizers(self, obj): - return BatchfertilizerSerializer(obj.batchfertilizer_set.all(), many=True).data - - @extend_schema_field(BatchMulchLayerSerializer(many=True)) - def get_mulch_layers(self, obj): - return BatchMulchLayerSerializer(obj.batchmulchlayer_set.all(), many=True).data - - @extend_schema_field(BatchSupportedSpeciesSerializer(many=True)) - def get_supported_species(self, obj): - return BatchSupportedSpeciesSerializer(obj.batchsupportedspecies_set.all(), many=True).data + @extend_schema_field(FertilizerTypeSerializer(many=True)) + def get_fertilizers(self, obj: Batch): + batch_fertilizers = Batchfertilizer.objects.filter(batch=obj) + fertilizer_types = [ + batch_fertilizer.fertilizer_type for batch_fertilizer in batch_fertilizers + ] + + return FertilizerTypeSerializer(fertilizer_types, many=True).data + + @extend_schema_field(MulchLayerTypeSerializer(many=True)) + def get_mulch_layers(self, obj: Batch): + batch_mulch_layers = Batchmulchlayer.objects.filter(batch=obj) + mulch_layer_types = [ + batch_mulch_layer.mulch_layer_type for batch_mulch_layer in batch_mulch_layers + ] + return MulchLayerTypeSerializer(mulch_layer_types, many=True).data + + @extend_schema_field(TreeTypeSerializer(many=True)) + def get_supported_species(self, obj: Batch): + batch_supported_species_list = BatchSupportedSpecies.objects.filter(batch=obj) + supported_species_types = [ + batch_supported_species.tree_type + for batch_supported_species in batch_supported_species_list + ] + return TreeTypeSerializer(supported_species_types, many=True).data @extend_schema_field(BatchSeedSerializer(many=True)) def get_seeds(self, obj): - return BatchSeedSerializer(obj.batchseed_set.all(), many=True).data + return BatchSeedSerializer(BatchSeed.objects.filter(batch=obj), many=True).data @extend_schema_field(BatchSpeciesSerializer(many=True)) def get_species(self, obj): - return BatchSpeciesSerializer(obj.batchspecies_set.all(), many=True).data + return BatchSpeciesSerializer(BatchSpecies.objects.filter(batch=obj), many=True).data class SiteAdminSerializer(serializers.ModelSerializer[Siteadmin]): diff --git a/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx b/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx index a352d9284..e26305429 100644 --- a/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx +++ b/canopeum_frontend/src/components/analytics/AnalyticsSiteHeader.tsx @@ -10,10 +10,10 @@ import { LanguageContext } from '@components/context/LanguageContext' import CustomIconBadge from '@components/CustomIconBadge' import { appRoutes } from '@constants/routes.constant' import { getImageNameByWMOCategories } from '@constants/weatherImageMap' -import type { SiteDetailSummary } from '@services/api' +import type { SiteSummaryDetail } from '@services/api' type Props = { - readonly siteSummary: SiteDetailSummary, + readonly siteSummary: SiteSummaryDetail, } const AnalyticsSiteHeader = ({ siteSummary }: Props) => { diff --git a/canopeum_frontend/src/components/analytics/BatchTable.tsx b/canopeum_frontend/src/components/analytics/BatchTable.tsx index ea529acd8..aad1c7f2a 100644 --- a/canopeum_frontend/src/components/analytics/BatchTable.tsx +++ b/canopeum_frontend/src/components/analytics/BatchTable.tsx @@ -108,9 +108,9 @@ const BatchTable = (props: Props) => { style={{ borderColor: cellBorderColor }} >
    - {batch.species.map(type => ( -
  • - {translateValue(type)} x {type.quantity} + {batch.species.map(batchSpecies => ( +
  • + {translateValue(batchSpecies.treeType)} x {batchSpecies.quantity}
  • ))}
@@ -299,8 +299,8 @@ const BatchTable = (props: Props) => { >
    {batch.seeds.map(batchSeed => ( -
  • - {translateValue(batchSeed)} x {batchSeed.quantity} +
  • + {translateValue(batchSeed.treeType)} x {batchSeed.quantity}
  • ))}
diff --git a/canopeum_frontend/src/components/analytics/FertilizersSelector.tsx b/canopeum_frontend/src/components/analytics/FertilizersSelector.tsx index d254e7467..61b427d55 100644 --- a/canopeum_frontend/src/components/analytics/FertilizersSelector.tsx +++ b/canopeum_frontend/src/components/analytics/FertilizersSelector.tsx @@ -4,13 +4,13 @@ import { useTranslation } from 'react-i18next' import OptionQuantitySelector, { type SelectorOption, type SelectorOptionQuantity } from '@components/analytics/OptionQuantitySelector' import { LanguageContext } from '@components/context/LanguageContext' import useApiClient from '@hooks/ApiClientHook' -import { Batchfertilizer, type FertilizerType } from '@services/api' +import { FertilizerType } from '@services/api' import { notEmpty } from '@utils/arrayUtils' type Props = { readonly fertilizers?: FertilizerType[], // Make sure that onChange is included in a useCallback if part of a component - readonly onChange: (selectedFertilizers: Batchfertilizer[]) => void, + readonly onChange: (selectedFertilizers: FertilizerType[]) => void, } const FertilizersSelector = ({ onChange, fertilizers }: Props) => { @@ -66,7 +66,7 @@ const FertilizersSelector = ({ onChange, fertilizers }: Props) => { const matchingFertilizer = availableFertilizers.get(optionQuantity.option.value) if (!matchingFertilizer) return null - return new Batchfertilizer({ + return new FertilizerType({ ...matchingFertilizer, }) }) diff --git a/canopeum_frontend/src/components/analytics/MulchLayersSelector.tsx b/canopeum_frontend/src/components/analytics/MulchLayersSelector.tsx index 03459b38f..8d0cdae84 100644 --- a/canopeum_frontend/src/components/analytics/MulchLayersSelector.tsx +++ b/canopeum_frontend/src/components/analytics/MulchLayersSelector.tsx @@ -4,14 +4,13 @@ import { useTranslation } from 'react-i18next' import OptionQuantitySelector, { type SelectorOption, type SelectorOptionQuantity } from '@components/analytics/OptionQuantitySelector' import { LanguageContext } from '@components/context/LanguageContext' import useApiClient from '@hooks/ApiClientHook' -import type { MulchLayerType } from '@services/api' -import { BatchMulchLayer } from '@services/api' +import { MulchLayerType } from '@services/api' import { notEmpty } from '@utils/arrayUtils' type Props = { readonly mulchLayers?: MulchLayerType[], // Make sure that onChange is included in a useCallback if part of a component - readonly onChange: (selectedMulchLayers: BatchMulchLayer[]) => void, + readonly onChange: (selectedMulchLayers: MulchLayerType[]) => void, } const MulchLayersSelector = ({ onChange, mulchLayers }: Props) => { @@ -67,7 +66,7 @@ const MulchLayersSelector = ({ onChange, mulchLayers }: Props) => { const matchingMulchLayer = availableMulchLayers.get(optionQuantity.option.value) if (!matchingMulchLayer) return null - return new BatchMulchLayer({ + return new MulchLayerType({ ...matchingMulchLayer, }) }) diff --git a/canopeum_frontend/src/components/analytics/SupportSpeciesSelector.tsx b/canopeum_frontend/src/components/analytics/SupportSpeciesSelector.tsx index dc665efd4..4faaa709d 100644 --- a/canopeum_frontend/src/components/analytics/SupportSpeciesSelector.tsx +++ b/canopeum_frontend/src/components/analytics/SupportSpeciesSelector.tsx @@ -4,14 +4,13 @@ import { useTranslation } from 'react-i18next' import OptionQuantitySelector, { type SelectorOption, type SelectorOptionQuantity } from '@components/analytics/OptionQuantitySelector' import { LanguageContext } from '@components/context/LanguageContext' import useApiClient from '@hooks/ApiClientHook' -import type { TreeType } from '@services/api' -import { BatchSupportedSpecies } from '@services/api' +import { TreeType } from '@services/api' import { notEmpty } from '@utils/arrayUtils' type Props = { - readonly species?: BatchSupportedSpecies[], + readonly species?: TreeType[], // Make sure that onChange is included in a useCallback if part of a component - readonly onChange: (selectedSpecies: BatchSupportedSpecies[]) => void, + readonly onChange: (selectedSpecies: TreeType[]) => void, } const SupportSpeciesSelector = ({ onChange, species }: Props) => { @@ -65,7 +64,7 @@ const SupportSpeciesSelector = ({ onChange, species }: Props) => { const matchingSpecie = availableSpecies.get(optionQuantity.option.value) if (!matchingSpecie) return null - return new BatchSupportedSpecies({ + return new TreeType({ ...matchingSpecie, }) }) diff --git a/canopeum_frontend/src/components/analytics/batch-modal/CreateBatchModal.tsx b/canopeum_frontend/src/components/analytics/batch-modal/CreateBatchModal.tsx index 1bba0f4e3..dc44d0baa 100644 --- a/canopeum_frontend/src/components/analytics/batch-modal/CreateBatchModal.tsx +++ b/canopeum_frontend/src/components/analytics/batch-modal/CreateBatchModal.tsx @@ -10,7 +10,7 @@ import SupportSpeciesSelector from '@components/analytics/SupportSpeciesSelector import TreeSpeciesSelector from '@components/analytics/TreeSpeciesSelector' import { SnackbarContext } from '@components/context/SnackbarContext' import useApiClient from '@hooks/ApiClientHook' -import type { Batchfertilizer, BatchMulchLayer, BatchSupportedSpecies, Seeds, SiteSummary, Species } from '@services/api' +import type { FertilizerType, MulchLayerType, Seeds, SiteSummary, Species, TreeType } from '@services/api' import { assetFormatter } from '@utils/assetFormatter' import { floorNumberValue } from '@utils/formUtils' @@ -32,11 +32,11 @@ type CreateBatchDto = { totalNumberSeed?: number, totalPropagation?: number, image?: File, - fertilizers: Batchfertilizer[], - mulchLayers: BatchMulchLayer[], + fertilizers: FertilizerType[], + mulchLayers: MulchLayerType[], seeds: Seeds[], species: Species[], - supportedSpecies: BatchSupportedSpecies[], + supportedSpecies: TreeType[], } const defaultCreateBatch: CreateBatchDto = { diff --git a/canopeum_frontend/src/components/analytics/batch-modal/EditBatchModal.tsx b/canopeum_frontend/src/components/analytics/batch-modal/EditBatchModal.tsx index a039b906f..03f23ee71 100644 --- a/canopeum_frontend/src/components/analytics/batch-modal/EditBatchModal.tsx +++ b/canopeum_frontend/src/components/analytics/batch-modal/EditBatchModal.tsx @@ -9,7 +9,8 @@ import SupportSpeciesSelector from '@components/analytics/SupportSpeciesSelector import TreeSpeciesSelector from '@components/analytics/TreeSpeciesSelector' import { SnackbarContext } from '@components/context/SnackbarContext' import useApiClient from '@hooks/ApiClientHook' -import { type BatchDetail, type Batchfertilizer, type BatchMulchLayer, type BatchSupportedSpecies, Seeds, Species } from '@services/api' +import type { BatchDetail, FertilizerType, MulchLayerType, TreeType } from '@services/api' +import { Seeds, Species } from '@services/api' import { floorNumberValue } from '@utils/formUtils' type Props = { @@ -29,17 +30,21 @@ type EditBatchDto = { totalNumberSeed?: number, totalPropagation?: number, // TODO: image?: File, - fertilizers: Batchfertilizer[], - mulchLayers: BatchMulchLayer[], + fertilizers: FertilizerType[], + mulchLayers: MulchLayerType[], seeds: Seeds[], species: Species[], - supportedSpecies: BatchSupportedSpecies[], + supportedSpecies: TreeType[], } const transformToEditBatchDto = (batchDetail: BatchDetail): EditBatchDto => ({ ...batchDetail, - seeds: batchDetail.seeds.map(seed => new Seeds(seed)), - species: batchDetail.species.map(specie => new Species(specie)), + seeds: batchDetail.seeds.map(batchSeed => + new Seeds({ id: batchSeed.treeType.id, quantity: batchSeed.quantity }) + ), + species: batchDetail.species.map(batchSpecies => + new Species({ id: batchSpecies.treeType.id, quantity: batchSpecies.quantity }) + ), }) const BatchModal = ({ batchToEdit, handleClose }: Props) => { diff --git a/canopeum_frontend/src/pages/AnalyticsSite.tsx b/canopeum_frontend/src/pages/AnalyticsSite.tsx index 53f149061..08ffa635b 100644 --- a/canopeum_frontend/src/pages/AnalyticsSite.tsx +++ b/canopeum_frontend/src/pages/AnalyticsSite.tsx @@ -8,7 +8,7 @@ import CreateBatchModal from '@components/analytics/batch-modal/CreateBatchModal import BatchTable from '@components/analytics/BatchTable' import { LanguageContext } from '@components/context/LanguageContext' import useApiClient from '@hooks/ApiClientHook' -import type { SiteDetailSummary } from '@services/api' +import type { SiteSummaryDetail } from '@services/api' const AnalyticsSite = () => { const { t: translate } = useTranslation<'analytics'>() @@ -16,7 +16,7 @@ const AnalyticsSite = () => { const { formatDate } = useContext(LanguageContext) const { getApiClient } = useApiClient() - const [siteSummary, setSiteSummary] = useState() + const [siteSummary, setSiteSummary] = useState() const [lastModifiedBatchDate, setLastModifiedBatchDate] = useState() const [isCreateBatchOpen, setIsCreateBatchOpen] = useState(false) diff --git a/canopeum_frontend/src/services/api.ts b/canopeum_frontend/src/services/api.ts index b561f01fc..91769300e 100644 --- a/canopeum_frontend/src/services/api.ts +++ b/canopeum_frontend/src/services/api.ts @@ -1178,7 +1178,7 @@ export class SiteClient { return Promise.resolve(null as any) } - summary(siteId: number): Promise { + summary(siteId: number): Promise { let url_ = this.baseUrl + '/analytics/sites/{siteId}/summary' if (siteId === undefined || siteId === null) { throw new Error("The parameter 'siteId' must be defined.") @@ -1198,7 +1198,7 @@ export class SiteClient { }) } - protected processSummary(response: Response): Promise { + protected processSummary(response: Response): Promise { const status = response.status let _headers: any = {} if (response.headers && response.headers.forEach) { @@ -1210,7 +1210,7 @@ export class SiteClient { let resultData200 = _responseText === '' ? null : JSON.parse(_responseText, this.jsonParseReviver) - result200 = SiteDetailSummary.fromJS(resultData200) + result200 = SiteSummaryDetail.fromJS(resultData200) return result200 }) } else if (status !== 200 && status !== 204) { @@ -1223,7 +1223,7 @@ export class SiteClient { ) }) } - return Promise.resolve(null as any) + return Promise.resolve(null as any) } map(): Promise { @@ -3222,14 +3222,14 @@ export interface IAsset { export class BatchDetail implements IBatchDetail { readonly id!: number - readonly fertilizers!: Batchfertilizer[] - readonly mulchLayers!: BatchMulchLayer[] - readonly supportedSpecies!: BatchSupportedSpecies[] + readonly fertilizers!: FertilizerType[] + readonly mulchLayers!: MulchLayerType[] + readonly supportedSpecies!: TreeType[] readonly seeds!: BatchSeed[] readonly species!: BatchSpecies[] image?: Asset - createdAt?: Date | undefined - updatedAt?: Date | undefined + readonly createdAt!: Date | undefined + readonly updatedAt!: Date | undefined name?: string | undefined sponsor?: string | undefined size?: number | undefined @@ -3271,19 +3271,19 @@ export class BatchDetail implements IBatchDetail { if (Array.isArray(_data['fertilizers'])) { ;( this).fertilizers = [] as any for (let item of _data['fertilizers']) { - ;( this).fertilizers!.push(Batchfertilizer.fromJS(item)) + ;( this).fertilizers!.push(FertilizerType.fromJS(item)) } } if (Array.isArray(_data['mulchLayers'])) { ;( this).mulchLayers = [] as any for (let item of _data['mulchLayers']) { - ;( this).mulchLayers!.push(BatchMulchLayer.fromJS(item)) + ;( this).mulchLayers!.push(MulchLayerType.fromJS(item)) } } if (Array.isArray(_data['supportedSpecies'])) { ;( this).supportedSpecies = [] as any for (let item of _data['supportedSpecies']) { - ;( this).supportedSpecies!.push(BatchSupportedSpecies.fromJS(item)) + ;( this).supportedSpecies!.push(TreeType.fromJS(item)) } } if (Array.isArray(_data['seeds'])) { @@ -3299,10 +3299,10 @@ export class BatchDetail implements IBatchDetail { } } this.image = _data['image'] ? Asset.fromJS(_data['image']) : undefined - this.createdAt = _data['createdAt'] + ;( this).createdAt = _data['createdAt'] ? new Date(_data['createdAt'].toString()) : undefined - this.updatedAt = _data['updatedAt'] + ;( this).updatedAt = _data['updatedAt'] ? new Date(_data['updatedAt'].toString()) : undefined this.name = _data['name'] @@ -3382,14 +3382,14 @@ export class BatchDetail implements IBatchDetail { export interface IBatchDetail { id: number - fertilizers: Batchfertilizer[] - mulchLayers: BatchMulchLayer[] - supportedSpecies: BatchSupportedSpecies[] + fertilizers: FertilizerType[] + mulchLayers: MulchLayerType[] + supportedSpecies: TreeType[] seeds: BatchSeed[] species: BatchSpecies[] image?: Asset - createdAt?: Date | undefined - updatedAt?: Date | undefined + createdAt: Date | undefined + updatedAt: Date | undefined name?: string | undefined sponsor?: string | undefined size?: number | undefined @@ -3404,70 +3404,10 @@ export interface IBatchDetail { [key: string]: any } -export class BatchMulchLayer implements IBatchMulchLayer { - readonly id!: number - readonly en!: string - readonly fr!: string; - - [key: string]: any - - constructor(data?: IBatchMulchLayer) { - if (data) { - for (var property in data) { - if (data.hasOwnProperty(property)) { - ;( this)[property] = ( data)[property] - } - } - } - } - - init(_data?: any) { - if (_data) { - for (var property in _data) { - if (_data.hasOwnProperty(property)) { - this[property] = _data[property] - } - } - ;( this).id = _data['id'] - ;( this).en = _data['en'] - ;( this).fr = _data['fr'] - } - } - - static fromJS(data: any): BatchMulchLayer { - data = typeof data === 'object' ? data : {} - let result = new BatchMulchLayer() - result.init(data) - return result - } - - toJSON(data?: any) { - data = typeof data === 'object' ? data : {} - for (var property in this) { - if (this.hasOwnProperty(property)) { - data[property] = this[property] - } - } - data['id'] = this.id - data['en'] = this.en - data['fr'] = this.fr - return data - } -} - -export interface IBatchMulchLayer { - id: number - en: string - fr: string - - [key: string]: any -} - export class BatchSeed implements IBatchSeed { readonly id!: number - quantity?: number | undefined - readonly en!: string - readonly fr!: string; + quantity!: number + readonly treeType!: TreeType; [key: string]: any @@ -3479,6 +3419,9 @@ export class BatchSeed implements IBatchSeed { } } } + if (!data) { + this.treeType = new TreeType() + } } init(_data?: any) { @@ -3490,8 +3433,9 @@ export class BatchSeed implements IBatchSeed { } ;( this).id = _data['id'] this.quantity = _data['quantity'] - ;( this).en = _data['en'] - ;( this).fr = _data['fr'] + ;( this).treeType = _data['treeType'] + ? TreeType.fromJS(_data['treeType']) + : new TreeType() } } @@ -3511,25 +3455,23 @@ export class BatchSeed implements IBatchSeed { } data['id'] = this.id data['quantity'] = this.quantity - data['en'] = this.en - data['fr'] = this.fr + data['treeType'] = this.treeType ? this.treeType.toJSON() : undefined return data } } export interface IBatchSeed { - quantity?: number | undefined - en: string - fr: string + id: number + quantity: number + treeType: TreeType [key: string]: any } export class BatchSpecies implements IBatchSpecies { readonly id!: number - quantity?: number | undefined - readonly en!: string - readonly fr!: string; + quantity!: number + readonly treeType!: TreeType; [key: string]: any @@ -3541,6 +3483,9 @@ export class BatchSpecies implements IBatchSpecies { } } } + if (!data) { + this.treeType = new TreeType() + } } init(_data?: any) { @@ -3552,8 +3497,9 @@ export class BatchSpecies implements IBatchSpecies { } ;( this).id = _data['id'] this.quantity = _data['quantity'] - ;( this).en = _data['en'] - ;( this).fr = _data['fr'] + ;( this).treeType = _data['treeType'] + ? TreeType.fromJS(_data['treeType']) + : new TreeType() } } @@ -3573,134 +3519,15 @@ export class BatchSpecies implements IBatchSpecies { } data['id'] = this.id data['quantity'] = this.quantity - data['en'] = this.en - data['fr'] = this.fr + data['treeType'] = this.treeType ? this.treeType.toJSON() : undefined return data } } export interface IBatchSpecies { - quantity?: number | undefined - en: string - fr: string - - [key: string]: any -} - -export class BatchSupportedSpecies implements IBatchSupportedSpecies { - readonly id!: number - readonly en!: string - readonly fr!: string; - - [key: string]: any - - constructor(data?: IBatchSupportedSpecies) { - if (data) { - for (var property in data) { - if (data.hasOwnProperty(property)) { - ;( this)[property] = ( data)[property] - } - } - } - } - - init(_data?: any) { - if (_data) { - for (var property in _data) { - if (_data.hasOwnProperty(property)) { - this[property] = _data[property] - } - } - ;( this).id = _data['id'] - ;( this).en = _data['en'] - ;( this).fr = _data['fr'] - } - } - - static fromJS(data: any): BatchSupportedSpecies { - data = typeof data === 'object' ? data : {} - let result = new BatchSupportedSpecies() - result.init(data) - return result - } - - toJSON(data?: any) { - data = typeof data === 'object' ? data : {} - for (var property in this) { - if (this.hasOwnProperty(property)) { - data[property] = this[property] - } - } - data['id'] = this.id - data['en'] = this.en - data['fr'] = this.fr - return data - } -} - -export interface IBatchSupportedSpecies { - id: number - en: string - fr: string - - [key: string]: any -} - -export class Batchfertilizer implements IBatchfertilizer { - readonly id!: number - readonly en!: string - readonly fr!: string; - - [key: string]: any - - constructor(data?: IBatchfertilizer) { - if (data) { - for (var property in data) { - if (data.hasOwnProperty(property)) { - ;( this)[property] = ( data)[property] - } - } - } - } - - init(_data?: any) { - if (_data) { - for (var property in _data) { - if (_data.hasOwnProperty(property)) { - this[property] = _data[property] - } - } - ;( this).id = _data['id'] - ;( this).en = _data['en'] - ;( this).fr = _data['fr'] - } - } - - static fromJS(data: any): Batchfertilizer { - data = typeof data === 'object' ? data : {} - let result = new Batchfertilizer() - result.init(data) - return result - } - - toJSON(data?: any) { - data = typeof data === 'object' ? data : {} - for (var property in this) { - if (this.hasOwnProperty(property)) { - data[property] = this[property] - } - } - data['id'] = this.id - data['en'] = this.en - data['fr'] = this.fr - return data - } -} - -export interface IBatchfertilizer { id: number - en: string - fr: string + quantity: number + treeType: TreeType [key: string]: any } @@ -5192,145 +5019,6 @@ export interface ISiteAdmin { [key: string]: any } -export class SiteDetailSummary implements ISiteDetailSummary { - readonly id!: number - name!: string - coordinate!: Coordinates - siteType!: SiteType - readonly plantCount!: number - readonly survivedCount!: number - readonly propagationCount!: number - visitorCount?: number | undefined - readonly sponsors!: string[] - readonly progress!: number - admins!: SiteAdmin[] - readonly batches!: BatchDetail[] - readonly weather!: Weather; - - [key: string]: any - - constructor(data?: ISiteDetailSummary) { - if (data) { - for (var property in data) { - if (data.hasOwnProperty(property)) { - ;( this)[property] = ( data)[property] - } - } - } - if (!data) { - this.coordinate = new Coordinates() - this.siteType = new SiteType() - this.sponsors = [] - this.admins = [] - this.batches = [] - this.weather = new Weather() - } - } - - init(_data?: any) { - if (_data) { - for (var property in _data) { - if (_data.hasOwnProperty(property)) { - this[property] = _data[property] - } - } - ;( this).id = _data['id'] - this.name = _data['name'] - this.coordinate = _data['coordinate'] - ? Coordinates.fromJS(_data['coordinate']) - : new Coordinates() - this.siteType = _data['siteType'] ? SiteType.fromJS(_data['siteType']) : new SiteType() - ;( this).plantCount = _data['plantCount'] - ;( this).survivedCount = _data['survivedCount'] - ;( this).propagationCount = _data['propagationCount'] - this.visitorCount = _data['visitorCount'] - if (Array.isArray(_data['sponsors'])) { - ;( this).sponsors = [] as any - for (let item of _data['sponsors']) { - ;( this).sponsors!.push(item) - } - } - ;( this).progress = _data['progress'] - if (Array.isArray(_data['admins'])) { - this.admins = [] as any - for (let item of _data['admins']) { - this.admins!.push(SiteAdmin.fromJS(item)) - } - } - if (Array.isArray(_data['batches'])) { - ;( this).batches = [] as any - for (let item of _data['batches']) { - ;( this).batches!.push(BatchDetail.fromJS(item)) - } - } - ;( this).weather = _data['weather'] ? Weather.fromJS(_data['weather']) : new Weather() - } - } - - static fromJS(data: any): SiteDetailSummary { - data = typeof data === 'object' ? data : {} - let result = new SiteDetailSummary() - result.init(data) - return result - } - - toJSON(data?: any) { - data = typeof data === 'object' ? data : {} - for (var property in this) { - if (this.hasOwnProperty(property)) { - data[property] = this[property] - } - } - data['id'] = this.id - data['name'] = this.name - data['coordinate'] = this.coordinate ? this.coordinate.toJSON() : undefined - data['siteType'] = this.siteType ? this.siteType.toJSON() : undefined - data['plantCount'] = this.plantCount - data['survivedCount'] = this.survivedCount - data['propagationCount'] = this.propagationCount - data['visitorCount'] = this.visitorCount - if (Array.isArray(this.sponsors)) { - data['sponsors'] = [] - for (let item of this.sponsors) { - data['sponsors'].push(item) - } - } - data['progress'] = this.progress - if (Array.isArray(this.admins)) { - data['admins'] = [] - for (let item of this.admins) { - data['admins'].push(item.toJSON()) - } - } - if (Array.isArray(this.batches)) { - data['batches'] = [] - for (let item of this.batches) { - data['batches'].push(item.toJSON()) - } - } - data['weather'] = this.weather ? this.weather.toJSON() : undefined - return data - } -} - -export interface ISiteDetailSummary { - id: number - name: string - coordinate: Coordinates - siteType: SiteType - plantCount: number - survivedCount: number - propagationCount: number - visitorCount?: number | undefined - sponsors: string[] - progress: number - admins: SiteAdmin[] - batches: BatchDetail[] - weather: Weather - - [key: string]: any -} - export class SiteMap implements ISiteMap { readonly id!: number name!: string @@ -5773,6 +5461,145 @@ export interface ISiteSummary { [key: string]: any } +export class SiteSummaryDetail implements ISiteSummaryDetail { + readonly id!: number + name!: string + coordinate!: Coordinates + siteType!: SiteType + readonly plantCount!: number + readonly survivedCount!: number + readonly propagationCount!: number + visitorCount?: number | undefined + readonly sponsors!: string[] + readonly progress!: number + admins!: SiteAdmin[] + readonly batches!: BatchDetail[] + readonly weather!: Weather; + + [key: string]: any + + constructor(data?: ISiteSummaryDetail) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) { + ;( this)[property] = ( data)[property] + } + } + } + if (!data) { + this.coordinate = new Coordinates() + this.siteType = new SiteType() + this.sponsors = [] + this.admins = [] + this.batches = [] + this.weather = new Weather() + } + } + + init(_data?: any) { + if (_data) { + for (var property in _data) { + if (_data.hasOwnProperty(property)) { + this[property] = _data[property] + } + } + ;( this).id = _data['id'] + this.name = _data['name'] + this.coordinate = _data['coordinate'] + ? Coordinates.fromJS(_data['coordinate']) + : new Coordinates() + this.siteType = _data['siteType'] ? SiteType.fromJS(_data['siteType']) : new SiteType() + ;( this).plantCount = _data['plantCount'] + ;( this).survivedCount = _data['survivedCount'] + ;( this).propagationCount = _data['propagationCount'] + this.visitorCount = _data['visitorCount'] + if (Array.isArray(_data['sponsors'])) { + ;( this).sponsors = [] as any + for (let item of _data['sponsors']) { + ;( this).sponsors!.push(item) + } + } + ;( this).progress = _data['progress'] + if (Array.isArray(_data['admins'])) { + this.admins = [] as any + for (let item of _data['admins']) { + this.admins!.push(SiteAdmin.fromJS(item)) + } + } + if (Array.isArray(_data['batches'])) { + ;( this).batches = [] as any + for (let item of _data['batches']) { + ;( this).batches!.push(BatchDetail.fromJS(item)) + } + } + ;( this).weather = _data['weather'] ? Weather.fromJS(_data['weather']) : new Weather() + } + } + + static fromJS(data: any): SiteSummaryDetail { + data = typeof data === 'object' ? data : {} + let result = new SiteSummaryDetail() + result.init(data) + return result + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {} + for (var property in this) { + if (this.hasOwnProperty(property)) { + data[property] = this[property] + } + } + data['id'] = this.id + data['name'] = this.name + data['coordinate'] = this.coordinate ? this.coordinate.toJSON() : undefined + data['siteType'] = this.siteType ? this.siteType.toJSON() : undefined + data['plantCount'] = this.plantCount + data['survivedCount'] = this.survivedCount + data['propagationCount'] = this.propagationCount + data['visitorCount'] = this.visitorCount + if (Array.isArray(this.sponsors)) { + data['sponsors'] = [] + for (let item of this.sponsors) { + data['sponsors'].push(item) + } + } + data['progress'] = this.progress + if (Array.isArray(this.admins)) { + data['admins'] = [] + for (let item of this.admins) { + data['admins'].push(item.toJSON()) + } + } + if (Array.isArray(this.batches)) { + data['batches'] = [] + for (let item of this.batches) { + data['batches'].push(item.toJSON()) + } + } + data['weather'] = this.weather ? this.weather.toJSON() : undefined + return data + } +} + +export interface ISiteSummaryDetail { + id: number + name: string + coordinate: Coordinates + siteType: SiteType + plantCount: number + survivedCount: number + propagationCount: number + visitorCount?: number | undefined + sponsors: string[] + progress: number + admins: SiteAdmin[] + batches: BatchDetail[] + weather: Weather + + [key: string]: any +} + export class SiteType implements ISiteType { readonly id!: number readonly en!: string From 94c5218925377a053426a497aea5c1a6e497b930 Mon Sep 17 00:00:00 2001 From: Nicolas Dontigny <45007853+NicolasDontigny@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:04:58 -0400 Subject: [PATCH 04/30] Feature/253 create shared batch form (#254) * Extracted BatchForm in its own component --------- Co-authored-by: Nicolas Dontigny --- .../migrations/0006_alter_batch_site.py | 19 ++ canopeum_backend/canopeum_backend/models.py | 2 +- .../src/components/analytics/ImageUpload.tsx | 9 +- .../analytics/batch-modal/BatchForm.tsx | 269 +++++++++++++++ .../batch-modal/CreateBatchModal.tsx | 310 +----------------- .../analytics/batch-modal/EditBatchModal.tsx | 293 ++--------------- .../analytics/batch-modal/batchModal.model.ts | 51 +++ .../analytics/site-modal/SiteModal.tsx | 2 +- canopeum_frontend/src/services/api.ts | 4 +- 9 files changed, 384 insertions(+), 575 deletions(-) create mode 100644 canopeum_backend/canopeum_backend/migrations/0006_alter_batch_site.py create mode 100644 canopeum_frontend/src/components/analytics/batch-modal/BatchForm.tsx create mode 100644 canopeum_frontend/src/components/analytics/batch-modal/batchModal.model.ts diff --git a/canopeum_backend/canopeum_backend/migrations/0006_alter_batch_site.py b/canopeum_backend/canopeum_backend/migrations/0006_alter_batch_site.py new file mode 100644 index 000000000..bfa170124 --- /dev/null +++ b/canopeum_backend/canopeum_backend/migrations/0006_alter_batch_site.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1 on 2024-09-26 19:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('canopeum_backend', '0005_made_batch_join_tables_properties_non_nullable'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='canopeum_backend.site'), + ), + ] diff --git a/canopeum_backend/canopeum_backend/models.py b/canopeum_backend/canopeum_backend/models.py index 83cdd090c..7f722803b 100644 --- a/canopeum_backend/canopeum_backend/models.py +++ b/canopeum_backend/canopeum_backend/models.py @@ -178,7 +178,7 @@ def delete(self, using=None, keep_parents=False): class Batch(models.Model): - site = models.ForeignKey(Site, models.CASCADE, blank=True, null=True) + site = models.ForeignKey(Site, models.CASCADE) created_at = models.DateTimeField(auto_now_add=True, null=True) updated_at = models.DateTimeField(auto_now=True, null=True) name = models.TextField(blank=True, null=True) diff --git a/canopeum_frontend/src/components/analytics/ImageUpload.tsx b/canopeum_frontend/src/components/analytics/ImageUpload.tsx index 3fa831b59..838ce120f 100644 --- a/canopeum_frontend/src/components/analytics/ImageUpload.tsx +++ b/canopeum_frontend/src/components/analytics/ImageUpload.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import UploadIcon from '@assets/icons/upload.svg' type Props = { + readonly id: string, readonly imageUrl?: string, readonly onChange: (file: File) => void, } @@ -14,7 +15,7 @@ const supportedFileTypes = [ 'image/jpeg', ] -const ImageUpload = ({ onChange, imageUrl }: Props) => { +const ImageUpload = ({ id, onChange, imageUrl }: Props) => { const { t } = useTranslation() const handleFileChange = ( @@ -35,10 +36,10 @@ const ImageUpload = ({ onChange, imageUrl }: Props) => { } return ( -
+