diff --git a/.gitignore b/.gitignore index d38a0ab..c10a670 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,6 @@ GitHub.sublime-settings !.vscode/launch.json !.vscode/extensions.json .history + +# Database +prod_db.sql diff --git a/brain/admin.py b/brain/admin.py new file mode 100644 index 0000000..fbde6b0 --- /dev/null +++ b/brain/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import BrainUserProfile + +@admin.register(BrainUserProfile) +class BrainUserProfileAdmin(admin.ModelAdmin): + list_display = ('user', 'role') + search_fields = ('user__username', 'user__email') + list_filter = ('role',) diff --git a/brain/migrations/0001_initial.py b/brain/migrations/0001_initial.py new file mode 100644 index 0000000..7e4aa88 --- /dev/null +++ b/brain/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-12-01 10:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BrainUserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('standard', 'Standard User'), ('annotator', 'Annotator'), ('project_manager', 'Project Manager'), ('admin', 'Administrator'), ('student_dev', 'Student Developer')], default='standard', max_length=20)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='brain_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/brain/migrations/0002_alter_brainuserprofile_options.py b/brain/migrations/0002_alter_brainuserprofile_options.py new file mode 100644 index 0000000..070451c --- /dev/null +++ b/brain/migrations/0002_alter_brainuserprofile_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-12-02 13:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('brain', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='brainuserprofile', + options={'verbose_name': 'User profile', 'verbose_name_plural': 'User profiles'}, + ), + ] diff --git a/brain/migrations/__init__.py b/brain/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/brain/models.py b/brain/models.py new file mode 100644 index 0000000..7c43f63 --- /dev/null +++ b/brain/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.contrib.auth.models import User + +class BrainUserProfile(models.Model): + ROLE_CHOICES = [ + ('standard', 'Standard User'), + ('annotator', 'Annotator'), + ('project_manager', 'Project Manager'), + ('admin', 'Administrator'), + ('student_dev', 'Student Developer'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='brain_profile') + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='standard') + + class Meta: + verbose_name = "User profile" + verbose_name_plural = "User profiles" + + def __str__(self): + return f"{self.user.username} - {self.get_role_display()}" + + def is_annotator(self): + return self.role in ['annotator', 'project_manager', 'admin'] + + def is_project_manager(self): + return self.role == 'project_manager' + + def is_admin(self): + return self.role == 'admin' + + def is_student_developer(self): + return self.role == 'student_dev' + + def can_upload_scans(self): + """Check if user can upload scans""" + return self.role in ['annotator', 'project_manager', 'admin', 'student_dev'] + + def can_see_debug_scans(self): + """Check if user can see debug scans""" + return self.role in ['admin', 'student_dev'] + + def can_see_public_private_scans(self): + """Check if user can see public/private scans""" + return self.role in ['annotator', 'project_manager', 'admin', 'standard'] + + def can_modify_scan_settings(self): + """Check if user can modify scan settings (visibility, dataset, etc.)""" + return self.role in ['annotator', 'project_manager', 'admin'] + + def can_delete_scans(self): + """Check if user can delete scans""" + return self.role in ['admin'] # Only admins can delete non-debug scans + + def can_delete_debug_scans(self): + """Check if user can delete debug scans""" + return self.role in ['admin', 'student_dev'] + + def can_view_other_profiles(self): + """Check if user can view other users' profiles""" + return self.role in ['project_manager', 'admin'] + + + def __str__(self): + return f"Brain profile of {self.user.username}" diff --git a/common/context_processors.py b/common/context_processors.py new file mode 100644 index 0000000..5f2c926 --- /dev/null +++ b/common/context_processors.py @@ -0,0 +1,77 @@ +from common.models import Project, ProjectAccess + + +def current_project(request): + project = None + icon = '' + name = '' + description = '' + all_projects = [] + pid = request.session.get('current_project_id') + if pid: + try: + project = Project.objects.get(id=pid, is_active=True) + name = getattr(project, 'name', '') or '' + icon = getattr(project, 'icon', '') or '' + description = getattr(project, 'description', '') or '' + except Project.DoesNotExist: + pass + + # Expose projects based on user access for navbar switching + user = getattr(request, 'user', None) + try: + if user and user.is_authenticated: + # Admins and student developers can see all projects + # Use getattr checks to avoid attribute errors if profile is missing + if user.is_staff or getattr(getattr(user, 'profile', None), 'is_admin', False) or getattr(getattr(user, 'profile', None), 'is_student_developer', False): + all_projects = Project.objects.filter(is_active=True).order_by('name') + else: + # Regular users only see projects they have access to + accessible_project_ids = ProjectAccess.objects.filter( + user=user, + can_view=True + ).values_list('project_id', flat=True) + all_projects = Project.objects.filter( + is_active=True, + id__in=accessible_project_ids + ).order_by('name') + except Exception: + # Avoid breaking templates if profile or db access fails in edge cases + all_projects = [] + + # Determine project-specific role display for the current user + current_project_slug = '' + current_project_role_display = None + current_project_profile = None + if project and user and user.is_authenticated: + current_project_slug = getattr(project, 'slug', '') or '' + # Try convention: _profile, then fallback to 'profile' + try: + if current_project_slug: + attr_name = f"{current_project_slug}_profile" + if hasattr(user, attr_name): + current_project_profile = getattr(user, attr_name) + current_project_role_display = getattr(current_project_profile, 'get_role_display', None) + if callable(current_project_role_display): + current_project_role_display = current_project_role_display() + # fallback + if not current_project_role_display and hasattr(user, 'profile'): + current_project_profile = user.profile + current_project_role_display = getattr(current_project_profile, 'get_role_display', None) + if callable(current_project_role_display): + current_project_role_display = current_project_role_display() + except Exception: + # Ignore profile lookup errors + current_project_role_display = None + + return { + 'current_project': project, + 'current_project_name': name, + 'current_project_icon': icon, + 'current_project_description': description, + 'current_project_id': pid, + 'all_projects': all_projects, + 'current_project_slug': current_project_slug, + 'current_project_role_display': current_project_role_display, + 'current_project_profile': current_project_profile, + } diff --git a/common/migrations/0011_alter_invitation_project.py b/common/migrations/0011_alter_invitation_project.py new file mode 100644 index 0000000..cd2d411 --- /dev/null +++ b/common/migrations/0011_alter_invitation_project.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-12-02 16:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0010_add_project_manager_role'), + ] + + operations = [ + migrations.AlterField( + model_name='invitation', + name='project', + field=models.ForeignKey(blank=True, help_text='Project the user will have access to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitations', to='common.project'), + ), + ] diff --git a/common/migrations/0012_alter_invitation_project.py b/common/migrations/0012_alter_invitation_project.py new file mode 100644 index 0000000..2eb4265 --- /dev/null +++ b/common/migrations/0012_alter_invitation_project.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2025-12-02 16:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0011_alter_invitation_project'), + ] + + operations = [ + migrations.AlterField( + model_name='invitation', + name='project', + field=models.ForeignKey( + on_delete=models.SET_NULL, + to='common.project', + null=True, + blank=True, + related_name='invitations', + help_text='Project the user will have access to', + ), + preserve_default=False, + ), + ] diff --git a/common/migrations/0013_alter_invitation_project.py b/common/migrations/0013_alter_invitation_project.py new file mode 100644 index 0000000..78b199b --- /dev/null +++ b/common/migrations/0013_alter_invitation_project.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.4 on 2025-12-02 16:11 + +import django.db.models.deletion +from django.db import migrations, models + + +def set_default_project(apps, schema_editor): + Invitation = apps.get_model('common', 'Invitation') + Project = apps.get_model('common', 'Project') + + default_project = Project.objects.get(slug='maxillo') + + # Assign default project to all invitations that have no project yet + Invitation.objects.filter(project__isnull=True).update(project=default_project) + +def unset_default_project(apps, schema_editor): + # Reverse function: in case of migration rollback + Invitation = apps.get_model('common', 'Invitation') + # You can either set project back to NULL: + Invitation.objects.filter().update(project=None) + # or leave it empty if you don't care about rolling back + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0012_alter_invitation_project'), + ] + + operations = [ + migrations.RunPython(set_default_project, unset_default_project), + ] diff --git a/common/migrations/0014_alter_invitation_project.py b/common/migrations/0014_alter_invitation_project.py new file mode 100644 index 0000000..c4d7c43 --- /dev/null +++ b/common/migrations/0014_alter_invitation_project.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-12-03 10:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0013_alter_invitation_project'), + ] + + operations = [ + migrations.AlterField( + model_name='invitation', + name='project', + field=models.ForeignKey(help_text='Project the user will have access to', on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='common.project'), + ), + ] diff --git a/common/models.py b/common/models.py index 30c9407..468365e 100644 --- a/common/models.py +++ b/common/models.py @@ -83,7 +83,8 @@ class Invitation(models.Model): code = models.CharField(max_length=64, unique=True) email = models.EmailField(blank=True, null=True) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='standard') - project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='invitations', help_text='Optional: Project the user will have access to') + #project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='invitations', help_text='Project the user will have access to') + project = models.ForeignKey(Project, on_delete=models.CASCADE, null=False, blank=False, related_name='invitations', help_text='Project the user will have access to') created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField() diff --git a/entrypoint.sh b/entrypoint.sh index a4959c4..f69b2de 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -38,5 +38,7 @@ done mkdir -p /app/logs +python manage.py makemigrations python manage.py migrate +# python manage.py collectstatic --noinput python manage.py runserver 0.0.0.0:8000 diff --git a/maxillo/admin.py b/maxillo/admin.py index 95ff002..0f9533c 100644 --- a/maxillo/admin.py +++ b/maxillo/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.auth.models import User -from .models import UserProfile, Dataset, Patient, Classification, VoiceCaption +from .models import MaxilloUserProfile, Dataset, Patient, Classification, VoiceCaption from common.models import Project, Modality, ProjectAccess, Job, FileRegistry, Invitation from .models import Tag, Folder from common.models import Project, Modality, ProjectAccess @@ -25,8 +25,8 @@ def has_delete_permission(self, request, obj=None): return super().has_delete_permission(request, obj) -@admin.register(UserProfile) -class UserProfileAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): +@admin.register(MaxilloUserProfile) +class MaxilloUserProfileAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): list_display = ['user', 'role'] list_filter = ['role'] search_fields = ['user__username', 'user__email'] diff --git a/maxillo/apps.py b/maxillo/apps.py index 79abf89..2540fc2 100644 --- a/maxillo/apps.py +++ b/maxillo/apps.py @@ -5,6 +5,7 @@ class MaxilloConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "maxillo" label = "scans" + verbose_name = "Maxillo" def ready(self): import maxillo.signals diff --git a/maxillo/context_processors.py b/maxillo/context_processors.py deleted file mode 100644 index 89f69b1..0000000 --- a/maxillo/context_processors.py +++ /dev/null @@ -1,48 +0,0 @@ -from .models import Project, ProjectAccess - - -def current_project(request): - project = None - icon = '' - name = '' - description = '' - all_projects = [] - pid = request.session.get('current_project_id') - if pid: - try: - project = Project.objects.get(id=pid, is_active=True) - name = getattr(project, 'name', '') or '' - icon = getattr(project, 'icon', '') or '' - description = getattr(project, 'description', '') or '' - except Project.DoesNotExist: - pass - # Expose projects based on user access for navbar switching - user = getattr(request, 'user', None) - try: - if user and user.is_authenticated: - # Admins and student developers can see all projects - if user.is_staff or getattr(user.profile, 'is_admin', False) or getattr(user.profile, 'is_student_developer', False): - all_projects = Project.objects.filter(is_active=True).order_by('name') - else: - # Regular users only see projects they have access to - accessible_project_ids = ProjectAccess.objects.filter( - user=user, - can_view=True - ).values_list('project_id', flat=True) - all_projects = Project.objects.filter( - is_active=True, - id__in=accessible_project_ids - ).order_by('name') - except Exception: - # Avoid breaking templates if profile or db access fails in edge cases - all_projects = [] - return { - 'current_project': project, - 'current_project_name': name, - 'current_project_icon': icon, - 'current_project_description': description, - 'current_project_id': pid, - 'all_projects': all_projects, - } - - diff --git a/maxillo/migrations/0004_maxillouserprofile_delete_userprofile.py b/maxillo/migrations/0004_maxillouserprofile_delete_userprofile.py new file mode 100644 index 0000000..eab2ecb --- /dev/null +++ b/maxillo/migrations/0004_maxillouserprofile_delete_userprofile.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2025-12-02 13:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scans', '0003_add_project_manager_role'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MaxilloUserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('standard', 'Standard User'), ('annotator', 'Annotator'), ('project_manager', 'Project Manager'), ('admin', 'Administrator'), ('student_dev', 'Student Developer')], default='standard', max_length=20)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='maxillo_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.DeleteModel( + name='UserProfile', + ), + ] diff --git a/maxillo/migrations/0005_alter_maxillouserprofile_options.py b/maxillo/migrations/0005_alter_maxillouserprofile_options.py new file mode 100644 index 0000000..8acfc8b --- /dev/null +++ b/maxillo/migrations/0005_alter_maxillouserprofile_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.4 on 2025-12-02 14:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('scans', '0004_maxillouserprofile_delete_userprofile'), + ] + + operations = [ + migrations.AlterModelOptions( + name='maxillouserprofile', + options={'verbose_name': 'User profile', 'verbose_name_plural': 'User profiles'}, + ), + ] diff --git a/maxillo/models.py b/maxillo/models.py index 59991a6..3edfff0 100644 --- a/maxillo/models.py +++ b/maxillo/models.py @@ -139,7 +139,7 @@ def validate_cbct_folder(files): return valid_files -class UserProfile(models.Model): +class MaxilloUserProfile(models.Model): ROLE_CHOICES = [ ('standard', 'Standard User'), ('annotator', 'Annotator'), @@ -148,8 +148,12 @@ class UserProfile(models.Model): ('student_dev', 'Student Developer'), ] - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='maxillo_profile') role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='standard') + + class Meta: + verbose_name = "User profile" + verbose_name_plural = "User profiles" def __str__(self): return f"{self.user.username} - {self.get_role_display()}" diff --git a/maxillo/signals.py b/maxillo/signals.py index 9796c84..e67eb57 100644 --- a/maxillo/signals.py +++ b/maxillo/signals.py @@ -1,18 +1,18 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.contrib.auth.models import User -from .models import UserProfile +from .models import MaxilloUserProfile @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: - UserProfile.objects.create(user=instance) + MaxilloUserProfile.objects.create(user=instance) -@receiver(post_save, sender=UserProfile) +@receiver(post_save, sender=MaxilloUserProfile) def update_user_staff_status(sender, instance, created, **kwargs): - """Update User's is_staff flag based on UserProfile role""" + """Update User's is_staff flag based on MaxilloUserProfile role""" user = instance.user # Student developers and admins should have staff access @@ -30,7 +30,7 @@ def update_user_staff_status(sender, instance, created, **kwargs): # Get all view permissions for our models content_types = ContentType.objects.filter( app_label='scans', - model__in=['userprofile', 'dataset', 'patient', 'classification', + model__in=['MaxilloUserProfile', 'dataset', 'patient', 'classification', 'voicecaption', 'processingjob', 'fileregistry', 'invitation'] ) diff --git a/maxillo/views/auth.py b/maxillo/views/auth.py index ca31f5b..5c667dc 100644 --- a/maxillo/views/auth.py +++ b/maxillo/views/auth.py @@ -9,6 +9,8 @@ from ..models import Invitation from ..forms import InvitationForm, InvitedUserCreationForm from common.models import ProjectAccess +from brain.models import BrainUserProfile +from maxillo.models import MaxilloUserProfile def register(request): @@ -17,8 +19,25 @@ def register(request): if form.is_valid(): invitation = Invitation.objects.get(code=form.cleaned_data['invitation_code']) user = form.save() - user.profile.role = invitation.role - user.profile.save() + project_name = invitation.project.name if invitation.project else None + if project_name: + pname = (project_name or '').lower() + if pname == 'maxillo': + profile, created = MaxilloUserProfile.objects.get_or_create( + user=user, + defaults={'role': invitation.role} + ) + if not created and profile.role != invitation.role: + profile.role = invitation.role + profile.save() + elif pname == 'brain': + profile, created = BrainUserProfile.objects.get_or_create( + user=user, + defaults={'role': invitation.role} + ) + if not created and profile.role != invitation.role: + profile.role = invitation.role + profile.save() # Create ProjectAccess entry if invitation has a project if invitation.project: @@ -49,7 +68,7 @@ def register(request): @login_required -@user_passes_test(lambda u: u.profile.is_admin) +@user_passes_test(lambda u: u.is_staff) def invitation_list(request): invitations = Invitation.objects.all().order_by('-created_at') if request.method == 'POST': diff --git a/maxillo/views/patient_list.py b/maxillo/views/patient_list.py index 3cd9dde..f384ef4 100644 --- a/maxillo/views/patient_list.py +++ b/maxillo/views/patient_list.py @@ -17,7 +17,7 @@ def home(request): all_projects = Project.objects.filter(is_active=True) # Admins can see all projects - if request.user.profile.is_admin(): + if request.user.is_staff: projects = all_projects.order_by('name') else: accessible_project_ids = ProjectAccess.objects.filter( diff --git a/templates/base.html b/templates/base.html index f66bbd4..11b6b4b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -120,7 +120,9 @@