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/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/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 2ed80a33..43a7b472 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' ) + 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: @@ -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..3f78d88f 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -224,9 +224,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, @@ -235,6 +236,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): @@ -302,6 +305,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 e5ca5990..3e7097f7 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 = ['tissues'] 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_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/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..d8fe7f7a --- /dev/null +++ b/src/tissues/models.py @@ -0,0 +1,17 @@ +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) + code = models.CharField(max_length=100, unique=True) # noqa: populated by migration 0003 + + 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..9329da93 --- /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', 'code'] 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..2e05957d 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') - 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') + 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(obj.tissues.values_list('name', flat=True)) + tissue_list.short_description = 'Tissues' admin.site.register(UserFile, UserFileAdmin) 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/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 3ef3e714..35f9ee75 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) + 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 b957cbe0..6e175ba4 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -39,9 +39,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', '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'] def validate_file_obj(self, value: InMemoryUploadedFile): """ @@ -95,10 +95,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 @@ -122,6 +124,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.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 e165efa5..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'] + filterset_fields = ['tag', 'file_type', 'institutions', 'tissues'] search_fields = ['name', 'description'] ordering_fields = ['name', 'description', 'upload_date', 'tag', 'user', 'file_type'] pagination_class = StandardResultsSetPagination