From c8974da0a65c0eb2d07cc1fd0b44adc7af5c5ae5 Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 26 Feb 2026 20:39:08 -0300 Subject: [PATCH 1/3] Add tissues app and tissue FK integration Introduce a new read-only tissues app (model, admin, serializer, views, URLs, app config) with initial migration and a data migration loading a list of tissue names. Add tissue ForeignKey fields to CGDSStudy and UserFile with corresponding migrations. Update serializers to accept tissue_id (writable PK) and include nested tissue representation on retrieve; persist tissue on updates/creates. Expose tissues in the REST API and enable filtering by tissue for CGDSStudy and UserFile (DjangoFilterBackend imports and filterset_fields). Register the app in settings and project URLs, and add a frontend URL constant for tissues. Admin/UI prevents adding/changing/deleting tissues (read-only). --- .../migrations/0037_cgdsstudy_tissue.py | 28 +++++++++++ src/datasets_synchronization/models.py | 4 +- src/datasets_synchronization/serializers.py | 16 ++++++ src/datasets_synchronization/views.py | 4 +- src/frontend/templates/frontend/base.html | 1 + src/multiomics_intermediate/settings.py | 1 + src/multiomics_intermediate/urls.py | 1 + src/tissues/__init__.py | 0 src/tissues/admin.py | 17 +++++++ src/tissues/apps.py | 6 +++ src/tissues/migrations/0001_initial.py | 31 ++++++++++++ .../migrations/0002_load_initial_tissues.py | 50 +++++++++++++++++++ src/tissues/migrations/__init__.py | 0 src/tissues/models.py | 16 ++++++ src/tissues/serializers.py | 8 +++ src/tissues/urls.py | 6 +++ src/tissues/views.py | 12 +++++ src/user_files/admin.py | 4 +- .../migrations/0016_userfile_tissue.py | 25 ++++++++++ src/user_files/models.py | 2 + src/user_files/serializers.py | 10 ++-- src/user_files/views.py | 2 +- 22 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 src/datasets_synchronization/migrations/0037_cgdsstudy_tissue.py create mode 100644 src/tissues/__init__.py create mode 100644 src/tissues/admin.py create mode 100644 src/tissues/apps.py create mode 100644 src/tissues/migrations/0001_initial.py create mode 100644 src/tissues/migrations/0002_load_initial_tissues.py create mode 100644 src/tissues/migrations/__init__.py create mode 100644 src/tissues/models.py create mode 100644 src/tissues/serializers.py create mode 100644 src/tissues/urls.py create mode 100644 src/tissues/views.py create mode 100644 src/user_files/migrations/0016_userfile_tissue.py diff --git a/src/datasets_synchronization/migrations/0037_cgdsstudy_tissue.py b/src/datasets_synchronization/migrations/0037_cgdsstudy_tissue.py new file mode 100644 index 00000000..2d916279 --- /dev/null +++ b/src/datasets_synchronization/migrations/0037_cgdsstudy_tissue.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.19 on 2026-02-23 00:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("tissues", "0001_initial"), + ( + "datasets_synchronization", + "0036_alter_cgdsstudy_clinical_patient_dataset_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="cgdsstudy", + name="tissue", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="tissues.tissue", + ), + ), + ] diff --git a/src/datasets_synchronization/models.py b/src/datasets_synchronization/models.py index 2ed80a33..45166fd6 100644 --- a/src/datasets_synchronization/models.py +++ b/src/datasets_synchronization/models.py @@ -10,6 +10,7 @@ from common.methylation import MethylationPlatform from feature_selection.models import TrainedModel from statistical_properties.models import StatisticalValidation +from tissues.models import Tissue from user_files.models import UserFile from user_files.models_choices import FileType from pandas import DataFrame @@ -288,6 +289,7 @@ class CGDSStudy(models.Model): null=True, related_name='cgds_studies_as_clinical_sample_dataset' ) + tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) task_id: Optional[str] = models.CharField(max_length=100, blank=True, null=True) # Celery Task ID def __str__(self) -> str: @@ -327,4 +329,4 @@ def delete(self, *args, **kwargs) -> None: dataset.delete() # Sends a websocket message to update the state in the frontend - send_update_cgds_studies_command() + send_update_cgds_studies_command() \ No newline at end of file diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index 337e716f..3a5f2698 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -8,6 +8,8 @@ from common.response import ResponseStatus from .enums import CreateCGDSStudyResponseCode from .models import CGDSStudy, CGDSDataset, SurvivalColumnsTupleCGDSDataset +from tissues.models import Tissue +from tissues.serializers import TissueSerializer from django.db.models import Q @@ -54,6 +56,13 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') + # tissue_id: writable PK field; tissue: read-only nested (set in to_representation) + tissue_id = serializers.PrimaryKeyRelatedField( + queryset=Tissue.objects.all(), + source='tissue', + allow_null=True, + required=False + ) class Meta: model = CGDSStudy @@ -63,6 +72,12 @@ class Meta: def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() + def to_representation(self, instance: CGDSStudy): + """Makes a nested representation of the tissue field on GET requests.""" + data = super().to_representation(instance) + data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + return data + def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: """ Creates a CGDSDataset instance from a request data. @@ -293,6 +308,7 @@ def update(self, instance: CGDSStudy, validated_data): instance.description = validated_data.get('description', instance.description) instance.url = validated_data.get('url', instance.url) instance.url_study_info = validated_data.get('url_study_info', instance.url_study_info) + instance.tissue = validated_data.get('tissue', instance.tissue) # Updates datasets instance.mrna_dataset = mrna_dataset diff --git a/src/datasets_synchronization/views.py b/src/datasets_synchronization/views.py index e5ca5990..abacb9a2 100644 --- a/src/datasets_synchronization/views.py +++ b/src/datasets_synchronization/views.py @@ -13,6 +13,7 @@ from .enums import SyncCGDSStudyResponseCode, SyncStrategy from .models import CGDSStudy, CGDSDatasetSynchronizationState, CGDSStudySynchronizationState, CGDSDataset from rest_framework import generics, permissions, filters +from django_filters.rest_framework import DjangoFilterBackend from user_files.models_choices import FileType from .serializers import CGDSStudySerializer from django.shortcuts import render, get_object_or_404 @@ -82,7 +83,8 @@ def get_queryset(self): serializer_class = CGDSStudySerializer permission_classes = [permissions.IsAuthenticated] pagination_class = StandardResultsSetPagination - filter_backends = [filters.OrderingFilter, filters.SearchFilter] + filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] + filterset_fields = ['tissue'] search_fields = ['name', 'description'] ordering_fields = '__all__' diff --git a/src/frontend/templates/frontend/base.html b/src/frontend/templates/frontend/base.html index 6b4e0393..0f2a9378 100644 --- a/src/frontend/templates/frontend/base.html +++ b/src/frontend/templates/frontend/base.html @@ -48,6 +48,7 @@ const urlTagsCRUD = "{% url 'tags' %}" {# REST CRUD URL User Tags #} const urlUserFilesCRUD = "{% url 'user_files' %}" {# REST CRUD URL User Files #} const urlCGDSStudiesCRUD = "{% url 'cgds_studies' %}" {# URL CGDS Studies #} + const urlTissuesCRUD = "{% url 'tissues' %}" {# REST URL for Tissues list #} const urlCurrentUser = "{% url 'current_user' %}" {# URL to get the current logged user #} const urlSitePolicy = "{% url 'site_policy' %}" {# URL to Site policy page #} const urlCGDSPanel = "{% url 'cgds_panel' %}" {# URL to the CGDS Panel #} diff --git a/src/multiomics_intermediate/settings.py b/src/multiomics_intermediate/settings.py index 4a8519b7..0e50f21b 100644 --- a/src/multiomics_intermediate/settings.py +++ b/src/multiomics_intermediate/settings.py @@ -63,6 +63,7 @@ 'molecules_details', 'chunked_upload', 'users', + 'tissues', ] MIDDLEWARE = [ diff --git a/src/multiomics_intermediate/urls.py b/src/multiomics_intermediate/urls.py index 436b0a95..6c63602a 100644 --- a/src/multiomics_intermediate/urls.py +++ b/src/multiomics_intermediate/urls.py @@ -38,4 +38,5 @@ path('admin/', admin.site.urls), path('email/', include(mail_urls)), path('users/', include('users.urls')), + path('tissues/', include('tissues.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/src/tissues/__init__.py b/src/tissues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tissues/admin.py b/src/tissues/admin.py new file mode 100644 index 00000000..659d00ba --- /dev/null +++ b/src/tissues/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from .models import Tissue + + +@admin.register(Tissue) +class TissueAdmin(admin.ModelAdmin): + list_display = ('id', 'name') + search_fields = ('name',) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/src/tissues/apps.py b/src/tissues/apps.py new file mode 100644 index 00000000..33490ccd --- /dev/null +++ b/src/tissues/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TissuesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tissues" diff --git a/src/tissues/migrations/0001_initial.py b/src/tissues/migrations/0001_initial.py new file mode 100644 index 00000000..1b4eb8e2 --- /dev/null +++ b/src/tissues/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.19 on 2026-02-23 00:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Tissue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/src/tissues/migrations/0002_load_initial_tissues.py b/src/tissues/migrations/0002_load_initial_tissues.py new file mode 100644 index 00000000..f181bf15 --- /dev/null +++ b/src/tissues/migrations/0002_load_initial_tissues.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.19 on 2026-02-26 22:58 + +from django.db import migrations + +TISSUES = [ + 'Cerebro (GBM)', + 'Cerebro (Glioma)', + 'Colon', + 'Cérvix', + 'Estómago', + 'Esófago', + 'Glándula Adrenal', + 'Hígado', + 'Mama', + 'Ovario', + 'Piel', + 'Próstata', + 'Pulmón', + 'Pulmón (Escamoso)', + 'Páncreas', + 'Recto', + 'Riñón (KIRC)', + 'Riñón (KIRP)', + 'Sangre (Leucemia)', + 'Testículo', + 'Tiroides', + 'Vejiga', + 'Útero', +] + + +def load_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.bulk_create([Tissue(name=name) for name in TISSUES]) + + +def unload_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.filter(name__in=TISSUES).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('tissues', '0001_initial'), + ] + + operations = [ + migrations.RunPython(load_tissues, reverse_code=unload_tissues), + ] \ No newline at end of file diff --git a/src/tissues/migrations/__init__.py b/src/tissues/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tissues/models.py b/src/tissues/models.py new file mode 100644 index 00000000..9b769759 --- /dev/null +++ b/src/tissues/models.py @@ -0,0 +1,16 @@ +from django.core.exceptions import PermissionDenied +from django.db import models + + +class Tissue(models.Model): + """Reference model for tissue types. Tissues are read-only and cannot be deleted.""" + name = models.CharField(max_length=100, unique=True) + + class Meta: + ordering = ['name'] + + def __str__(self) -> str: + return self.name + + def delete(self, *args, **kwargs): + raise PermissionDenied("Tissues cannot be deleted") diff --git a/src/tissues/serializers.py b/src/tissues/serializers.py new file mode 100644 index 00000000..6a106382 --- /dev/null +++ b/src/tissues/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from .models import Tissue + + +class TissueSerializer(serializers.ModelSerializer): + class Meta: + model = Tissue + fields = ['id', 'name'] diff --git a/src/tissues/urls.py b/src/tissues/urls.py new file mode 100644 index 00000000..4d583cc6 --- /dev/null +++ b/src/tissues/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.TissueList.as_view(), name='tissues'), +] diff --git a/src/tissues/views.py b/src/tissues/views.py new file mode 100644 index 00000000..9dbf6e9b --- /dev/null +++ b/src/tissues/views.py @@ -0,0 +1,12 @@ +from rest_framework import generics, permissions, filters +from .models import Tissue +from .serializers import TissueSerializer + + +class TissueList(generics.ListAPIView): + """REST endpoint: read-only list for Tissue model.""" + queryset = Tissue.objects.all() + serializer_class = TissueSerializer + permission_classes = [permissions.IsAuthenticated] + filter_backends = [filters.SearchFilter] + search_fields = ['name'] \ No newline at end of file diff --git a/src/user_files/admin.py b/src/user_files/admin.py index d1730942..1547b0fe 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -4,8 +4,8 @@ class UserFileAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'file_type', 'upload_date', 'contains_nan_values', 'number_of_rows', - 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform') - list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id') + 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', 'tissue') + list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissue') search_fields = ('name', 'description', 'user__username') diff --git a/src/user_files/migrations/0016_userfile_tissue.py b/src/user_files/migrations/0016_userfile_tissue.py new file mode 100644 index 00000000..524fb036 --- /dev/null +++ b/src/user_files/migrations/0016_userfile_tissue.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.19 on 2026-02-23 00:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("tissues", "0001_initial"), + ("user_files", "0015_alter_userfile_options"), + ] + + operations = [ + migrations.AddField( + model_name="userfile", + name="tissue", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="tissues.tissue", + ), + ), + ] diff --git a/src/user_files/models.py b/src/user_files/models.py index 3ef3e714..0365fc9f 100644 --- a/src/user_files/models.py +++ b/src/user_files/models.py @@ -14,6 +14,7 @@ from common.methylation import MethylationPlatform from institutions.models import Institution from tags.models import Tag +from tissues.models import Tissue from user_files.models_choices import FileType, FileDecimalSeparator from user_files.utils import get_decimal_separator_and_numerical_data, read_excel_in_chunks @@ -32,6 +33,7 @@ class UserFile(models.Model): file_obj = models.FileField(upload_to=user_directory_path) file_type = models.IntegerField(choices=FileType.choices) tag = models.ForeignKey(Tag, on_delete=models.SET_NULL, blank=True, null=True) + tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) upload_date = models.DateTimeField(auto_now_add=True, blank=False, null=True) user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) institutions = models.ManyToManyField(Institution, blank=True) diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index b957cbe0..d2916287 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,6 +10,7 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer +from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -39,9 +40,9 @@ class UserFileSerializer(serializers.ModelSerializer): class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'upload_date', 'institutions', - 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', - 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissue', 'tissue_id', + 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', + 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] def validate_file_obj(self, value: InMemoryUploadedFile): """ @@ -65,6 +66,8 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data + data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( instance.survival_columns, @@ -122,6 +125,7 @@ def update(self, instance: UserFile, validated_data): instance.file_type = validated_data.get('file_type', instance.file_type) instance.description = validated_data.get('description', instance.description) instance.tag = validated_data.get('tag') + instance.tissue = validated_data.get('tissue') instance.institutions.set(validated_data.get('institutions', [])) instance.is_cpg_site_id = validated_data.get('is_cpg_site_id') instance.platform = validated_data.get('platform') diff --git a/src/user_files/views.py b/src/user_files/views.py index e165efa5..82098375 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -149,7 +149,7 @@ def get_queryset(self): serializer_class = UserFileWithoutFileObjSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tag', 'file_type', 'institutions'] + filterset_fields = ['tag', 'file_type', 'institutions', 'tissue'] search_fields = ['name', 'description'] ordering_fields = ['name', 'description', 'upload_date', 'tag', 'user', 'file_type'] pagination_class = StandardResultsSetPagination From d846c99a48cb712e1c5fe6c022e5375cbb475013 Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 5 Mar 2026 19:51:46 -0300 Subject: [PATCH 2/3] Make tissue a ManyToMany on files and studies Convert Tissue FK to ManyToMany on CGDSStudy and UserFile and update related code. Models updated to use tissues M2M; serializers changed (tissue_id -> tissue_ids, nested tissues in responses, create/update now set M2M relations); views' filterset_fields switched to 'tissues'; admin updated to filter and edit M2M with filter_horizontal and a user-facing tissue list column. Added migrations to replace FK with M2M for datasets_synchronization and user_files, and a new tissues migration that adds a unique code field and loads standardized initial tissue data (replacing the previous locale-specific seed). Run migrations after pulling these changes. --- src/datasets_synchronization/admin.py | 3 +- ...move_cgdsstudy_tissue_cgdsstudy_tissues.py | 23 ++++++++ src/datasets_synchronization/models.py | 2 +- src/datasets_synchronization/serializers.py | 22 ++++--- src/datasets_synchronization/views.py | 2 +- .../migrations/0002_load_initial_tissues.py | 50 ---------------- .../0002_tissue_code_and_initial_data.py | 57 +++++++++++++++++++ src/tissues/models.py | 1 + src/tissues/serializers.py | 2 +- src/user_files/admin.py | 10 +++- ...remove_userfile_tissue_userfile_tissues.py | 23 ++++++++ src/user_files/models.py | 2 +- src/user_files/serializers.py | 15 ++++- src/user_files/views.py | 2 +- 14 files changed, 145 insertions(+), 69 deletions(-) create mode 100644 src/datasets_synchronization/migrations/0038_remove_cgdsstudy_tissue_cgdsstudy_tissues.py delete mode 100644 src/tissues/migrations/0002_load_initial_tissues.py create mode 100644 src/tissues/migrations/0002_tissue_code_and_initial_data.py create mode 100644 src/user_files/migrations/0017_remove_userfile_tissue_userfile_tissues.py diff --git a/src/datasets_synchronization/admin.py b/src/datasets_synchronization/admin.py index 63341976..6463ff93 100644 --- a/src/datasets_synchronization/admin.py +++ b/src/datasets_synchronization/admin.py @@ -5,8 +5,9 @@ class CGDSStudyAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'version', 'date_last_synchronization', 'state') - list_filter = ('state',) + list_filter = ('state', 'tissues') search_fields = ('name', 'description') + filter_horizontal = ('tissues',) def delete_queryset(self, request, queryset): """ diff --git a/src/datasets_synchronization/migrations/0038_remove_cgdsstudy_tissue_cgdsstudy_tissues.py b/src/datasets_synchronization/migrations/0038_remove_cgdsstudy_tissue_cgdsstudy_tissues.py new file mode 100644 index 00000000..b004849a --- /dev/null +++ b/src/datasets_synchronization/migrations/0038_remove_cgdsstudy_tissue_cgdsstudy_tissues.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2026-03-05 22:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tissues", "0002_tissue_code_and_initial_data"), + ("datasets_synchronization", "0037_cgdsstudy_tissue"), + ] + + operations = [ + migrations.RemoveField( + model_name="cgdsstudy", + name="tissue", + ), + migrations.AddField( + model_name="cgdsstudy", + name="tissues", + field=models.ManyToManyField(blank=True, to="tissues.tissue"), + ), + ] diff --git a/src/datasets_synchronization/models.py b/src/datasets_synchronization/models.py index 45166fd6..43a7b472 100644 --- a/src/datasets_synchronization/models.py +++ b/src/datasets_synchronization/models.py @@ -289,7 +289,7 @@ class CGDSStudy(models.Model): null=True, related_name='cgds_studies_as_clinical_sample_dataset' ) - tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) + tissues = models.ManyToManyField(Tissue, blank=True) task_id: Optional[str] = models.CharField(max_length=100, blank=True, null=True) # Celery Task ID def __str__(self) -> str: diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index 3a5f2698..bdc73ccc 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -56,11 +56,11 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') - # tissue_id: writable PK field; tissue: read-only nested (set in to_representation) - tissue_id = serializers.PrimaryKeyRelatedField( + # tissue_ids: writable list of PKs; tissues: read-only nested list (set in to_representation) + tissue_ids = serializers.PrimaryKeyRelatedField( queryset=Tissue.objects.all(), - source='tissue', - allow_null=True, + source='tissues', + many=True, required=False ) @@ -73,9 +73,9 @@ def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() def to_representation(self, instance: CGDSStudy): - """Makes a nested representation of the tissue field on GET requests.""" + """Makes a nested representation of the tissues field on GET requests.""" data = super().to_representation(instance) - data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data return data def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: @@ -239,9 +239,10 @@ def create(self, validated_data) -> Optional[CGDSStudy]: methylation_dataset = self.__create_cgds_dataset(validated_data.pop('methylation_dataset')) clinical_patient_dataset = self.__create_cgds_dataset(validated_data.pop('clinical_patient_dataset')) clinical_sample_dataset = self.__create_cgds_dataset(validated_data.pop('clinical_sample_dataset')) + tissues = validated_data.pop('tissues', []) # Creates the CGDSStudy - return CGDSStudy.objects.create( + cgds_study = CGDSStudy.objects.create( mrna_dataset=mrna_dataset, mirna_dataset=mirna_dataset, cna_dataset=cna_dataset, @@ -250,6 +251,8 @@ def create(self, validated_data) -> Optional[CGDSStudy]: clinical_sample_dataset=clinical_sample_dataset, **validated_data ) + cgds_study.tissues.set(tissues) + return cgds_study @staticmethod def __set_clinical_datasets_to_existing_experiments(cgds_study: CGDSStudy): @@ -308,7 +311,6 @@ def update(self, instance: CGDSStudy, validated_data): instance.description = validated_data.get('description', instance.description) instance.url = validated_data.get('url', instance.url) instance.url_study_info = validated_data.get('url_study_info', instance.url_study_info) - instance.tissue = validated_data.get('tissue', instance.tissue) # Updates datasets instance.mrna_dataset = mrna_dataset @@ -318,6 +320,10 @@ def update(self, instance: CGDSStudy, validated_data): instance.clinical_patient_dataset = clinical_patient_dataset instance.clinical_sample_dataset = clinical_sample_dataset + # Updates M2M tissues if provided + if 'tissues' in validated_data: + instance.tissues.set(validated_data['tissues']) + # Saves new changes and returns instance instance.save() return instance diff --git a/src/datasets_synchronization/views.py b/src/datasets_synchronization/views.py index abacb9a2..3e7097f7 100644 --- a/src/datasets_synchronization/views.py +++ b/src/datasets_synchronization/views.py @@ -84,7 +84,7 @@ def get_queryset(self): permission_classes = [permissions.IsAuthenticated] pagination_class = StandardResultsSetPagination filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tissue'] + filterset_fields = ['tissues'] search_fields = ['name', 'description'] ordering_fields = '__all__' diff --git a/src/tissues/migrations/0002_load_initial_tissues.py b/src/tissues/migrations/0002_load_initial_tissues.py deleted file mode 100644 index f181bf15..00000000 --- a/src/tissues/migrations/0002_load_initial_tissues.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 4.2.19 on 2026-02-26 22:58 - -from django.db import migrations - -TISSUES = [ - 'Cerebro (GBM)', - 'Cerebro (Glioma)', - 'Colon', - 'Cérvix', - 'Estómago', - 'Esófago', - 'Glándula Adrenal', - 'Hígado', - 'Mama', - 'Ovario', - 'Piel', - 'Próstata', - 'Pulmón', - 'Pulmón (Escamoso)', - 'Páncreas', - 'Recto', - 'Riñón (KIRC)', - 'Riñón (KIRP)', - 'Sangre (Leucemia)', - 'Testículo', - 'Tiroides', - 'Vejiga', - 'Útero', -] - - -def load_tissues(apps, schema_editor): - Tissue = apps.get_model('tissues', 'Tissue') - Tissue.objects.bulk_create([Tissue(name=name) for name in TISSUES]) - - -def unload_tissues(apps, schema_editor): - Tissue = apps.get_model('tissues', 'Tissue') - Tissue.objects.filter(name__in=TISSUES).delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tissues', '0001_initial'), - ] - - operations = [ - migrations.RunPython(load_tissues, reverse_code=unload_tissues), - ] \ No newline at end of file diff --git a/src/tissues/migrations/0002_tissue_code_and_initial_data.py b/src/tissues/migrations/0002_tissue_code_and_initial_data.py new file mode 100644 index 00000000..a432d2a5 --- /dev/null +++ b/src/tissues/migrations/0002_tissue_code_and_initial_data.py @@ -0,0 +1,57 @@ +from django.db import migrations, models + +TISSUES = [ + ('Adrenal Gland', 'ADRENAL_GLAND'), + ('Bladder', 'BLADDER'), + ('Blood', 'BLOOD'), + ('Brain', 'BRAIN'), + ('Breast', 'BREAST'), + ('Cervix Uteri', 'CERVIX_UTERI'), + ('Colon', 'COLON'), + ('Esophagus', 'ESOPHAGUS'), + ('Kidney', 'KIDNEY'), + ('Liver', 'LIVER'), + ('Lung', 'LUNG'), + ('Ovary', 'OVARY'), + ('Pancreas', 'PANCREAS'), + ('Prostate', 'PROSTATE'), + ('Skin', 'SKIN'), + ('Stomach', 'STOMACH'), + ('Testis', 'TESTIS'), + ('Thyroid', 'THYROID'), + ('Uterus', 'UTERUS'), +] + + +def load_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.bulk_create([Tissue(name=name, code=code) for name, code in TISSUES]) + + +def unload_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.filter(name__in=[name for name, _ in TISSUES]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('tissues', '0001_initial'), + ] + + operations = [ + # Add code field as nullable first (so existing rows don't violate constraints) + migrations.AddField( + model_name='tissue', + name='code', + field=models.CharField(max_length=100, null=True, blank=True), + ), + # Insert the 19 English tissues (table is empty at this point in migration history) + migrations.RunPython(load_tissues, reverse_code=unload_tissues), + # Make code non-nullable and unique + migrations.AlterField( + model_name='tissue', + name='code', + field=models.CharField(max_length=100, unique=True), + ), + ] diff --git a/src/tissues/models.py b/src/tissues/models.py index 9b769759..d8fe7f7a 100644 --- a/src/tissues/models.py +++ b/src/tissues/models.py @@ -5,6 +5,7 @@ class Tissue(models.Model): """Reference model for tissue types. Tissues are read-only and cannot be deleted.""" name = models.CharField(max_length=100, unique=True) + code = models.CharField(max_length=100, unique=True) # noqa: populated by migration 0003 class Meta: ordering = ['name'] diff --git a/src/tissues/serializers.py b/src/tissues/serializers.py index 6a106382..9329da93 100644 --- a/src/tissues/serializers.py +++ b/src/tissues/serializers.py @@ -5,4 +5,4 @@ class TissueSerializer(serializers.ModelSerializer): class Meta: model = Tissue - fields = ['id', 'name'] + fields = ['id', 'name', 'code'] diff --git a/src/user_files/admin.py b/src/user_files/admin.py index 1547b0fe..40c90524 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -4,9 +4,15 @@ class UserFileAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'file_type', 'upload_date', 'contains_nan_values', 'number_of_rows', - 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', 'tissue') - list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissue') + 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', + 'tissue_list') + list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissues') search_fields = ('name', 'description', 'user__username') + filter_horizontal = ('tissues',) + + def tissue_list(self, obj): + return ', '.join(t.name for t in obj.tissues.all()) + tissue_list.short_description = 'Tissues' admin.site.register(UserFile, UserFileAdmin) diff --git a/src/user_files/migrations/0017_remove_userfile_tissue_userfile_tissues.py b/src/user_files/migrations/0017_remove_userfile_tissue_userfile_tissues.py new file mode 100644 index 00000000..d18ae51e --- /dev/null +++ b/src/user_files/migrations/0017_remove_userfile_tissue_userfile_tissues.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.19 on 2026-03-05 22:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tissues", "0002_tissue_code_and_initial_data"), + ("user_files", "0016_userfile_tissue"), + ] + + operations = [ + migrations.RemoveField( + model_name="userfile", + name="tissue", + ), + migrations.AddField( + model_name="userfile", + name="tissues", + field=models.ManyToManyField(blank=True, to="tissues.tissue"), + ), + ] diff --git a/src/user_files/models.py b/src/user_files/models.py index 0365fc9f..35f9ee75 100644 --- a/src/user_files/models.py +++ b/src/user_files/models.py @@ -33,7 +33,7 @@ class UserFile(models.Model): file_obj = models.FileField(upload_to=user_directory_path) file_type = models.IntegerField(choices=FileType.choices) tag = models.ForeignKey(Tag, on_delete=models.SET_NULL, blank=True, null=True) - tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) + tissues = models.ManyToManyField(Tissue, blank=True) upload_date = models.DateTimeField(auto_now_add=True, blank=False, null=True) user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) institutions = models.ManyToManyField(Institution, blank=True) diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index d2916287..8d086f1e 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,6 +10,7 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer +from tissues.models import Tissue from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -37,10 +38,16 @@ class Meta: class UserFileSerializer(serializers.ModelSerializer): user = UserSimpleSerializer(many=False, read_only=True) survival_columns = SurvivalColumnsTupleUserFileSimpleSerializer(many=True, read_only=True) + tissue_ids = serializers.PrimaryKeyRelatedField( + queryset=Tissue.objects.all(), + source='tissues', + many=True, + required=False + ) class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissue', 'tissue_id', + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'tissue_ids', 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] @@ -66,7 +73,7 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data - data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( @@ -98,10 +105,12 @@ def create(self, validated_data): """ with transaction.atomic(): institutions_ids = validated_data.pop('institutions', []) + tissues = validated_data.pop('tissues', []) # User file and institutions user_file = UserFile.objects.create(user=self.context['request'].user, **validated_data) user_file.institutions.set(institutions_ids) + user_file.tissues.set(tissues) user_file.save() # Survival columns @@ -125,7 +134,7 @@ def update(self, instance: UserFile, validated_data): instance.file_type = validated_data.get('file_type', instance.file_type) instance.description = validated_data.get('description', instance.description) instance.tag = validated_data.get('tag') - instance.tissue = validated_data.get('tissue') + instance.tissues.set(validated_data.get('tissues', [])) instance.institutions.set(validated_data.get('institutions', [])) instance.is_cpg_site_id = validated_data.get('is_cpg_site_id') instance.platform = validated_data.get('platform') diff --git a/src/user_files/views.py b/src/user_files/views.py index 82098375..f2af2d22 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -149,7 +149,7 @@ def get_queryset(self): serializer_class = UserFileWithoutFileObjSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tag', 'file_type', 'institutions', 'tissue'] + filterset_fields = ['tag', 'file_type', 'institutions', 'tissues'] search_fields = ['name', 'description'] ordering_fields = ['name', 'description', 'upload_date', 'tag', 'user', 'file_type'] pagination_class = StandardResultsSetPagination From 105d48002619df0e87b5fa8626732dd338e4e35a Mon Sep 17 00:00:00 2001 From: Hernan Date: Fri, 20 Mar 2026 11:36:07 -0300 Subject: [PATCH 3/3] Refactor tissue representation in serializers and admin; remove unused fields --- src/datasets_synchronization/serializers.py | 15 --------------- src/user_files/admin.py | 2 +- src/user_files/serializers.py | 12 +----------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index bdc73ccc..3f78d88f 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -8,8 +8,6 @@ from common.response import ResponseStatus from .enums import CreateCGDSStudyResponseCode from .models import CGDSStudy, CGDSDataset, SurvivalColumnsTupleCGDSDataset -from tissues.models import Tissue -from tissues.serializers import TissueSerializer from django.db.models import Q @@ -56,13 +54,6 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') - # tissue_ids: writable list of PKs; tissues: read-only nested list (set in to_representation) - tissue_ids = serializers.PrimaryKeyRelatedField( - queryset=Tissue.objects.all(), - source='tissues', - many=True, - required=False - ) class Meta: model = CGDSStudy @@ -72,12 +63,6 @@ class Meta: def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() - def to_representation(self, instance: CGDSStudy): - """Makes a nested representation of the tissues field on GET requests.""" - data = super().to_representation(instance) - data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data - return data - def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: """ Creates a CGDSDataset instance from a request data. diff --git a/src/user_files/admin.py b/src/user_files/admin.py index 40c90524..2e05957d 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -11,7 +11,7 @@ class UserFileAdmin(admin.ModelAdmin): filter_horizontal = ('tissues',) def tissue_list(self, obj): - return ', '.join(t.name for t in obj.tissues.all()) + return ', '.join(obj.tissues.values_list('name', flat=True)) tissue_list.short_description = 'Tissues' diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index 8d086f1e..6e175ba4 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,8 +10,6 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer -from tissues.models import Tissue -from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -38,16 +36,10 @@ class Meta: class UserFileSerializer(serializers.ModelSerializer): user = UserSimpleSerializer(many=False, read_only=True) survival_columns = SurvivalColumnsTupleUserFileSimpleSerializer(many=True, read_only=True) - tissue_ids = serializers.PrimaryKeyRelatedField( - queryset=Tissue.objects.all(), - source='tissues', - many=True, - required=False - ) class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'tissue_ids', + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] @@ -73,8 +65,6 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data - data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data - data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( instance.survival_columns,