From bff6dd5bb86f5c9b9f62c153e6e4afd5cfaf5b60 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Sun, 12 Apr 2026 02:18:08 +0000 Subject: [PATCH 1/2] Add teen delegated login feature - Add TeenEmailMapping model to map teen OAuth email to parent profiles - Add admin UI for configuring teen email mappings via Django admin - Modify JWT generation to return parent's tokens + teen's profile when teen logs in --- back/bots/admin.py | 10 +++++- back/bots/migrations/0034_teenemailmapping.py | 28 +++++++++++++++ back/bots/models/__init__.py | 4 ++- back/bots/models/teen_email_mapping.py | 26 ++++++++++++++ back/bots/views/get_jwt.py | 36 +++++++++++++------ 5 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 back/bots/migrations/0034_teenemailmapping.py create mode 100644 back/bots/models/teen_email_mapping.py diff --git a/back/bots/admin.py b/back/bots/admin.py index fe909e9..4840c77 100644 --- a/back/bots/admin.py +++ b/back/bots/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Chat, Message, Profile, Bot, UserAccount, UsageLimitHit, AiModel, Device, RevenueCatWebhookEvent +from .models import Chat, Message, Profile, Bot, UserAccount, UsageLimitHit, AiModel, Device, RevenueCatWebhookEvent, TeenEmailMapping from django.contrib.auth.models import User from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -65,6 +65,13 @@ class UserAccountAdmin(admin.ModelAdmin): def get_list_display(self, request): return ['user_id', 'pin', 'subscription_level', 'timezone'] + list(super().get_list_display(request)) +class TeenEmailMappingAdmin(admin.ModelAdmin): + def get_readonly_fields(self, request, obj=None): + return ['created_at', 'modified_at'] + + def get_list_display(self, request): + return ['oauth_email', 'teen_profile', 'parent_account', 'created_at', 'modified_at'] + class UserAdmin(BaseUserAdmin): list_display = ['username', 'email', 'first_name', 'last_name', 'date_joined'] ordering = ['-date_joined'] @@ -78,5 +85,6 @@ class UserAdmin(BaseUserAdmin): admin.site.register(AiModel, AiModelAdmin) admin.site.register(UsageLimitHit, UsageLimitHitAdmin) admin.site.register(RevenueCatWebhookEvent, RevenueCatWebhookEventAdmin) +admin.site.register(TeenEmailMapping, TeenEmailMappingAdmin) admin.site.unregister(User) admin.site.register(User, UserAdmin) diff --git a/back/bots/migrations/0034_teenemailmapping.py b/back/bots/migrations/0034_teenemailmapping.py new file mode 100644 index 0000000..43f8dba --- /dev/null +++ b/back/bots/migrations/0034_teenemailmapping.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-04-12 02:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0033_add_enable_web_search'), + ] + + operations = [ + migrations.CreateModel( + name='TeenEmailMapping', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('oauth_email', models.EmailField(max_length=254)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('parent_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teen_mappings', to='bots.useraccount')), + ('teen_profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='teen_email_mapping', to='bots.profile')), + ], + options={ + 'unique_together': {('oauth_email', 'parent_account')}, + }, + ), + ] diff --git a/back/bots/models/__init__.py b/back/bots/models/__init__.py index e5f1049..577a989 100644 --- a/back/bots/models/__init__.py +++ b/back/bots/models/__init__.py @@ -6,6 +6,7 @@ from .usage_limit_hit import UsageLimitHit from .ai_model import AiModel from .device import Device +from .teen_email_mapping import TeenEmailMapping __all__ = [ 'Chat', @@ -16,5 +17,6 @@ 'UsageLimitHit', 'AiModel', 'Device', - 'RevenueCatWebhookEvent' + 'RevenueCatWebhookEvent', + 'TeenEmailMapping' ] diff --git a/back/bots/models/teen_email_mapping.py b/back/bots/models/teen_email_mapping.py new file mode 100644 index 0000000..9e97a37 --- /dev/null +++ b/back/bots/models/teen_email_mapping.py @@ -0,0 +1,26 @@ +from django.conf import settings +from django.db import models +from .profile import Profile +from .user_account import UserAccount + + +class TeenEmailMapping(models.Model): + teen_profile = models.OneToOneField( + Profile, + on_delete=models.CASCADE, + related_name='teen_email_mapping' + ) + parent_account = models.ForeignKey( + UserAccount, + on_delete=models.CASCADE, + related_name='teen_mappings' + ) + oauth_email = models.EmailField(max_length=254) + created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('oauth_email', 'parent_account') + + def __str__(self): + return f"{self.oauth_email} -> {self.teen_profile.name}" \ No newline at end of file diff --git a/back/bots/views/get_jwt.py b/back/bots/views/get_jwt.py index 20a998a..894568a 100644 --- a/back/bots/views/get_jwt.py +++ b/back/bots/views/get_jwt.py @@ -5,14 +5,25 @@ from django.shortcuts import render import environ -# Initialize environ +from bots.models import TeenEmailMapping + env = environ.Env( - DEBUG=(bool, False) # Set default values and casting types + DEBUG=(bool, False) ) -# Read from .env file environ.Env.read_env('.env') +def get_delegated_tokens(user, teen_profile): + """Generate JWT tokens for the parent account (delegated login).""" + parent_user = teen_profile.user + refresh = RefreshToken.for_user(parent_user) + return { + 'access': str(refresh.access_token), + 'refresh': str(refresh), + 'active_profile_id': str(teen_profile.profile_id), + 'is_teen_delegated': True + } + @api_view(['GET']) @permission_classes([AllowAny]) def start_web_login(self): @@ -26,14 +37,19 @@ def get_jwt(request): user = request.user - refresh = RefreshToken.for_user(user) - - response_data = { - 'access': str(refresh.access_token), - 'refresh': str(refresh), - } + try: + mapping = TeenEmailMapping.objects.select_related('teen_profile', 'parent_account').get( + oauth_email=user.email + ) + response_data = get_delegated_tokens(user, mapping.teen_profile) + except TeenEmailMapping.DoesNotExist: + refresh = RefreshToken.for_user(user) + response_data = { + 'access': str(refresh.access_token), + 'refresh': str(refresh), + } if 'json' in request.query_params: return JsonResponse(response_data) - return render(request, 'jwt_template.html', {'app_deep_url': env('APP_DEEP_URL'), 'access': str(refresh.access_token), 'refresh': str(refresh)}) + return render(request, 'jwt_template.html', {'app_deep_url': env('APP_DEEP_URL'), 'access': response_data['access'], 'refresh': response_data['refresh']}) From 50999bf1c95db2556408ef400889bd6a383dbb61 Mon Sep 17 00:00:00 2001 From: Paul Shippy Date: Sun, 12 Apr 2026 02:40:17 +0000 Subject: [PATCH 2/2] Refactor: use oauth_email field on Profile instead of separate model - Add oauth_email field to Profile model - Simplify get_jwt to lookup Profile by oauth_email - Remove TeenEmailMapping model and admin - Update Profile admin to show oauth_email in list view --- back/bots/admin.py | 12 ++------ .../migrations/0034_profile_oauth_email.py | 18 ++++++++++++ back/bots/migrations/0034_teenemailmapping.py | 28 ------------------- back/bots/models/__init__.py | 4 +-- back/bots/models/profile.py | 1 + back/bots/models/teen_email_mapping.py | 26 ----------------- back/bots/views/get_jwt.py | 10 +++---- 7 files changed, 26 insertions(+), 73 deletions(-) create mode 100644 back/bots/migrations/0034_profile_oauth_email.py delete mode 100644 back/bots/migrations/0034_teenemailmapping.py delete mode 100644 back/bots/models/teen_email_mapping.py diff --git a/back/bots/admin.py b/back/bots/admin.py index 4840c77..b9cb6d9 100644 --- a/back/bots/admin.py +++ b/back/bots/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Chat, Message, Profile, Bot, UserAccount, UsageLimitHit, AiModel, Device, RevenueCatWebhookEvent, TeenEmailMapping +from .models import Chat, Message, Profile, Bot, UserAccount, UsageLimitHit, AiModel, Device, RevenueCatWebhookEvent from django.contrib.auth.models import User from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -37,7 +37,7 @@ def get_readonly_fields(self, request, obj=None): return ['created_at', 'modified_at', 'profile_id'] def get_list_display(self, request): - return ['profile_id', 'created_at', 'modified_at'] + list(super().get_list_display(request)) + return ['profile_id', 'name', 'user', 'oauth_email', 'created_at', 'modified_at'] + list(super().get_list_display(request)) class BotAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): @@ -65,13 +65,6 @@ class UserAccountAdmin(admin.ModelAdmin): def get_list_display(self, request): return ['user_id', 'pin', 'subscription_level', 'timezone'] + list(super().get_list_display(request)) -class TeenEmailMappingAdmin(admin.ModelAdmin): - def get_readonly_fields(self, request, obj=None): - return ['created_at', 'modified_at'] - - def get_list_display(self, request): - return ['oauth_email', 'teen_profile', 'parent_account', 'created_at', 'modified_at'] - class UserAdmin(BaseUserAdmin): list_display = ['username', 'email', 'first_name', 'last_name', 'date_joined'] ordering = ['-date_joined'] @@ -85,6 +78,5 @@ class UserAdmin(BaseUserAdmin): admin.site.register(AiModel, AiModelAdmin) admin.site.register(UsageLimitHit, UsageLimitHitAdmin) admin.site.register(RevenueCatWebhookEvent, RevenueCatWebhookEventAdmin) -admin.site.register(TeenEmailMapping, TeenEmailMappingAdmin) admin.site.unregister(User) admin.site.register(User, UserAdmin) diff --git a/back/bots/migrations/0034_profile_oauth_email.py b/back/bots/migrations/0034_profile_oauth_email.py new file mode 100644 index 0000000..2729dba --- /dev/null +++ b/back/bots/migrations/0034_profile_oauth_email.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-12 02:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0033_add_enable_web_search'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='oauth_email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + ] diff --git a/back/bots/migrations/0034_teenemailmapping.py b/back/bots/migrations/0034_teenemailmapping.py deleted file mode 100644 index 43f8dba..0000000 --- a/back/bots/migrations/0034_teenemailmapping.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 6.0.3 on 2026-04-12 02:17 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0033_add_enable_web_search'), - ] - - operations = [ - migrations.CreateModel( - name='TeenEmailMapping', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('oauth_email', models.EmailField(max_length=254)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('parent_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teen_mappings', to='bots.useraccount')), - ('teen_profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='teen_email_mapping', to='bots.profile')), - ], - options={ - 'unique_together': {('oauth_email', 'parent_account')}, - }, - ), - ] diff --git a/back/bots/models/__init__.py b/back/bots/models/__init__.py index 577a989..e5f1049 100644 --- a/back/bots/models/__init__.py +++ b/back/bots/models/__init__.py @@ -6,7 +6,6 @@ from .usage_limit_hit import UsageLimitHit from .ai_model import AiModel from .device import Device -from .teen_email_mapping import TeenEmailMapping __all__ = [ 'Chat', @@ -17,6 +16,5 @@ 'UsageLimitHit', 'AiModel', 'Device', - 'RevenueCatWebhookEvent', - 'TeenEmailMapping' + 'RevenueCatWebhookEvent' ] diff --git a/back/bots/models/profile.py b/back/bots/models/profile.py index 5541505..22709b3 100644 --- a/back/bots/models/profile.py +++ b/back/bots/models/profile.py @@ -10,6 +10,7 @@ class Profile(models.Model): ) profile_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) name = models.CharField(max_length=255) + oauth_email = models.EmailField(max_length=254, null=True, blank=True) deleted_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/back/bots/models/teen_email_mapping.py b/back/bots/models/teen_email_mapping.py deleted file mode 100644 index 9e97a37..0000000 --- a/back/bots/models/teen_email_mapping.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.conf import settings -from django.db import models -from .profile import Profile -from .user_account import UserAccount - - -class TeenEmailMapping(models.Model): - teen_profile = models.OneToOneField( - Profile, - on_delete=models.CASCADE, - related_name='teen_email_mapping' - ) - parent_account = models.ForeignKey( - UserAccount, - on_delete=models.CASCADE, - related_name='teen_mappings' - ) - oauth_email = models.EmailField(max_length=254) - created_at = models.DateTimeField(auto_now_add=True) - modified_at = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = ('oauth_email', 'parent_account') - - def __str__(self): - return f"{self.oauth_email} -> {self.teen_profile.name}" \ No newline at end of file diff --git a/back/bots/views/get_jwt.py b/back/bots/views/get_jwt.py index 894568a..0c56af6 100644 --- a/back/bots/views/get_jwt.py +++ b/back/bots/views/get_jwt.py @@ -5,7 +5,7 @@ from django.shortcuts import render import environ -from bots.models import TeenEmailMapping +from bots.models import Profile env = environ.Env( DEBUG=(bool, False) @@ -38,11 +38,9 @@ def get_jwt(request): user = request.user try: - mapping = TeenEmailMapping.objects.select_related('teen_profile', 'parent_account').get( - oauth_email=user.email - ) - response_data = get_delegated_tokens(user, mapping.teen_profile) - except TeenEmailMapping.DoesNotExist: + teen_profile = Profile.objects.get(oauth_email=user.email) + response_data = get_delegated_tokens(user, teen_profile) + except Profile.DoesNotExist: refresh = RefreshToken.for_user(user) response_data = { 'access': str(refresh.access_token),