diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a14150918..1e54c6ab0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -153,8 +153,7 @@ jobs:
gh workflow run deploy.yml \
--ref develop \
-f version_type=latest \
- -f environment=dev \
- -f run_legacy_cluster_tests=true
+ -f environment=dev
echo "Triggered deploy workflow for dev environment with latest containers"
echo "View workflow runs: https://github.com/${{ github.repository }}/actions/workflows/deploy.yml"
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 743e3b818..40edc7c36 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -25,11 +25,6 @@ on:
options:
- dev
- prod
- run_legacy_cluster_tests:
- description: 'Run cluster tests for frontend-legacy before deploying'
- required: true
- type: boolean
- default: true
workflow_call:
inputs:
version_type:
@@ -46,11 +41,6 @@ on:
required: false
type: string
default: prod
- run_legacy_cluster_tests:
- description: "Run cluster tests for frontend-legacy before deploying"
- required: false
- type: boolean
- default: true
permissions:
contents: read
@@ -83,105 +73,9 @@ jobs:
repo: platform
specific-version: ${{ inputs.version_type != 'latest' && inputs.specific_version || '' }}
- test-release:
- name: Test Release
- needs: [determine-version]
- if: ${{ inputs.run_legacy_cluster_tests == true || inputs.run_legacy_cluster_tests == 'true' }}
- runs-on: ubuntu-latest
-
- env:
- FORCE_COLOR: 1
- PYTHONUNBUFFERED: 1
- PYTHONDONTWRITEBYTECODE: 1
- AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- PLATFORM_EMAIL_HOST_USER: ${{ secrets.PLATFORM_EMAIL_HOST_USER }}
- PLATFORM_EMAIL_HOST_PASSWORD: ${{ secrets.PLATFORM_EMAIL_HOST_PASSWORD }}
- PLATFORM_TWILIO_ACCOUNT_SID: ${{ secrets.PLATFORM_TWILIO_ACCOUNT_SID }}
- PLATFORM_TWILIO_AUTH_TOKEN: ${{ secrets.PLATFORM_TWILIO_AUTH_TOKEN }}
- PLATFORM_TWILIO_SMS_FROM: ${{ vars.PLATFORM_TWILIO_SMS_FROM }}
- AWS_INTEGRATION_S3_ACCESS_KEY_ID: ${{ secrets.AWS_INTEGRATION_S3_ACCESS_KEY_ID }}
- AWS_INTEGRATION_S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_INTEGRATION_S3_SECRET_ACCESS_KEY }}
- CI_TWILIO_RECIPIENT_PHONE_NUMBER: ${{ secrets.CI_TWILIO_RECIPIENT_PHONE_NUMBER }}
-
- steps:
- - name: Generate GitHub App token
- id: app-token
- uses: actions/create-github-app-token@v3
- with:
- client-id: ${{ secrets.HELIUM_BOT_APP_ID }}
- private-key: ${{ secrets.HELIUM_BOT_PRIVATE_KEY }}
- skip-token-revoke: true
-
- - name: Checkout infra monorepo
- uses: actions/checkout@v6
- with:
- repository: HeliumEdu/infra
- token: ${{ steps.app-token.outputs.token }}
- path: deploy
-
- - name: Set up Python "3.12"
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
-
- - name: Install GitHub SSH key
- uses: shimataro/ssh-key-action@v2
- with:
- key: ${{ secrets.SSH_KEY_GITHUB }}
- known_hosts: ${{ secrets.KNOWN_HOSTS_GITHUB }}
- if_key_exists: replace
-
- - name: Login to Docker Hub
- uses: docker/login-action@v4
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Checkout platform repository
- uses: actions/checkout@v6
- with:
- repository: HeliumEdu/platform
- ref: ${{ needs.determine-version.outputs.version }}
- path: platform-repo
-
- - name: Set container environment variables
- env:
- VERSION_NUMBER: ${{ needs.determine-version.outputs.version_number }}
- run: |
- echo "PLATFORM_RESOURCE_IMAGE=public.ecr.aws/heliumedu/helium/platform-resource:amd64-${VERSION_NUMBER}" >> $GITHUB_ENV
- echo "PLATFORM_API_IMAGE=public.ecr.aws/heliumedu/helium/platform-api:amd64-${VERSION_NUMBER}" >> $GITHUB_ENV
- echo "PLATFORM_WORKER_IMAGE=public.ecr.aws/heliumedu/helium/platform-worker:amd64-${VERSION_NUMBER}" >> $GITHUB_ENV
- : ${FRONTEND_LEGACY_VERSION:=latest}
- echo "FRONTEND_LEGACY_VERSION=${FRONTEND_LEGACY_VERSION}" >> $GITHUB_ENV
- echo "FRONTEND_IMAGE=public.ecr.aws/heliumedu/helium/frontend:legacy-amd64-${FRONTEND_LEGACY_VERSION}" >> $GITHUB_ENV
-
- - name: Install dependencies
- working-directory: deploy
- run: make install
-
- - name: Run cluster tests for frontend-legacy against release build
- working-directory: deploy
- env:
- TAG_VERSION: ${{ needs.determine-version.outputs.version }}
- run: make test-cluster-legacy
-
- - name: Upload test output
- if: ${{ always() }}
- uses: actions/upload-artifact@v7
- with:
- name: cluster-test-output-${{ needs.determine-version.outputs.version }}
- path: deploy/projects/cluster-tests/build/screenshots/
- retention-days: 30
-
- - name: Dump Docker logs on failure
- if: failure()
- uses: jwalton/gh-docker-logs@v2
-
deploy:
name: Deploy to ${{ inputs.environment }}
- needs: [determine-version, test-release]
- if: "!cancelled() && needs.determine-version.result == 'success' && (needs.test-release.result == 'success' || needs.test-release.result == 'skipped')"
+ needs: [determine-version]
runs-on: ubuntu-latest
env:
@@ -266,13 +160,6 @@ jobs:
pip install sentry-cli
SENTRY_PROPERTIES=sentry.properties sentry-cli releases deploys "$RELEASE_VERSION" new --env "${{ inputs.environment }}"
- - name: Trigger legacy cluster-tests run
- run: |
- curl -s -X POST https://api.github.com/repos/HeliumEdu/cluster-tests/dispatches \
- -H "Authorization: token ${{ steps.app-token.outputs.token }}" \
- -H "Accept: application/vnd.github+json" \
- -d "{\"event_type\":\"\`platform\` triggered for \`${{ needs.determine-version.outputs.version }}\` to \`${{ inputs.environment }}\`\",\"client_payload\":{\"environment\":\"${{ inputs.environment }}\",\"project\":\"platform\",\"version\":\"${{ needs.determine-version.outputs.version }}\"}}"
-
- name: Trigger frontend integration tests
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fff919b17..5c7f349eb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,11 +27,6 @@ on:
options:
- dev
- prod
- run_legacy_cluster_tests:
- description: 'Run cluster tests for frontend-legacy before deploying'
- required: true
- type: boolean
- default: true
workflow_call:
inputs:
bump_type:
@@ -49,11 +44,6 @@ on:
required: false
type: string
default: prod
- run_legacy_cluster_tests:
- description: "Run cluster tests for frontend-legacy before deploying"
- required: false
- type: boolean
- default: true
outputs:
version:
description: "Released version"
@@ -251,26 +241,22 @@ jobs:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ needs.prereq.outputs.version }}
ENVIRONMENT: ${{ inputs.environment }}
- RUN_LEGACY_CLUSTER_TESTS: ${{ inputs.run_legacy_cluster_tests }}
run: |
gh workflow run deploy.yml \
--repo ${{ github.repository }} \
-f version_type=specific \
-f specific_version="$VERSION" \
- -f environment="$ENVIRONMENT" \
- -f run_legacy_cluster_tests="$RUN_LEGACY_CLUSTER_TESTS"
+ -f environment="$ENVIRONMENT"
- name: Deployment trigger summary
env:
VERSION: ${{ needs.prereq.outputs.version }}
ENVIRONMENT: ${{ inputs.environment }}
- RUN_LEGACY_CLUSTER_TESTS: ${{ inputs.run_legacy_cluster_tests }}
run: |
echo "### Deploy Workflow Triggered" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Environment**: $ENVIRONMENT" >> $GITHUB_STEP_SUMMARY
- echo "- **Run Legacy Cluster Tests**: $RUN_LEGACY_CLUSTER_TESTS" >> $GITHUB_STEP_SUMMARY
cleanup:
needs: [prereq, deploy]
diff --git a/conf/configs/common.py b/conf/configs/common.py
index c08b17f22..2495da8ef 100644
--- a/conf/configs/common.py
+++ b/conf/configs/common.py
@@ -36,25 +36,16 @@
PROJECT_APP_HOST = config('PROJECT_FLUTTER_APP_HOST', 'http://localhost:8080' if 'local' in ENVIRONMENT else f'https://app.{ENVIRONMENT_PREFIX}heliumedu.com')
PROJECT_API_HOST = config('PROJECT_API_HOST', 'http://localhost:8000' if 'local' in ENVIRONMENT else f'https://api.{ENVIRONMENT_PREFIX}heliumedu.com')
-PROJECT_APP_LEGACY_HOST = config('PROJECT_APP_HOST', 'http://localhost:3000' if 'local' in ENVIRONMENT else f'https://www.{ENVIRONMENT_PREFIX}heliumedu.com')
# Version information
PROJECT_VERSION = __version__
-FRONTEND_LEGACY_VERSION = config('PLATFORM_FRONTEND_LEGACY_VERSION', "latest")
-
# AWS S3
AWS_S3_ACCESS_KEY_ID = config('PLATFORM_AWS_S3_ACCESS_KEY_ID')
AWS_S3_SECRET_ACCESS_KEY = config('PLATFORM_AWS_S3_SECRET_ACCESS_KEY')
-# Twilio
-
-TWILIO_ACCOUNT_SID = config('PLATFORM_TWILIO_ACCOUNT_SID')
-TWILIO_AUTH_TOKEN = config('PLATFORM_TWILIO_AUTH_TOKEN')
-TWILIO_SMS_FROM = config('PLATFORM_TWILIO_SMS_FROM')
-
# Google Analytics
GA4_MEASUREMENT_ID = config('PLATFORM_GA4_MEASUREMENT_ID', default=None)
@@ -243,12 +234,11 @@
),
'DEFAULT_THROTTLE_CLASSES': (
'rest_framework.throttling.AnonRateThrottle',
- 'helium.common.throttles.UserRateThrottle',
+ 'rest_framework.throttling.UserRateThrottle',
),
'DEFAULT_THROTTLE_RATES': {
'anon': '10/min',
'user': '120/min',
- 'user_legacy': '300/min', # TODO: Remove once the legacy frontend (www.heliumedu.com) is retired
'user_token': '5/hour',
'delete_inactive': '1/min',
'support_contact': '5/hour',
@@ -260,10 +250,6 @@
ACCESS_TOKEN_TTL_MINUTES = 5
REFRESH_TOKEN_TTL_DAYS = 14
-# TTL values for the legacy frontend that doesn't reliably support token refresh
-LEGACY_ACCESS_TOKEN_TTL_MINUTES = 60 * 24 * 7
-LEGACY_REFRESH_TOKEN_TTL_DAYS = int(config('PLATFORM_LEGACY_REFRESH_TOKEN_TTL_DAYS', '30'))
-
if ACCESS_TOKEN_TTL_MINUTES < 3:
raise ImproperlyConfigured("ACCESS_TOKEN_TTL_MINUTES cannot be less than 3")
@@ -494,7 +480,6 @@
# Email settings
DISABLE_EMAILS = config('PROJECT_DISABLE_EMAILS', 'False') == 'True'
-DISABLE_TEXTS = config('PROJECT_DISABLE_TEXTS', 'False') == 'True'
DISABLE_PUSH = config('PROJECT_DISABLE_PUSH', 'False') == 'True'
REMINDER_SEND_WINDOW_MINUTES = int(config('PROJECT_REMINDER_SEND_WINDOW_MINUTES', '15'))
@@ -568,22 +553,20 @@
PROJECT_LANDING_HOST = config(
'PROJECT_LANDING_HOST',
- 'http://localhost:4321' if 'local' in ENVIRONMENT else f'https://landing.{ENVIRONMENT_PREFIX}heliumedu.com'
+ 'http://localhost:4321' if 'local' in ENVIRONMENT else f'https://www.{ENVIRONMENT_PREFIX}heliumedu.com'
)
CSRF_TRUSTED_ORIGINS = [
PROJECT_APP_HOST,
PROJECT_API_HOST,
- PROJECT_APP_LEGACY_HOST,
- strip_www(PROJECT_APP_LEGACY_HOST),
PROJECT_LANDING_HOST,
+ strip_www(PROJECT_LANDING_HOST),
]
CORS_ALLOWED_ORIGINS = [
PROJECT_APP_HOST,
PROJECT_API_HOST,
- PROJECT_APP_LEGACY_HOST,
- strip_www(PROJECT_APP_LEGACY_HOST),
PROJECT_LANDING_HOST,
+ strip_www(PROJECT_LANDING_HOST),
]
if PROJECT_CI_APP_HOST:
@@ -605,16 +588,10 @@
CSRF_TRUSTED_ORIGINS += [
'http://localhost:8080',
'http://127.0.0.1:8080',
- # Legacy frontend
- 'http://localhost:3000',
- 'http://127.0.0.1:3000',
]
CORS_ALLOWED_ORIGINS += [
'http://localhost:8080',
'http://127.0.0.1:8080',
- # Legacy frontend
- 'http://localhost:3000',
- 'http://127.0.0.1:3000',
]
if 'local' in ENVIRONMENT:
diff --git a/helium/auth/admin.py b/helium/auth/admin.py
index 18fbd3d0c..761c64d94 100644
--- a/helium/auth/admin.py
+++ b/helium/auth/admin.py
@@ -17,7 +17,6 @@
from rest_framework_simplejwt.token_blacklist.admin import OutstandingTokenAdmin, BlacklistedTokenAdmin
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken
-from helium.auth.models import UserProfile
from helium.auth.models import UserSettings
from helium.auth.models.tokenproxy import BlacklistedTokenProxy, OutstandingTokenProxy
from helium.auth.models.userclientactivity import UserClientActivity
@@ -334,7 +333,7 @@ class UserAdmin(ObjectActionsMixin, admin.UserAdmin, BaseModelAdmin):
list_display = ('email', 'last_activity', 'get_auth_type',
'num_notes', 'num_courses', 'num_homework', 'num_events',
- 'num_attachments', 'num_external_calendars', 'last_login_legacy',
+ 'num_attachments', 'num_external_calendars',
'deletion_warning_count', 'deletion_requested_at', 'mobile_app_usage_percent_30d',
'created_at', 'is_active')
list_filter = (ActiveStatusFilter, PendingDeletionFilter, 'settings__show_getting_started',
@@ -396,7 +395,7 @@ def _user_count_subquery(model, user_path='user'):
def get_readonly_fields(self, request, obj=None):
if obj:
- base = self.readonly_fields + ('created_at', 'last_login', 'last_login_legacy', 'last_activity',
+ base = self.readonly_fields + ('created_at', 'last_login', 'last_activity',
'mobile_app_usage_percent_30d', 'deletion_warning_count',
'deletion_warning_sent_at', 'onboarding_completed_at',
'deletion_requested_at', 'get_2fa_enabled',
@@ -471,43 +470,6 @@ def num_external_calendars(self, obj):
num_external_calendars.admin_order_field = '_num_external_calendars'
-class UserProfileAdmin(BaseModelAdmin):
- list_display = ['get_user', 'phone', 'phone_verified', 'get_last_login', 'get_last_activity']
- list_filter = [staff_filter('user')]
- search_fields = ('user__id', 'user__email', 'user__username')
- ordering = ('-user__last_activity',)
- readonly_fields = ('user', 'phone_changing', 'phone_verification_code', 'phone_verified',)
-
- def has_add_permission(self, request):
- return False
-
- def has_delete_permission(self, request, obj=None):
- return False
-
- def get_user(self, obj):
- if obj.user:
- return obj.user.get_username()
- else:
- return ''
-
- def get_last_login(self, obj):
- if obj.user:
- return obj.user.last_login
- else:
- return ''
-
- def get_last_activity(self, obj):
- if obj.user:
- return obj.user.last_activity
- else:
- return ''
-
- get_user.short_description = 'User'
- get_user.admin_order_field = 'user__username'
- get_last_login.short_description = 'Last Login'
- get_last_login.admin_order_field = 'user__last_login'
-
-
class UserSettingsAdmin(BaseModelAdmin):
list_display = ['get_user', 'time_zone', 'default_view', 'default_reminder_type', 'color_scheme_theme',
'review_prompts_requested', 'get_last_activity']
@@ -666,7 +628,6 @@ def has_delete_permission(self, request, obj=None):
# Register the models in the Admin
admin_site.register(get_user_model(), UserAdmin)
-admin_site.register(UserProfile, UserProfileAdmin)
admin_site.register(UserSettings, UserSettingsAdmin)
admin_site.register(UserPushToken, UserPushTokenAdmin)
admin_site.register(UserOAuthProvider, UserOAuthProviderAdmin)
diff --git a/helium/auth/managers/usermanager.py b/helium/auth/managers/usermanager.py
index 250b777b7..b297d4043 100644
--- a/helium/auth/managers/usermanager.py
+++ b/helium/auth/managers/usermanager.py
@@ -7,7 +7,6 @@
from django.db import models
from django.db.models import Q, Count
-from helium.auth.models.userprofile import UserProfile
from helium.auth.models.usersettings import UserSettings
logger = logging.getLogger(__name__)
@@ -38,11 +37,10 @@ class UserManager(BaseUserManager):
@staticmethod
def create_references(user):
"""
- Create necessary one-to-one references to profile and settings models for a user.
+ Create necessary one-to-one references to settings models for a user.
:param user: the user to create the dependencies for
"""
- UserProfile.objects.create(user=user)
UserSettings.objects.create(user=user)
def create_user(self, username, email, password=None): # pragma: no cover
diff --git a/helium/auth/migrations/0063_alter_usersettings_default_reminder_type.py b/helium/auth/migrations/0063_alter_usersettings_default_reminder_type.py
new file mode 100644
index 000000000..4f05030df
--- /dev/null
+++ b/helium/auth/migrations/0063_alter_usersettings_default_reminder_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.14 on 2026-05-11 14:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('helium_auth', '0062_alter_useroauthprovider_provider'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='usersettings',
+ name='default_reminder_type',
+ field=models.PositiveIntegerField(choices=[(1, 'Email'), (3, 'Push')], default=3, help_text='A valid default type of reminder choice when creating a new reminder.'),
+ ),
+ ]
diff --git a/helium/auth/migrations/0064_remove_userprofile_and_last_login_legacy.py b/helium/auth/migrations/0064_remove_userprofile_and_last_login_legacy.py
new file mode 100644
index 000000000..94506bf50
--- /dev/null
+++ b/helium/auth/migrations/0064_remove_userprofile_and_last_login_legacy.py
@@ -0,0 +1,21 @@
+__copyright__ = "Copyright (c) 2026 Helium Edu"
+__license__ = "MIT"
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('helium_auth', '0063_alter_usersettings_default_reminder_type'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='last_login_legacy',
+ ),
+ migrations.DeleteModel(
+ name='UserProfile',
+ ),
+ ]
diff --git a/helium/auth/migrations/0065_alter_user_username_and_more.py b/helium/auth/migrations/0065_alter_user_username_and_more.py
new file mode 100644
index 000000000..983607159
--- /dev/null
+++ b/helium/auth/migrations/0065_alter_user_username_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.2.14 on 2026-05-29 16:19
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('helium_auth', '0064_remove_userprofile_and_last_login_legacy'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='username',
+ field=models.CharField(error_messages={'unique': 'Sorry, that username is already in use.'}, help_text='A unique name used to login to the system.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.+-]+$', 'Enter a valid username, which means less than 30 characters consisting of letters, numbers, or these symbols: +-_.', 'invalid')]),
+ ),
+ migrations.AlterField(
+ model_name='usersettings',
+ name='default_reminder_type',
+ field=models.PositiveIntegerField(choices=[(1, 'Email'), (3, 'Push')], default=3, help_text='The reminder type pre-selected when creating a new reminder.'),
+ ),
+ ]
diff --git a/helium/auth/models/__init__.py b/helium/auth/models/__init__.py
index 4e69a2c0e..a1f75e6be 100644
--- a/helium/auth/models/__init__.py
+++ b/helium/auth/models/__init__.py
@@ -5,6 +5,5 @@
from .user import User
from .userclientactivity import UserClientActivity
from .useroauthprovider import UserOAuthProvider
-from .userprofile import UserProfile
from .userpushtoken import UserPushToken
from .usersettings import UserSettings
diff --git a/helium/auth/models/user.py b/helium/auth/models/user.py
index 29d2af94d..b42a2ab22 100644
--- a/helium/auth/models/user.py
+++ b/helium/auth/models/user.py
@@ -15,7 +15,10 @@
class User(AbstractBaseUser, BaseModel):
- username = models.CharField(help_text='A unique name used to log in to the system.',
+ # Deprecated: kept only because Django's AbstractBaseUser requires a USERNAME_FIELD.
+ # Auto-generated from email at registration; never surfaced to users in the new
+ # frontend (email-only auth). Removal requires a custom user model migration.
+ username = models.CharField(help_text='A unique name used to login to the system.',
max_length=255, unique=True,
validators=[validators.RegexValidator(r'^[\w.+-]+$',
'Enter a valid username, which means less than '
@@ -37,10 +40,6 @@ class User(AbstractBaseUser, BaseModel):
is_superuser = models.BooleanField(default=False)
- # Deprecated: tracks legacy frontend logins, remove when frontend-legacy is shut down
- last_login_legacy = models.DateTimeField(blank=True, null=True,
- help_text='Last login time via legacy frontend.')
-
last_activity = models.DateTimeField(auto_now_add=True, db_index=True,
help_text='Last user activity (login or token refresh).')
diff --git a/helium/auth/models/userprofile.py b/helium/auth/models/userprofile.py
deleted file mode 100644
index 1c364e3e4..000000000
--- a/helium/auth/models/userprofile.py
+++ /dev/null
@@ -1,33 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import logging
-
-from django.conf import settings
-from django.db import models
-
-from helium.auth.utils.userutils import generate_verification_code
-from helium.common.models import BaseModel
-
-logger = logging.getLogger(__name__)
-
-
-class UserProfile(BaseModel):
- phone = models.CharField(help_text='A valid phone number.',
- max_length=50, blank=True, null=True)
-
- phone_changing = models.CharField(max_length=50, blank=True, null=True)
-
- phone_verification_code = models.PositiveIntegerField(
- help_text='The code sent to `phone` when registering or changing a phone number.',
- default=generate_verification_code)
-
- phone_verified = models.BooleanField(default=False)
-
- user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='profile', on_delete=models.CASCADE)
-
- def __str__(self): # pragma: no cover
- return f'{self.pk} ({self.user.get_username()})'
-
- def get_user(self):
- return self.user
diff --git a/helium/auth/serializers/tokenserializer.py b/helium/auth/serializers/tokenserializer.py
index 0e74866d5..f3c51a47a 100644
--- a/helium/auth/serializers/tokenserializer.py
+++ b/helium/auth/serializers/tokenserializer.py
@@ -16,7 +16,6 @@
from rest_framework.exceptions import PermissionDenied
from rest_framework_simplejwt.exceptions import AuthenticationFailed
from rest_framework_simplejwt.settings import api_settings
-from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
from helium.auth.models import UserClientActivity, UserOAuthProvider
from helium.auth.tasks import blacklist_refresh_token
@@ -79,7 +78,7 @@ def __init__(self, *args, **kwargs):
trim_whitespace=False,
)
- def validate(self, attrs, update_last_login_field=True):
+ def validate(self, attrs):
legacy_username = attrs.pop('username', None)
email = attrs.pop('email', None)
password = attrs.pop('password', None)
@@ -116,8 +115,7 @@ def validate(self, attrs, update_last_login_field=True):
attrs["access"] = str(token.access_token)
attrs["refresh"] = str(token)
- if update_last_login_field:
- update_last_login(None, user)
+ update_last_login(None, user)
user.last_activity = timezone.now()
user.deletion_warning_count = 0
@@ -141,39 +139,6 @@ def validate(self, attrs, update_last_login_field=True):
return attrs
-class LegacyAccessToken(AccessToken):
- """Access token with legacy (longer) lifetime for legacy frontend."""
- lifetime = timedelta(minutes=settings.LEGACY_ACCESS_TOKEN_TTL_MINUTES)
-
-
-class LegacyRefreshToken(RefreshToken):
- """Refresh token with legacy (longer) lifetime for legacy frontend."""
- lifetime = timedelta(days=settings.LEGACY_REFRESH_TOKEN_TTL_DAYS)
- access_token_class = LegacyAccessToken
-
-
-class LegacyTokenObtainSerializer(TokenObtainSerializer):
- """
- Token obtain serializer for legacy frontend that doesn't properly support token refresh.
- Uses longer token lifetimes configured via LEGACY_*_TTL settings.
-
- Deprecated: Remove when frontend-legacy is shut down.
- """
-
- @classmethod
- def get_token(cls, user):
- return LegacyRefreshToken.for_user(user)
-
- def validate(self, attrs):
- attrs = super().validate(attrs, update_last_login_field=False)
-
- if user := getattr(self, '_authenticated_user', None):
- user.last_login_legacy = timezone.now()
- user.save(update_fields=['last_login_legacy'])
-
- return attrs
-
-
class TokenRefreshSerializer(jwt_serializers.TokenRefreshSerializer):
def validate(self, attrs):
UserModel = get_user_model()
diff --git a/helium/auth/serializers/userprofileserializer.py b/helium/auth/serializers/userprofileserializer.py
deleted file mode 100644
index 3842c8694..000000000
--- a/helium/auth/serializers/userprofileserializer.py
+++ /dev/null
@@ -1,103 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import logging
-
-from django.conf import settings
-from rest_framework import serializers
-
-from helium.auth.models import UserProfile
-from helium.auth.utils.userutils import generate_verification_code
-from helium.common.services.phoneservice import verify_number, HeliumPhoneError
-from helium.common.tasks import send_text
-from helium.common.utils import metricutils, taskutils
-
-logger = logging.getLogger(__name__)
-
-
-class UserProfileSerializer(serializers.ModelSerializer):
- class Meta:
- model = UserProfile
- fields = (
- 'phone', 'phone_changing', 'phone_verification_code', 'phone_verified', 'user')
- read_only_fields = ('phone_changing', 'phone_verified', 'user',)
- extra_kwargs = {
- 'phone_verification_code': {'write_only': True},
- }
-
- def validate_phone(self, phone):
- """
- Cleanup the phone number by validating it with an external service.
-
- :param phone: the phone number being saved
- :return:
- """
- if phone.strip() == "":
- return ""
-
- try:
- return verify_number(phone)
- except HeliumPhoneError as ex:
- raise serializers.ValidationError(ex)
-
- def validate_phone_verification_code(self, phone_verification_code):
- """
- Ensure the phone verification code matches our records.
-
- :param phone_verification_code: the verification code
- """
- if phone_verification_code != self.instance.phone_verification_code:
- raise serializers.ValidationError("The verification code does not match our records")
-
- return phone_verification_code
-
- def update(self, instance, validated_data):
- # Manually process fields that require shuffling before relying on the serializer's internals to save the rest
- if 'phone_verification_code' in validated_data and validated_data.get('phone_verification_code'):
- self.__process_phone_verification_code(instance, validated_data)
-
- logger.debug(f"User {instance.user} has verified their phone number as {instance.phone}")
-
- metricutils.increment('action.user.phone-changed', request=self.context.get('request'),
- user=instance.user)
- elif 'phone' in validated_data and not validated_data.get('phone'):
- self.__clear_phone_fields(instance, validated_data)
- else:
- self.__process_phone_changing(instance, validated_data)
-
- super().update(instance, validated_data)
-
- return instance
-
- def __process_phone_verification_code(self, instance, validated_data):
- if instance.phone_changing:
- instance.phone = instance.phone_changing
- instance.phone_changing = None
-
- instance.phone_verified = True
-
- validated_data.pop('phone', None)
-
- def __clear_phone_fields(self, instance, validated_data):
- instance.phone = None
- instance.phone_changing = None
- instance.phone_verified = False
-
- validated_data.pop('phone', None)
-
- def __process_phone_changing(self, instance, validated_data):
- phone = instance.phone
-
- if 'phone' in validated_data and instance.phone != validated_data.get('phone'):
- instance.phone_changing = validated_data.pop('phone')
- phone = instance.phone_changing
-
- if instance.phone != phone and phone:
- instance.phone_verification_code = generate_verification_code()
-
- taskutils.safe_apply_async(send_text,
- args=(phone, f'Enter this verification code on Helium\'s "Settings" page: {instance.phone_verification_code}'),
- priority=settings.CELERY_PRIORITY_HIGH,
- )
-
- logger.debug(f"Verification text with code \"{instance.phone_verification_code}\" sent to {instance.phone}")
diff --git a/helium/auth/serializers/userserializer.py b/helium/auth/serializers/userserializer.py
index 756b5f53f..9c9407176 100644
--- a/helium/auth/serializers/userserializer.py
+++ b/helium/auth/serializers/userserializer.py
@@ -12,7 +12,6 @@
from helium.auth.models import UserSettings
from helium.auth.serializers.useroauthproviderserializer import UserOAuthProviderSerializer
-from helium.auth.serializers.userprofileserializer import UserProfileSerializer
from helium.auth.serializers.usersettingsserializer import UserSettingsSerializer
from helium.auth.tasks import send_verification_email
from helium.auth.utils.userutils import generate_verification_code, generate_unique_username_from_email, \
@@ -37,8 +36,6 @@ class UserSerializer(serializers.ModelSerializer):
password = serializers.CharField(help_text='A password to set for the user.',
required=False, write_only=True)
- profile = UserProfileSerializer(required=False, read_only=True)
-
settings = UserSettingsSerializer(required=False, read_only=True)
oauth_providers = UserOAuthProviderSerializer(many=True, read_only=True)
@@ -54,7 +51,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
fields = ('id', 'username', 'email', 'email_changing', 'old_password', 'password',
- 'profile', 'settings', 'oauth_providers', 'has_usable_password', 'has_oauth_providers',)
+ 'settings', 'oauth_providers', 'has_usable_password', 'has_oauth_providers',)
read_only_fields = ('email_changing', 'oauth_providers', 'has_usable_password', 'has_oauth_providers',)
extra_kwargs = {
'email': {'validators': []},
@@ -200,12 +197,6 @@ def create_from_oauth(self, validated_data):
class UserCreateSerializer(serializers.Serializer):
- username = serializers.CharField(
- required=False,
- allow_blank=True,
- help_text=get_user_model()._meta.get_field('username').help_text
- )
-
email = serializers.CharField(help_text=get_user_model()._meta.get_field('email').help_text)
password = serializers.CharField(help_text=get_user_model()._meta.get_field('password').help_text)
diff --git a/helium/auth/tests/views/apis/testcaseauthenticationviews.py b/helium/auth/tests/views/apis/testcaseauthenticationviews.py
index c564c707e..e9f3612e6 100644
--- a/helium/auth/tests/views/apis/testcaseauthenticationviews.py
+++ b/helium/auth/tests/views/apis/testcaseauthenticationviews.py
@@ -9,7 +9,6 @@
from django.utils import timezone
from rest_framework import status
-from helium.auth.models import UserProfile
from helium.auth.models import UserSettings
from helium.auth.tests.helpers import userhelper
@@ -93,7 +92,6 @@ def test_registration_success(self):
self.assertEqual(user.username, 'test')
self.assertEqual(user.settings.time_zone, 'America/Chicago')
- self.assertTrue(UserProfile.objects.filter(user__email='test@test.com').exists())
self.assertTrue(UserSettings.objects.filter(user__email='test@test.com').exists())
def test_registration_success_without_username(self):
diff --git a/helium/auth/tests/views/apis/testcasetokenviews.py b/helium/auth/tests/views/apis/testcasetokenviews.py
index ce0be8462..0c697bcc8 100644
--- a/helium/auth/tests/views/apis/testcasetokenviews.py
+++ b/helium/auth/tests/views/apis/testcasetokenviews.py
@@ -268,142 +268,3 @@ def test_authenticated_view_success(self):
self.assertEqual(response2.status_code, status.HTTP_200_OK)
-class TestCaseLegacyTokenViews(APITestCase):
- def test_legacy_token_success(self):
- # GIVEN
- user = userhelper.given_a_user_exists()
-
- # WHEN
- data = {
- 'username': user.get_username(),
- 'password': 'test_pass_1!'
- }
- response = self.client.post(reverse('auth_token_obtain_legacy'),
- json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertIn('access', response.data)
- self.assertIn('refresh', response.data)
- user = get_user_model().objects.get(username=user.get_username())
- self.assertIsNone(user.last_login)
- self.assertIsNotNone(user.last_login_legacy)
- self.assertEqual(OutstandingToken.objects.count(), 1)
-
- def test_legacy_token_has_longer_lifetime(self):
- # GIVEN
- user = userhelper.given_a_user_exists()
-
- # WHEN
- data = {
- 'username': user.get_username(),
- 'password': 'test_pass_1!'
- }
- legacy_response = self.client.post(reverse('auth_token_obtain_legacy'),
- json.dumps(data),
- content_type='application/json')
- standard_response = self.client.post(reverse('auth_token_obtain'),
- json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(legacy_response.status_code, status.HTTP_200_OK)
- self.assertEqual(standard_response.status_code, status.HTTP_200_OK)
-
- # Decode tokens without verification to check expiration times
- legacy_access = jwt.decode(legacy_response.data['access'], options={"verify_signature": False})
- standard_access = jwt.decode(standard_response.data['access'], options={"verify_signature": False})
- legacy_refresh = jwt.decode(legacy_response.data['refresh'], options={"verify_signature": False})
- standard_refresh = jwt.decode(standard_response.data['refresh'], options={"verify_signature": False})
-
- # Legacy tokens should have longer expiration times
- legacy_access_exp = legacy_access['exp'] - legacy_access['iat']
- standard_access_exp = standard_access['exp'] - standard_access['iat']
- legacy_refresh_exp = legacy_refresh['exp'] - legacy_refresh['iat']
- standard_refresh_exp = standard_refresh['exp'] - standard_refresh['iat']
-
- # Legacy access token should be 7 days (vs 5 minutes standard)
- self.assertEqual(legacy_access_exp, settings.LEGACY_ACCESS_TOKEN_TTL_MINUTES * 60)
- self.assertEqual(standard_access_exp, settings.ACCESS_TOKEN_TTL_MINUTES * 60)
- self.assertGreater(legacy_access_exp, standard_access_exp)
-
- # Legacy refresh token should be 30 days (vs 14 days standard)
- self.assertEqual(legacy_refresh_exp, settings.LEGACY_REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60)
- self.assertEqual(standard_refresh_exp, settings.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60)
- self.assertGreater(legacy_refresh_exp, standard_refresh_exp)
-
- def test_legacy_token_works_with_refresh_endpoint(self):
- # GIVEN
- user = userhelper.given_a_user_exists()
- data = {
- 'username': user.get_username(),
- 'password': 'test_pass_1!'
- }
- token_response = self.client.post(reverse('auth_token_obtain_legacy'),
- json.dumps(data),
- content_type='application/json')
- legacy_refresh_token = token_response.data['refresh']
-
- # WHEN
- refresh_response = self.client.post(reverse('auth_token_refresh'),
- json.dumps({"refresh": legacy_refresh_token}),
- content_type='application/json')
-
- # THEN
- self.assertEqual(refresh_response.status_code, status.HTTP_200_OK)
- self.assertIn('access', refresh_response.data)
- self.assertIn('refresh', refresh_response.data)
- self.assertNotEqual(refresh_response.data['refresh'], legacy_refresh_token)
- self.assertEqual(OutstandingToken.objects.count(), 2)
- self.assertEqual(BlacklistedToken.objects.count(), 1)
- self.assertTrue(BlacklistedToken.objects.filter(token__token=legacy_refresh_token).exists())
-
- def test_legacy_token_works_with_blacklist_endpoint(self):
- # GIVEN
- user = userhelper.given_a_user_exists()
- data = {
- 'username': user.get_username(),
- 'password': 'test_pass_1!'
- }
- token_response = self.client.post(reverse('auth_token_obtain_legacy'),
- json.dumps(data),
- content_type='application/json')
- legacy_refresh_token = token_response.data['refresh']
-
- # WHEN
- blacklist_response = self.client.post(reverse('auth_token_blacklist'),
- json.dumps({"refresh": legacy_refresh_token}),
- content_type='application/json')
-
- # THEN
- self.assertEqual(blacklist_response.status_code, status.HTTP_200_OK)
- self.assertEqual(OutstandingToken.objects.count(), 1)
- self.assertEqual(BlacklistedToken.objects.count(), 1)
- self.assertTrue(BlacklistedToken.objects.filter(token__token=legacy_refresh_token).exists())
-
- # Verify the token can no longer be used for refresh
- refresh_response = self.client.post(reverse('auth_token_refresh'),
- json.dumps({"refresh": legacy_refresh_token}),
- content_type='application/json')
- self.assertEqual(refresh_response.status_code, status.HTTP_401_UNAUTHORIZED)
-
- def test_legacy_token_access_works_for_authenticated_views(self):
- # GIVEN
- user = userhelper.given_a_user_exists()
- data = {
- 'username': user.get_username(),
- 'password': 'test_pass_1!'
- }
- token_response = self.client.post(reverse('auth_token_obtain_legacy'),
- json.dumps(data),
- content_type='application/json')
- legacy_access_token = token_response.data['access']
-
- # WHEN
- self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {legacy_access_token}')
- response = self.client.get(reverse('auth_user_detail'))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data['email'], user.email)
diff --git a/helium/auth/tests/views/apis/testcaseuserprofileviews.py b/helium/auth/tests/views/apis/testcaseuserprofileviews.py
deleted file mode 100644
index 7f25f80fa..000000000
--- a/helium/auth/tests/views/apis/testcaseuserprofileviews.py
+++ /dev/null
@@ -1,152 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import json
-from unittest import mock
-
-from django.urls import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from helium.auth.tests.helpers import userhelper
-from helium.common.services.phoneservice import HeliumPhoneError
-
-
-class TestCaseUserProfileViews(APITestCase):
- def test_user_profile_login_required(self):
- # GIVEN
- userhelper.given_a_user_exists()
-
- # WHEN
- responses = [
- self.client.get(reverse('auth_user_profile_detail')),
- self.client.put(reverse('auth_user_profile_detail'))
- ]
-
- # THEN
- for response in responses:
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
- @mock.patch('helium.common.services.phoneservice._get_client')
- @mock.patch('helium.auth.serializers.userprofileserializer.verify_number', return_value='+15555555555')
- def test_put_user_profile(self, mock_verify_number, mock_get_client):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- self.assertIsNone(user.profile.phone)
- self.assertIsNone(user.profile.phone_changing)
-
- # WHEN
- data = {
- 'phone': '(555) 555-5555',
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- mock_verify_number.assert_called_once()
- mock_get_client.assert_called_once()
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertIsNone(response.data['phone'])
- self.assertEqual(response.data['phone_changing'], '+15555555555')
- user.refresh_from_db()
- self.assertIsNone(user.profile.phone)
- self.assertEqual(user.profile.phone_changing, response.data['phone_changing'])
-
- @mock.patch('helium.auth.serializers.userprofileserializer.verify_number')
- def test_put_bad_data_fails(self, mock_verify_number):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- self.assertIsNone(user.profile.phone)
- self.assertIsNone(user.profile.phone_changing)
- mock_verify_number.side_effect = HeliumPhoneError('Invalid phone number.')
-
- # WHEN
- data = {
- 'phone': 'not-a-phone',
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertIn('phone', response.data)
-
- def test_phone_changes_after_verification(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- user.profile.phone_changing = '5555555'
- user.profile.phone_verification_code = 123456
- user.profile.save()
- self.assertFalse(user.profile.phone_verified)
-
- # WHEN
- data = {
- 'phone_verification_code': user.profile.phone_verification_code,
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data['phone'], '5555555')
- self.assertIsNone(response.data['phone_changing'])
- user.refresh_from_db()
- self.assertEqual(user.profile.phone, response.data['phone'])
- self.assertIsNone(user.profile.phone_changing)
- self.assertTrue(user.profile.phone_verified)
-
- def test_remove_phone(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- user.profile.phone = '5555555'
- user.profile.save()
-
- # WHEN
- data = {
- 'phone': '',
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertIsNone(response.data['phone'], '5555555')
- user.refresh_from_db()
- self.assertIsNone(user.profile.phone)
-
- def test_invalid_phone_verification_code_fails(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- user.profile.phone_changing = '5555555'
- user.profile.phone_verification_code = 123456
- user.profile.save()
-
- # WHEN
- data = {
- 'phone_verification_code': 000000,
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertIn('phone_verification_code', response.data)
-
- def test_put_read_only_field_does_nothing(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- phone_changing = '5555555'
- user.profile.phone_changing = phone_changing
- user.profile.save()
-
- # WHEN
- data = {
- 'phone_changing': '444-4444'
- }
- response = self.client.put(reverse('auth_user_profile_detail'), json.dumps(data),
- content_type='application/json')
-
- # THEN
- user.refresh_from_db()
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(user.profile.phone_changing, phone_changing)
diff --git a/helium/auth/tests/views/apis/testcaseuserpushtokenviews.py b/helium/auth/tests/views/apis/testcaseuserpushtokenviews.py
index 6355bfd6e..38351cfab 100644
--- a/helium/auth/tests/views/apis/testcaseuserpushtokenviews.py
+++ b/helium/auth/tests/views/apis/testcaseuserpushtokenviews.py
@@ -67,19 +67,6 @@ def test_create_push_token(self):
push_token = UserPushToken.objects.get(pk=response.data['id'])
userhelper.verify_push_token_matches(self, push_token, response.data)
- def test_get_push_token_by_id(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- push_token = userhelper.given_user_push_token_exists(user)
-
- # WHEN
- response = self.client.get(reverse('auth_user_pushtoken_detail',
- kwargs={'pk': push_token.pk}))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- userhelper.verify_push_token_matches(self, push_token, response.data)
-
def test_delete_push_token_by_id(self):
# GIVEN
user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
diff --git a/helium/auth/tests/views/apis/testcaseuserviews.py b/helium/auth/tests/views/apis/testcaseuserviews.py
index 4c9363406..3386f8093 100644
--- a/helium/auth/tests/views/apis/testcaseuserviews.py
+++ b/helium/auth/tests/views/apis/testcaseuserviews.py
@@ -9,7 +9,7 @@
from rest_framework import status
from rest_framework.test import APITestCase
-from helium.auth.models import UserSettings, UserProfile
+from helium.auth.models import UserSettings
from helium.auth.tests.helpers import userhelper
from helium.planner.tests.helpers import coursegrouphelper, coursehelper, homeworkhelper
@@ -42,12 +42,6 @@ def test_get_user(self):
self.assertNotIn('verification_code', response.data)
self.assertNotIn('username', response.data)
self.assertEqual(user.email, response.data['email'])
- # Profile fields
- self.assertNotIn('phone_verification_code', response.data['profile'])
- self.assertEqual(user.profile.phone, response.data['profile']['phone'])
- self.assertEqual(user.profile.phone_changing, response.data['profile']['phone_changing'])
- self.assertEqual(user.profile.phone_verified, response.data['profile']['phone_verified'])
- self.assertEqual(user.profile.user.pk, response.data['profile']['user'])
# Settings fields
self.assertEqual(user.settings.time_zone, response.data['settings']['time_zone'])
self.assertEqual(user.settings.default_view, response.data['settings']['default_view'])
@@ -278,7 +272,6 @@ def test_delete_user(self):
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(get_user_model().objects.filter(pk=user.pk).exists())
self.assertFalse(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertFalse(UserProfile.objects.filter(user_id=user.pk).exists())
def test_delete_fails_bad_request(self):
# GIVEN
@@ -295,7 +288,6 @@ def test_delete_fails_bad_request(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(get_user_model().objects.filter(pk=user.pk).exists())
self.assertTrue(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertTrue(UserProfile.objects.filter(user_id=user.pk).exists())
def test_delete_oauth_user_without_password(self):
"""Test that OAuth users (without usable password) can delete their account without providing a password."""
@@ -314,7 +306,6 @@ def test_delete_oauth_user_without_password(self):
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(get_user_model().objects.filter(pk=user.pk).exists())
self.assertFalse(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertFalse(UserProfile.objects.filter(user_id=user.pk).exists())
def test_delete_user_inactive(self):
# GIVEN
@@ -332,7 +323,6 @@ def test_delete_user_inactive(self):
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(get_user_model().objects.filter(pk=user.pk).exists())
self.assertFalse(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertFalse(UserProfile.objects.filter(user_id=user.pk).exists())
def test_delete_user_inactive_fails_bad_request(self):
# GIVEN
@@ -350,7 +340,6 @@ def test_delete_user_inactive_fails_bad_request(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(get_user_model().objects.filter(pk=user.pk).exists())
self.assertTrue(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertTrue(UserProfile.objects.filter(user_id=user.pk).exists())
def test_delete_user_inactive_with_active_user(self):
# GIVEN
@@ -368,7 +357,6 @@ def test_delete_user_inactive_with_active_user(self):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertTrue(get_user_model().objects.filter(pk=user.pk).exists())
self.assertTrue(UserSettings.objects.filter(user_id=user.pk).exists())
- self.assertTrue(UserProfile.objects.filter(user_id=user.pk).exists())
def test_oauth_user_can_add_password(self):
"""Test that OAuth users can add a password without providing old_password."""
diff --git a/helium/auth/urls.py b/helium/auth/urls.py
index ee4118733..16d46836b 100644
--- a/helium/auth/urls.py
+++ b/helium/auth/urls.py
@@ -5,12 +5,10 @@
from helium.auth.views.apis.apitokenviews import ApiTokenView
from helium.auth.views.apis.oauthviews import OAuthLoginView
-from helium.auth.views.apis.tokenviews import TokenObtainPairView, TokenRefreshView, TokenBlacklistView, \
- LegacyTokenObtainPairView
+from helium.auth.views.apis.tokenviews import TokenObtainPairView, TokenRefreshView, TokenBlacklistView
from helium.auth.views.apis.userauthresourceviews import UserRegisterResourceView, UserVerifyResourceView, \
UserForgotResourceView, UserResendVerificationResourceView
from helium.auth.views.apis.userdeleteexamplescheduleviews import UserDeleteExampleScheduleView
-from helium.auth.views.apis.userprofileviews import UserProfileApiDetailView
from helium.auth.views.apis.userpushtoken import UserPushTokenApiDetailView, UserPushTokenApiListView
from helium.auth.views.apis.userreviewpromptviews import UserReviewPromptAckView
from helium.auth.views.apis.usersettingsviews import UserSettingsApiDetailView
@@ -33,7 +31,6 @@
# Authentication URLs
##############################
path('auth/token/', TokenObtainPairView.as_view(), name='auth_token_obtain'),
- path('auth/token/legacy/', LegacyTokenObtainPairView.as_view(), name='auth_token_obtain_legacy'),
path('auth/token/refresh/', TokenRefreshView.as_view(), name='auth_token_refresh'),
path('auth/token/blacklist/', TokenBlacklistView.as_view(), name='auth_token_blacklist'),
path('auth/token/oauth/', OAuthLoginView.as_view({'post': 'oauth_login'}),
@@ -49,8 +46,6 @@
path('auth/user/delete/', UserDeleteResourceView.as_view(), name='auth_user_resource_delete'),
path('auth/user/delete/inactive/', UserDeleteInactiveResourceView.as_view(),
name='auth_user_resource_delete_inactive'),
- path('auth/user/profile/', UserProfileApiDetailView.as_view(),
- name='auth_user_profile_detail'),
path('auth/user/settings/', UserSettingsApiDetailView.as_view(),
name='auth_user_settings_detail'),
path('auth/user/settings/review-prompt-ack/', UserReviewPromptAckView.as_view(),
diff --git a/helium/auth/views/apis/tokenviews.py b/helium/auth/views/apis/tokenviews.py
index b011270b9..ecb84e153 100644
--- a/helium/auth/views/apis/tokenviews.py
+++ b/helium/auth/views/apis/tokenviews.py
@@ -6,8 +6,7 @@
from drf_spectacular.utils import extend_schema, OpenApiExample
from rest_framework_simplejwt import views
-from helium.auth.serializers.tokenserializer import TokenRefreshSerializer, TokenObtainSerializer, \
- LegacyTokenObtainSerializer
+from helium.auth.serializers.tokenserializer import TokenRefreshSerializer, TokenObtainSerializer
from helium.common.views.base import HeliumAPIView
logger = logging.getLogger(__name__)
@@ -38,16 +37,7 @@ def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
-@extend_schema(deprecated=True, exclude=True)
-class LegacyTokenObtainPairView(HeliumAPIView, views.TokenObtainPairView):
- """
- Token obtain endpoint for legacy frontend that doesn't properly support token refresh.
- Uses longer token lifetimes. Excluded from API documentation.
- """
- serializer_class = LegacyTokenObtainSerializer
-
-
-@extend_schema(tags=['auth.token.jwt'])
+@extend_schema(tags=['auth.token'])
class TokenRefreshView(HeliumAPIView, views.TokenRefreshView):
@extend_schema(
operation_id='token_refresh',
diff --git a/helium/auth/views/apis/userprofileviews.py b/helium/auth/views/apis/userprofileviews.py
deleted file mode 100644
index efdc66178..000000000
--- a/helium/auth/views/apis/userprofileviews.py
+++ /dev/null
@@ -1,39 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import logging
-
-from django.contrib.auth import get_user_model
-from drf_spectacular.utils import extend_schema
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-
-from helium.auth.serializers.userprofileserializer import UserProfileSerializer
-from helium.common.permissions import IsOwner
-from helium.common.views.base import HeliumAPIView
-
-logger = logging.getLogger(__name__)
-
-
-class UserProfileApiDetailView(HeliumAPIView):
- queryset = get_user_model().objects.all()
- serializer_class = UserProfileSerializer
- permission_classes = (IsAuthenticated, IsOwner)
-
- def get_object(self):
- return self.request.user
-
- @extend_schema(deprecated=True, exclude=True)
- def put(self, request, *args, **kwargs):
- """
- Update the authenticated user's profile. Only the fields supplied are updated.
- """
- user = self.get_object()
-
- serializer = self.get_serializer(user.profile, data=request.data, partial=True)
- serializer.is_valid(raise_exception=True)
- serializer.save()
-
- logger.info(f'Profile updated for user {user.pk}')
-
- return Response(serializer.data)
diff --git a/helium/auth/views/apis/userpushtoken.py b/helium/auth/views/apis/userpushtoken.py
index 2439ae3d8..fd89b4553 100644
--- a/helium/auth/views/apis/userpushtoken.py
+++ b/helium/auth/views/apis/userpushtoken.py
@@ -4,8 +4,7 @@
import logging
from drf_spectacular.utils import extend_schema
-from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, \
- DestroyModelMixin
+from rest_framework.mixins import CreateModelMixin, ListModelMixin, UpdateModelMixin, DestroyModelMixin
from rest_framework.permissions import IsAuthenticated
from helium.auth.filters import UserPushTokenFilter
@@ -58,7 +57,7 @@ def post(self, request, *args, **kwargs):
@extend_schema(exclude=True)
-class UserPushTokenApiDetailView(HeliumAPIView, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin):
+class UserPushTokenApiDetailView(HeliumAPIView, UpdateModelMixin, DestroyModelMixin):
serializer_class = UserPushTokenSerializer
permission_classes = (IsAuthenticated, IsOwner,)
@@ -69,15 +68,6 @@ def get_queryset(self):
else:
return UserPushToken.objects.none()
- @extend_schema(deprecated=True, exclude=True)
- def get(self, request, *args, **kwargs):
- """
- Return the given push token instance.
- """
- response = self.retrieve(request, *args, **kwargs)
-
- return response
-
def delete(self, request, *args, **kwargs):
"""
Delete the given push token instance.
diff --git a/helium/common/enums.py b/helium/common/enums.py
index 9c92467b9..62022c1b9 100644
--- a/helium/common/enums.py
+++ b/helium/common/enums.py
@@ -35,15 +35,16 @@
(SATURDAY, 'Saturday')
)
-POPUP = 0
EMAIL = 1
-TEXT = 2
PUSH = 3
+# Deprecated reminder types. Kept as aliases for PUSH so historical references and existing
+# imports continue to work, while the API surface (REMINDER_TYPE_CHOICES) only exposes EMAIL
+# and PUSH going forward.
+POPUP = PUSH
+TEXT = PUSH
REMINDER_TYPE_CHOICES = (
- (POPUP, 'Popup'),
(EMAIL, 'Email'),
- (TEXT, 'Text'),
- (PUSH, 'Push')
+ (PUSH, 'Push'),
)
MINUTES = 0
diff --git a/helium/common/services/phoneservice.py b/helium/common/services/phoneservice.py
deleted file mode 100644
index 6a97062ef..000000000
--- a/helium/common/services/phoneservice.py
+++ /dev/null
@@ -1,56 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import logging
-import re
-
-from django.conf import settings
-from twilio.base.exceptions import TwilioRestException
-from twilio.rest import Client
-
-from helium.common.utils import metricutils
-from helium.common.utils.commonutils import HeliumError
-
-logger = logging.getLogger(__name__)
-
-
-class HeliumPhoneError(HeliumError):
- pass
-
-
-def send_sms(phone, message):
- client = _get_client()
-
- try:
- client.api.account.messages.create(
- to=phone,
- from_=settings.TWILIO_SMS_FROM,
- body=message)
-
- logger.debug(f"SMS sent to {phone}")
- metricutils.increment('action.text.sent')
- except TwilioRestException:
- logger.error(f"Failed to send SMS to {phone}", exc_info=True)
- metricutils.increment('action.text.failed')
- raise
-
-
-def verify_number(phone):
- client = _get_client()
-
- try:
- cleaned_phone = re.sub("[()\\-+\\s]", "", phone)
-
- logger.info(f"Asking Twilio to validate {cleaned_phone}")
-
- number = client.lookups.v1.phone_numbers(cleaned_phone).fetch()
-
- return number.phone_number
- except TwilioRestException:
- logger.info(f"Number {phone} did not pass validation")
-
- raise HeliumPhoneError("Oops, that looks like an invalid phone number.")
-
-
-def _get_client():
- return Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
diff --git a/helium/common/tasks.py b/helium/common/tasks.py
index a04b5a538..b95aa8a9b 100644
--- a/helium/common/tasks.py
+++ b/helium/common/tasks.py
@@ -8,7 +8,6 @@
from conf.celery import app
from helium.auth.models import UserPushToken
from helium.common.periodic import PERIODIC_TASKS
-from helium.common.services.phoneservice import send_sms
from helium.common.services.pushservice import send_notifications
from helium.common.services.sesreputationservice import process_ses_notification
from helium.common.utils import metricutils
@@ -16,21 +15,6 @@
logger = logging.getLogger(__name__)
-@app.task(bind=True)
-def send_text(self, phone, message):
- published_at_ms = metricutils.get_published_at_ms(self)
- metrics = metricutils.task_start("text.sent", priority="high", published_at_ms=published_at_ms)
-
- if settings.DISABLE_TEXTS:
- logger.warning(
- f'Texts disabled. Text with message "{message}" to {phone} not sent.')
- metricutils.task_stop(metrics, value=0)
- return
-
- send_sms(phone, message)
- metricutils.task_stop(metrics)
-
-
@app.task(bind=True)
def send_pushes(self, push_tokens, username, subject, message, reminder_data):
published_at_ms = metricutils.get_published_at_ms(self)
diff --git a/helium/common/tests/testcasetasks.py b/helium/common/tests/testcasetasks.py
index ab34ce08f..f4e8a4c6f 100644
--- a/helium/common/tests/testcasetasks.py
+++ b/helium/common/tests/testcasetasks.py
@@ -5,44 +5,10 @@
from django.test import TestCase, override_settings
-from helium.common.tasks import send_text, send_pushes
+from helium.common.tasks import send_pushes
class TestCaseTasks(TestCase):
- @override_settings(DISABLE_TEXTS=True)
- @mock.patch('helium.common.tasks.send_sms')
- @mock.patch('helium.common.tasks.metricutils.task_stop')
- @mock.patch('helium.common.tasks.metricutils.task_start')
- @mock.patch('helium.common.tasks.metricutils.get_published_at_ms')
- def test_send_text_disabled(self, mock_published, mock_start, mock_stop, mock_send_sms):
- # GIVEN
- mock_published.return_value = 12345
- mock_start.return_value = {'start': 'metrics'}
-
- # WHEN
- send_text('+15551234567', 'Test message')
-
- # THEN
- mock_send_sms.assert_not_called()
- mock_stop.assert_called_once_with({'start': 'metrics'}, value=0)
-
- @override_settings(DISABLE_TEXTS=False)
- @mock.patch('helium.common.tasks.send_sms')
- @mock.patch('helium.common.tasks.metricutils.task_stop')
- @mock.patch('helium.common.tasks.metricutils.task_start')
- @mock.patch('helium.common.tasks.metricutils.get_published_at_ms')
- def test_send_text_enabled(self, mock_published, mock_start, mock_stop, mock_send_sms):
- # GIVEN
- mock_published.return_value = 12345
- mock_start.return_value = {'start': 'metrics'}
-
- # WHEN
- send_text('+15551234567', 'Test message')
-
- # THEN
- mock_send_sms.assert_called_once_with('+15551234567', 'Test message')
- mock_stop.assert_called_once_with({'start': 'metrics'})
-
@override_settings(DISABLE_PUSH=True)
@mock.patch('helium.common.tasks.send_notifications')
@mock.patch('helium.common.tasks.metricutils.task_stop')
diff --git a/helium/common/throttles.py b/helium/common/throttles.py
index c85773bd8..f3bc8e391 100644
--- a/helium/common/throttles.py
+++ b/helium/common/throttles.py
@@ -43,31 +43,3 @@ class UserTokenRateThrottle(DRFUserRateThrottle):
Tight rate throttle for the API token management endpoint; rotations should be rare.
"""
scope = 'user_token'
-
-
-class UserRateThrottle(DRFUserRateThrottle):
- """
- Uses a higher rate limit for requests from the legacy frontend (www.heliumedu.com),
- which fans out more API calls per page load than the modern frontend. All other
- clients use the standard user rate.
-
- TODO: Remove this class and restore rest_framework.throttling.UserRateThrottle in
- DEFAULT_THROTTLE_CLASSES once the legacy frontend is retired.
- """
-
- def _is_legacy_frontend(self, origin):
- # Production legacy frontend
- if origin.startswith('https://www.') and origin.endswith('heliumedu.com'):
- return True
- # Local legacy frontend
- if origin.startswith('http://localhost:3000'):
- return True
- return False
-
- def allow_request(self, request, view):
- origin = request.META.get('HTTP_ORIGIN', '')
- if self._is_legacy_frontend(origin):
- self.scope = 'user_legacy'
- self.rate = self.get_rate()
- self.num_requests, self.duration = self.parse_rate(self.rate)
- return super().allow_request(request, view)
diff --git a/helium/feed/views/apis/externalcalendaraseventsviews.py b/helium/feed/views/apis/externalcalendaraseventsviews.py
index da543746a..5254e2b33 100644
--- a/helium/feed/views/apis/externalcalendaraseventsviews.py
+++ b/helium/feed/views/apis/externalcalendaraseventsviews.py
@@ -77,10 +77,6 @@ def list(self, request, *arg, **kwargs):
logger.warning(f"External Calendar {external_calendar.pk} is not a valid ICAL feed, disabled.")
# Re-assign sequential IDs to ensure uniqueness across all calendars.
- # TODO: Once the legacy frontend (frontend-legacy) is shut down, replace this with
- # stable IDs derived from each event's ICS UID so the frontend can reliably
- # deduplicate the same event returned by different date-range queries. The new
- # frontend already works around this limitation with content-based deduplication.
for i, event in enumerate(events):
event.id = i
diff --git a/helium/importexport/services/importservice.py b/helium/importexport/services/importservice.py
index 69fbc2703..5afa8fe80 100644
--- a/helium/importexport/services/importservice.py
+++ b/helium/importexport/services/importservice.py
@@ -400,6 +400,11 @@ def _import_homework(homework, course_remap, category_remap, material_remap, use
def _import_reminders(reminders, user, event_remap, homework_remap, course_remap):
for reminder in reminders:
+ # Forward-migrate deprecated reminder types (POPUP=0, TEXT=2) to PUSH so legacy exports
+ # keep importing cleanly.
+ if reminder.get('type') in (0, 2):
+ reminder['type'] = enums.PUSH
+
reminder['homework'] = _resolve_parent(
homework_remap, reminder.get('homework'), 'reminders', 'homework') \
if reminder.get('homework') else None
diff --git a/helium/importexport/tests/views/apis/testcaseimportexportviews.py b/helium/importexport/tests/views/apis/testcaseimportexportviews.py
index 105169e51..2e7c0f3fa 100644
--- a/helium/importexport/tests/views/apis/testcaseimportexportviews.py
+++ b/helium/importexport/tests/views/apis/testcaseimportexportviews.py
@@ -226,14 +226,14 @@ def test_import_success(self, mock_validate_url):
reminderhelper.verify_reminder_matches_data(self, reminders[2], {'id': 1, 'title': 'Test Homework Reminder',
'message': 'You need to do something now.',
'start_of_range': '2017-05-08T15:45:00Z',
- 'offset': 15, 'offset_type': 0, 'type': 2,
+ 'offset': 15, 'offset_type': 0, 'type': 3,
'sent': False, 'dismissed': False,
'homework': homework[0].pk,
'event': None, 'user': user.pk})
reminderhelper.verify_reminder_matches_data(self, reminders[3], {'id': 3, 'title': 'Test Homework Reminder',
'message': 'You need to do something now.',
'start_of_range': '2017-05-08T15:45Z',
- 'offset': 15, 'offset_type': 0, 'type': 2,
+ 'offset': 15, 'offset_type': 0, 'type': 3,
'sent': False, 'dismissed': False,
'homework': homework[2].pk,
'event': None, 'user': user.pk})
diff --git a/helium/planner/migrations/0054_alter_reminder_type.py b/helium/planner/migrations/0054_alter_reminder_type.py
new file mode 100644
index 000000000..582e26eee
--- /dev/null
+++ b/helium/planner/migrations/0054_alter_reminder_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.14 on 2026-05-13 14:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('planner', '0053_alter_category_weight_alter_event_priority_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='reminder',
+ name='type',
+ field=models.PositiveIntegerField(choices=[(1, 'Email'), (3, 'Push')], default=3, help_text='The notification channel for this reminder.'),
+ ),
+ ]
diff --git a/helium/planner/models/reminder.py b/helium/planner/models/reminder.py
index 36af8c73a..fa248a92d 100644
--- a/helium/planner/models/reminder.py
+++ b/helium/planner/models/reminder.py
@@ -40,7 +40,7 @@ class Reminder(BaseModel):
choices=enums.REMINDER_OFFSET_TYPE_CHOICES, default=enums.MINUTES)
type = models.PositiveIntegerField(help_text='The notification channel for this reminder.',
- choices=enums.REMINDER_TYPE_CHOICES, default=enums.POPUP)
+ choices=enums.REMINDER_TYPE_CHOICES, default=enums.PUSH)
sent = models.BooleanField(help_text='Whether the reminder has been sent.', default=False)
diff --git a/helium/planner/serializers/materialserializer.py b/helium/planner/serializers/materialserializer.py
index 4647e5606..c67764c22 100644
--- a/helium/planner/serializers/materialserializer.py
+++ b/helium/planner/serializers/materialserializer.py
@@ -23,4 +23,4 @@ class Meta:
fields = (
'id', 'title', 'status', 'condition', 'website', 'price', 'details', 'material_group',
'courses', 'notes',)
- read_only_fields = ('notes',)
+ read_only_fields = ('material_group', 'notes',)
diff --git a/helium/planner/services/reminderservice.py b/helium/planner/services/reminderservice.py
index 0db2bbb61..911dc41f9 100644
--- a/helium/planner/services/reminderservice.py
+++ b/helium/planner/services/reminderservice.py
@@ -9,7 +9,7 @@
from django.utils import timezone
from helium.common import enums
-from helium.common.tasks import send_text, send_pushes
+from helium.common.tasks import send_pushes
from helium.common.utils.commonutils import format_short_time
from helium.common.utils import metricutils, taskutils
from helium.planner.models import Reminder
@@ -295,47 +295,6 @@ def process_email_reminders():
timezone.deactivate()
-def process_text_reminders():
- for reminder in (Reminder.objects
- .with_type(enums.TEXT)
- .unsent()
- .for_today()
- .filter(course__isnull=True)
- .select_related('user', 'user__settings', 'user__profile', 'homework', 'homework__course', 'event')
- .iterator()):
- user = reminder.get_user()
-
- timezone.activate(ZoneInfo(user.settings.time_zone))
-
- try:
- if user.profile.phone and user.profile.phone_verified:
- subject = get_subject(reminder)
- message = f'({subject}) {_push_body(reminder)}'
-
- if not subject:
- logger.warning(f'Reminder {reminder.pk} was not processed, as it appears to be orphaned')
- else:
- logger.info(f'Sending text reminder {reminder.pk} for user {user.pk}')
-
- metricutils.increment('task', user=user, extra_tags=['name:reminder.queue.text'])
-
- taskutils.safe_apply_async(send_text,
- args=(user.profile.phone, message),
- priority=settings.CELERY_PRIORITY_HIGH,
- )
- else:
- logger.warning(
- f'Reminder {reminder.pk} was not processed, as the phone and carrier are no longer set for user {user.pk}')
-
- if not Reminder.objects.filter(pk=reminder.pk, sent=False).update(sent=True):
- continue
- reminder.sent = True
- except Exception:
- logger.error("An error occurred processing text reminder.", exc_info=True)
-
- timezone.deactivate()
-
-
def process_push_reminders(mark_sent_only=False):
for reminder in (Reminder.objects
.with_type(enums.PUSH)
diff --git a/helium/planner/tasks.py b/helium/planner/tasks.py
index 29f2107b9..456841133 100644
--- a/helium/planner/tasks.py
+++ b/helium/planner/tasks.py
@@ -174,20 +174,6 @@ def email_reminders(self):
metricutils.task_stop(metrics)
-@app.task(bind=True)
-def text_reminders(self):
- published_at_ms = metricutils.get_published_at_ms(self)
- metrics = metricutils.task_start("reminder.text.process", priority="high", published_at_ms=published_at_ms)
-
- if settings.DISABLE_TEXTS:
- logger.warning('Texts disabled. Text reminders not being sent.')
- metricutils.task_stop(metrics, value=0)
- return
-
- reminderservice.process_text_reminders()
- metricutils.task_stop(metrics)
-
-
@app.task(bind=True)
def push_reminders(self):
published_at_ms = metricutils.get_published_at_ms(self)
@@ -303,8 +289,6 @@ def send_email_reminder(self, email, subject, reminder_id, calendar_item_id, cal
manually_triggerable=False)
register_periodic(push_reminders, 60,
manually_triggerable=False)
-register_periodic(text_reminders, 60,
- manually_triggerable=False)
register_periodic(reminder_watchdog, settings.REMINDER_WATCHDOG_FREQUENCY_SEC,
priority=settings.CELERY_PRIORITY_LOW,
description="Heal orphaned repeating reminders")
diff --git a/helium/planner/tests/services/testcasereminderservice.py b/helium/planner/tests/services/testcasereminderservice.py
index 259f8fb22..0e6890359 100644
--- a/helium/planner/tests/services/testcasereminderservice.py
+++ b/helium/planner/tests/services/testcasereminderservice.py
@@ -55,61 +55,6 @@ def test_process_email_reminders(self, mock_send_multipart_email):
self.assertTrue(reminder2.sent)
self.assertFalse(reminder3.sent)
- @mock.patch('helium.common.tasks.send_sms')
- def test_process_text_reminders(self, mock_send_sms):
- # GIVEN
- user = userhelper.given_a_user_exists()
- user.profile.phone = '5555555'
- user.profile.phone_verified = True
- user.profile.save()
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group,
- start_date=datetime.date.today() - datetime.timedelta(days=7),
- end_date=datetime.date.today() + datetime.timedelta(days=30))
- homework = homeworkhelper.given_homework_exists(course,
- start=timezone.now() + datetime.timedelta(minutes=settings.REMINDER_SEND_WINDOW_MINUTES),
- end=timezone.now() + datetime.timedelta(minutes=10))
- event1 = eventhelper.given_event_exists(user,
- start=timezone.now() + datetime.timedelta(minutes=settings.REMINDER_SEND_WINDOW_MINUTES),
- end=timezone.now() + datetime.timedelta(minutes=10))
- event2 = eventhelper.given_event_exists(user,
- start=datetime.datetime.now().replace(
- tzinfo=ZoneInfo(user.settings.time_zone)) + datetime.timedelta(
- days=1),
- end=datetime.datetime.now().replace(
- tzinfo=ZoneInfo(user.settings.time_zone)) + datetime.timedelta(
- days=1, hours=1))
- reminder1 = reminderhelper.given_reminder_exists(user, event=event1)
- reminder2 = reminderhelper.given_reminder_exists(user, homework=homework)
- # Course text reminder in the send window — must be excluded (text is legacy; courses are email+push only)
- course_text_reminder = Reminder(
- title='Course reminder', message='Class soon',
- start_of_range=timezone.now() - datetime.timedelta(minutes=1),
- offset=15, offset_type=enums.MINUTES,
- type=enums.TEXT, sent=False, dismissed=False,
- course=course, user=user,
- )
- Reminder.objects.bulk_create([course_text_reminder])
- course_text_reminder = Reminder.objects.get(course=course, sent=False)
- # This reminder is ignored, as we're not yet in its send window
- reminder3 = reminderhelper.given_reminder_exists(user, type=enums.EMAIL, event=event2)
- # Sent reminders are ignored
- reminderhelper.given_reminder_exists(user, sent=True, event=event1)
-
- # WHEN
- reminderservice.process_text_reminders()
-
- # THEN — only homework and event reminders are texted; course text reminder is excluded
- self.assertEqual(mock_send_sms.call_count, 2)
- reminder1.refresh_from_db()
- reminder2.refresh_from_db()
- reminder3.refresh_from_db()
- course_text_reminder.refresh_from_db()
- self.assertTrue(reminder1.sent)
- self.assertTrue(reminder2.sent)
- self.assertFalse(reminder3.sent)
- self.assertFalse(course_text_reminder.sent)
-
@mock.patch('helium.common.tasks.send_notifications')
def test_process_push_reminders(self, mock_send_notifications):
# GIVEN
@@ -191,46 +136,6 @@ def test_process_email_reminders_inactive_user(self, mock_send_multipart_email):
reminder.refresh_from_db()
self.assertTrue(reminder.sent)
- @mock.patch('helium.common.tasks.send_sms')
- def test_process_text_reminders_no_phone(self, mock_send_sms):
- # GIVEN
- user = userhelper.given_a_user_exists()
- # User has no phone set (default)
- event = eventhelper.given_event_exists(user,
- start=timezone.now() + datetime.timedelta(minutes=settings.REMINDER_SEND_WINDOW_MINUTES),
- end=timezone.now() + datetime.timedelta(minutes=10))
- reminder = reminderhelper.given_reminder_exists(user, type=enums.TEXT, event=event)
-
- # WHEN
- reminderservice.process_text_reminders()
-
- # THEN
- # No SMS sent when user has no phone
- mock_send_sms.assert_not_called()
- reminder.refresh_from_db()
- self.assertTrue(reminder.sent)
-
- @mock.patch('helium.common.tasks.send_sms')
- def test_process_text_reminders_phone_not_verified(self, mock_send_sms):
- # GIVEN
- user = userhelper.given_a_user_exists()
- user.profile.phone = '5555555'
- user.profile.phone_verified = False # Phone not verified
- user.profile.save()
- event = eventhelper.given_event_exists(user,
- start=timezone.now() + datetime.timedelta(minutes=settings.REMINDER_SEND_WINDOW_MINUTES),
- end=timezone.now() + datetime.timedelta(minutes=10))
- reminder = reminderhelper.given_reminder_exists(user, type=enums.TEXT, event=event)
-
- # WHEN
- reminderservice.process_text_reminders()
-
- # THEN
- # No SMS sent when phone not verified
- mock_send_sms.assert_not_called()
- reminder.refresh_from_db()
- self.assertTrue(reminder.sent)
-
@mock.patch('helium.common.tasks.send_notifications')
def test_process_push_reminders_no_push_tokens(self, mock_send_notifications):
# GIVEN
diff --git a/helium/planner/tests/testcasetasks.py b/helium/planner/tests/testcasetasks.py
index 30a4fec9e..068df6341 100644
--- a/helium/planner/tests/testcasetasks.py
+++ b/helium/planner/tests/testcasetasks.py
@@ -10,7 +10,7 @@
from helium.auth.tests.helpers import userhelper
from helium.common import enums
from helium.planner.tasks import (
- email_reminders, text_reminders, push_reminders,
+ email_reminders, push_reminders,
recalculate_course_grades_for_course_group,
recalculate_category_grades_for_course, adjust_reminder_times, send_email_reminder
)
@@ -28,14 +28,6 @@ def test_email_reminders(self, mock_process_email_reminders):
# THEN
mock_process_email_reminders.assert_called_once()
- @mock.patch('helium.planner.services.reminderservice.process_text_reminders')
- def test_text_reminders(self, mock_process_text_reminders):
- # WHEN
- text_reminders()
-
- # THEN
- mock_process_text_reminders.assert_called_once()
-
@mock.patch('helium.planner.services.reminderservice.process_push_reminders')
def test_push_reminders(self, mock_process_push_reminders):
# WHEN
diff --git a/helium/planner/tests/views/apis/testcasecoursescheduleresourceviews.py b/helium/planner/tests/views/apis/testcasecoursescheduleresourceviews.py
deleted file mode 100644
index 7ce87d3f7..000000000
--- a/helium/planner/tests/views/apis/testcasecoursescheduleresourceviews.py
+++ /dev/null
@@ -1,262 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import datetime
-
-from unittest import mock
-
-from django.urls import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from helium.auth.tests.helpers import userhelper
-from helium.common.tests.test import CacheTestCase
-from helium.planner.models import CourseSchedule
-from helium.planner.tests.helpers import coursegrouphelper, coursehelper, courseschedulehelper, attachmenthelper
-
-
-class TestCaseCourseScheduleResourceViews(APITestCase, CacheTestCase):
- def test_courseschedule_event_login_required(self):
- # GIVEN
- userhelper.given_a_user_exists()
-
- # WHEN
- responses = [
- self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': '9999', 'course': '9999'}))
- ]
-
- # THEN
- for response in responses:
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
- def test_error_on_object_owned_by_another_user(self):
- # GIVEN
- user1 = userhelper.given_a_user_exists()
- userhelper.given_a_user_exists_and_is_authenticated(self.client, username='user2', email='test2@email.com')
- course_group = coursegrouphelper.given_course_group_exists(user1)
- course = coursehelper.given_course_exists(course_group)
- course_schedule = courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- responses = [
- self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
- ]
-
- # THEN
- self.assertTrue(
- CourseSchedule.objects.filter(pk=course_schedule.pk, course__course_group__user_id=user1.pk).exists())
- for response in responses:
- if isinstance(response.data, list):
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 0)
- else:
- self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
- def test_get_course_schedule_as_events(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- attachmenthelper.given_attachment_exists(user, course=course)
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
-
- # THEN
- self.assertEqual(len(response.data), 53)
- self.assertEqual(response.data[0]['title'], course.title)
- self.assertEqual(response.data[0]['start'], '2017-01-06T10:30:00Z')
- self.assertEqual(response.data[0]['end'], '2017-01-06T13:00:00Z')
- self.assertEqual(response.data[0]['all_day'], False)
- self.assertEqual(response.data[0]['show_end_time'], True)
- self.assertEqual(response.data[0]['comments'],
- f'{course.title} in {course.room}')
-
- # May 8, 2017 is during PDT (UTC-7), so 2:30 AM local = 09:30 UTC
- self.assertEqual(response.data[-1]['start'], '2017-05-08T09:30:00Z')
- self.assertEqual(response.data[-1]['end'], '2017-05-08T10:00:00Z')
-
- def test_get_course_schedule_cached(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- attachmenthelper.given_attachment_exists(user, course=course)
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response_db = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
- with mock.patch('helium.planner.services.coursescheduleservice._create_events_from_course_schedules') as \
- _create_events_from_course_schedules_mock:
- response_cached = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
-
- # THEN
- self.assertEqual(_create_events_from_course_schedules_mock.call_count, 0)
- self.assertEqual(len(response_cached.data), len(response_db.data))
- cached_event = [e for e in response_cached.data if e['id'] == response_db.data[0]['id']]
- self.assertTrue(len(cached_event) == 1)
- self.assertEqual(cached_event[0]['title'], response_db.data[0]['title'])
- self.assertEqual(cached_event[0]['start'], response_db.data[0]['start'])
- self.assertEqual(cached_event[0]['end'], response_db.data[0]['end'])
- self.assertEqual(cached_event[0]['all_day'], response_db.data[0]['all_day'])
- self.assertEqual(cached_event[0]['show_end_time'], response_db.data[0]['show_end_time'])
- self.assertEqual(cached_event[0]['comments'], response_db.data[0]['comments'])
- self.assertEqual(cached_event[0]['comments'], response_db.data[0]['comments'])
-
- @mock.patch('helium.planner.services.coursescheduleservice.cache.get_many')
- def test_course_schedule_cache_cleared_when_course_changed(self, get_many_mock):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- course_schedule = courseschedulehelper.given_course_schedule_exists(course)
- response = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(get_many_mock.call_count, 0)
-
- # WHEN
- course.start_date = datetime.date(2017, 1, 2)
- course.save()
-
- course_schedule.mon_start_time = datetime.time(2, 00, 0)
- course_schedule.save()
-
- response = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(get_many_mock.call_count, 0)
-
- def test_get_user_course_schedule_events_login_required(self):
- # GIVEN
- userhelper.given_a_user_exists()
-
- # WHEN
- response = self.client.get(reverse('planner_courseschedules_events'))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
- def test_get_user_course_schedule_as_events(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- attachmenthelper.given_attachment_exists(user, course=course)
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response = self.client.get(reverse('planner_courseschedules_events'))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 53)
- self.assertEqual(response.data[0]['title'], course.title)
-
- def test_get_user_course_schedule_as_events_shown_on_calendar_filter(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group_visible = coursegrouphelper.given_course_group_exists(user, shown_on_calendar=True)
- course_group_hidden = coursegrouphelper.given_course_group_exists(user, title='Hidden Group',
- shown_on_calendar=False)
- course_visible = coursehelper.given_course_exists(course_group_visible, title='Visible Course')
- course_hidden = coursehelper.given_course_exists(course_group_hidden, title='Hidden Course')
- courseschedulehelper.given_course_schedule_exists(course_visible)
- courseschedulehelper.given_course_schedule_exists(course_hidden)
-
- # WHEN
- response = self.client.get(reverse('planner_courseschedules_events') + '?shown_on_calendar=true')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- # Only visible course events should be returned
- for event in response.data:
- self.assertEqual(event['title'], 'Visible Course')
-
- def test_get_user_course_schedule_as_events_with_date_range(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response = self.client.get(
- reverse('planner_courseschedules_events') + '?from=2017-01-01T00:00:00Z&to=2017-01-15T00:00:00Z')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- # Should have fewer events due to date range filter
- self.assertLess(len(response.data), 53)
-
- def test_get_user_course_schedule_as_events_with_search(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course1 = coursehelper.given_course_exists(course_group, title='Math 101')
- course2 = coursehelper.given_course_exists(course_group, title='English 201')
- courseschedulehelper.given_course_schedule_exists(course1)
- courseschedulehelper.given_course_schedule_exists(course2)
-
- # WHEN
- response = self.client.get(reverse('planner_courseschedules_events') + '?search=math')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- for event in response.data:
- self.assertEqual(event['title'], 'Math 101')
-
- def test_get_course_schedule_as_events_not_found_for_invalid_course(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
-
- # WHEN
- response = self.client.get(reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': 9999}))
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
- def test_get_course_schedule_as_events_with_date_range(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group)
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response = self.client.get(
- reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}) +
- '?from=2017-01-01T00:00:00Z&to=2017-01-15T00:00:00Z')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertLess(len(response.data), 53)
-
- def test_get_course_schedule_as_events_with_search(self):
- # GIVEN
- user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
- course_group = coursegrouphelper.given_course_group_exists(user)
- course = coursehelper.given_course_exists(course_group, title='Biology 301')
- courseschedulehelper.given_course_schedule_exists(course)
-
- # WHEN
- response = self.client.get(
- reverse('planner_resource_courses_courseschedules_events',
- kwargs={'course_group': course_group.pk, 'course': course.pk}) + '?search=bio')
-
- # THEN
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertGreater(len(response.data), 0)
- for event in response.data:
- self.assertEqual(event['title'], 'Biology 301')
diff --git a/helium/planner/tests/views/apis/testcasecoursescheduleviews.py b/helium/planner/tests/views/apis/testcasecoursescheduleviews.py
index b1ce3c9b2..d515ab454 100644
--- a/helium/planner/tests/views/apis/testcasecoursescheduleviews.py
+++ b/helium/planner/tests/views/apis/testcasecoursescheduleviews.py
@@ -194,7 +194,7 @@ def test_update_course_schedule_by_id(self):
course_schedule.refresh_from_db()
courseschedulehelper.verify_course_schedule_matches(self, course_schedule, response.data)
- def test_delete_course_schedule_by_id(self):
+ def test_delete_course_schedule_by_id_not_allowed(self):
# GIVEN
user = userhelper.given_a_user_exists_and_is_authenticated(self.client)
course_group = coursegrouphelper.given_course_group_exists(user)
@@ -207,8 +207,7 @@ def test_delete_course_schedule_by_id(self):
'pk': course_schedule.pk}))
# THEN
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertIn('Deleting a course schedule is not allowed', response.data['detail'])
+ self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertTrue(CourseSchedule.objects.filter(pk=course_schedule.pk).exists())
self.assertEqual(CourseSchedule.objects.count(), 1)
diff --git a/helium/planner/tests/views/apis/testcasematerialviews.py b/helium/planner/tests/views/apis/testcasematerialviews.py
index 6e388c250..f049ea730 100644
--- a/helium/planner/tests/views/apis/testcasematerialviews.py
+++ b/helium/planner/tests/views/apis/testcasematerialviews.py
@@ -115,7 +115,6 @@ def test_update_material_by_id(self):
course1 = coursehelper.given_course_exists(course_group)
course2 = coursehelper.given_course_exists(course_group)
material_group1 = materialgrouphelper.given_material_group_exists(user)
- material_group2 = materialgrouphelper.given_material_group_exists(user)
material = materialhelper.given_material_exists(material_group1, courses=[course1])
self.assertEqual(material.title, '📘 Test Material')
@@ -127,7 +126,6 @@ def test_update_material_by_id(self):
'website': 'http://www.some-material.com',
'price': '500.27',
'details': 'N/A',
- 'material_group': material_group2.pk,
'courses': [course2.pk]
}
response = self.client.put(
@@ -198,7 +196,7 @@ def test_related_field_owned_by_another_user_forbidden(self):
material = materialhelper.given_material_exists(material_group1, courses=[course1])
# WHEN
- responses = [
+ forbidden_responses = [
self.client.post(
reverse('planner_materialgroups_materials_list', kwargs={'material_group': material_group1.pk}),
json.dumps({'courses': [course2.pk]}),
@@ -207,21 +205,34 @@ def test_related_field_owned_by_another_user_forbidden(self):
reverse('planner_materialgroups_materials_list', kwargs={'material_group': material_group2.pk}),
json.dumps({}),
content_type='application/json'),
- self.client.put(
- reverse('planner_materialgroups_materials_detail',
- kwargs={'material_group': material_group1.pk, 'pk': material.pk}),
- json.dumps({'material_group': material_group2.pk}),
- content_type='application/json'),
self.client.put(
reverse('planner_materialgroups_materials_detail',
kwargs={'material_group': material_group1.pk, 'pk': material.pk}),
json.dumps({'courses': [course2.pk]}),
content_type='application/json')
]
+ # Sending material_group in a write is silently ignored (read-only field); the material stays put.
+ move_attempt = self.client.put(
+ reverse('planner_materialgroups_materials_detail',
+ kwargs={'material_group': material_group1.pk, 'pk': material.pk}),
+ json.dumps({
+ 'title': material.title,
+ 'status': material.status,
+ 'condition': material.condition,
+ 'website': material.website,
+ 'price': material.price,
+ 'details': material.details,
+ 'courses': [course1.pk],
+ 'material_group': material_group2.pk,
+ }),
+ content_type='application/json')
# THEN
- for response in responses:
+ for response in forbidden_responses:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ self.assertEqual(move_attempt.status_code, status.HTTP_200_OK)
+ material.refresh_from_db()
+ self.assertEqual(material.material_group_id, material_group1.pk)
def test_no_access_object_owned_by_another_user(self):
# GIVEN
diff --git a/helium/planner/tests/views/apis/testcasereminderviews.py b/helium/planner/tests/views/apis/testcasereminderviews.py
index 2f580ae93..8c1cd6525 100644
--- a/helium/planner/tests/views/apis/testcasereminderviews.py
+++ b/helium/planner/tests/views/apis/testcasereminderviews.py
@@ -29,7 +29,7 @@ def test_reminder_login_required(self):
self.client.get(reverse('planner_reminders_list')),
self.client.post(reverse('planner_reminders_list')),
self.client.get(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
- self.client.put(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
+ self.client.patch(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
self.client.delete(reverse('planner_reminders_detail', kwargs={'pk': '9999'}))
]
@@ -212,10 +212,10 @@ def test_update_reminder_by_id(self):
'offset_type': enums.HOURS,
'type': enums.POPUP
}
- response = self.client.put(reverse('planner_reminders_detail',
- kwargs={'pk': reminder.pk}),
- json.dumps(data),
- content_type='application/json')
+ response = self.client.patch(reverse('planner_reminders_detail',
+ kwargs={'pk': reminder.pk}),
+ json.dumps(data),
+ content_type='application/json')
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -440,12 +440,12 @@ def test_related_field_owned_by_another_user_forbidden(self):
self.client.post(reverse('planner_reminders_list'),
json.dumps({'homework': homework2.pk}),
content_type='application/json'),
- self.client.put(
+ self.client.patch(
reverse('planner_reminders_detail',
kwargs={'pk': reminder.pk}),
json.dumps({'event': event2.pk}),
content_type='application/json'),
- self.client.put(
+ self.client.patch(
reverse('planner_reminders_detail',
kwargs={'pk': reminder.pk}),
json.dumps({'homework': homework2.pk}),
@@ -472,7 +472,7 @@ def test_no_access_object_owned_by_another_user(self):
self.client.get(reverse('planner_reminders_list') + f'?event={event.pk}'),
self.client.get(reverse('planner_reminders_list') + f'?homework={homework.pk}'),
self.client.get(reverse('planner_reminders_detail', kwargs={'pk': event_reminder.pk})),
- self.client.put(reverse('planner_reminders_detail', kwargs={'pk': event_reminder.pk})),
+ self.client.patch(reverse('planner_reminders_detail', kwargs={'pk': event_reminder.pk})),
self.client.delete(reverse('planner_reminders_detail', kwargs={'pk': event_reminder.pk}))
]
@@ -496,8 +496,8 @@ def test_update_read_only_field_does_nothing(self):
data = {
'start_of_range': '2014-05-08T12:00:00Z'
}
- response = self.client.put(reverse('planner_reminders_detail', kwargs={'pk': reminder.pk}),
- json.dumps(data), content_type='application/json')
+ response = self.client.patch(reverse('planner_reminders_detail', kwargs={'pk': reminder.pk}),
+ json.dumps(data), content_type='application/json')
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -531,8 +531,8 @@ def test_update_bad_data(self):
data = {
'offset': 'asdf'
}
- response = self.client.put(reverse('planner_reminders_detail', kwargs={'pk': reminder.pk}),
- json.dumps(data), content_type='application/json')
+ response = self.client.patch(reverse('planner_reminders_detail', kwargs={'pk': reminder.pk}),
+ json.dumps(data), content_type='application/json')
# THEN
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -547,7 +547,7 @@ def test_not_found(self):
# WHEN
responses = [
self.client.get(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
- self.client.put(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
+ self.client.patch(reverse('planner_reminders_detail', kwargs={'pk': '9999'})),
self.client.delete(reverse('planner_reminders_detail', kwargs={'pk': '9999'}))
]
diff --git a/helium/planner/urls.py b/helium/planner/urls.py
index 4955515b6..545d96084 100644
--- a/helium/planner/urls.py
+++ b/helium/planner/urls.py
@@ -8,8 +8,6 @@
CourseGroupCourseCategoriesApiDetailView
from helium.planner.views.apis.coursegroupviews import CourseGroupsApiDetailView
from helium.planner.views.apis.coursegroupviews import CourseGroupsApiListView
-from helium.planner.views.apis.coursescheduleaseventsviews import CourseScheduleAsEventsListView, \
- UserCourseScheduleAsEventsListView
from helium.planner.views.apis.coursescheduleviews import CourseGroupCourseCourseSchedulesApiDetailView, \
UserCourseSchedulesApiListView
from helium.planner.views.apis.coursescheduleviews import CourseGroupCourseCourseSchedulesApiListView
@@ -33,10 +31,6 @@
##############################
# Resource shortcuts
path('planner/grades/', GradesApiResourceView.as_view(), name='planner_resource_grades'),
- path('planner/coursegroups//courses//courseschedules/events/',
- CourseScheduleAsEventsListView.as_view(), name='planner_resource_courses_courseschedules_events'),
- path('planner/courseschedules/events/', UserCourseScheduleAsEventsListView.as_view(),
- name='planner_courseschedules_events'),
# CourseGroup
path('planner/coursegroups/', CourseGroupsApiListView.as_view(), name='planner_coursegroups_list'),
diff --git a/helium/planner/views/apis/coursescheduleaseventsviews.py b/helium/planner/views/apis/coursescheduleaseventsviews.py
deleted file mode 100644
index 4c22c3b3f..000000000
--- a/helium/planner/views/apis/coursescheduleaseventsviews.py
+++ /dev/null
@@ -1,129 +0,0 @@
-__copyright__ = "Copyright (c) 2025 Helium Edu"
-__license__ = "MIT"
-
-import logging
-from datetime import datetime, timezone
-
-from dateutil import parser
-from drf_spectacular.utils import extend_schema, OpenApiParameter
-from rest_framework.exceptions import NotFound
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-
-from helium.planner.models import Course, CourseSchedule, Attachment
-from helium.planner.serializers.attachmentserializer import AttachmentSerializer
-from helium.planner.serializers.eventserializer import EventSerializer
-from helium.planner.services import coursescheduleservice
-from helium.planner.views.base import HeliumCalendarItemAPIView
-
-logger = logging.getLogger(__name__)
-
-# Deprecated: All events in this view are deprecated, the frontend handles this through native recurring event support
-# built from CourseSchedules. These routes are only maintained for the legacy frontend, then can be removed.
-
-@extend_schema(
- tags=['planner.courseschedule.event'],
- deprecated=True,
- exclude=True,
-)
-class UserCourseScheduleAsEventsListView(HeliumCalendarItemAPIView):
- serializer_class = EventSerializer
- permission_classes = (IsAuthenticated,)
-
- @extend_schema(
- parameters=[
- OpenApiParameter(name='from', type=datetime),
- OpenApiParameter(name='to', type=datetime),
- OpenApiParameter(name='search', description='A search term.', type=str),
- ]
- )
- def get(self, request, *args, **kwargs):
- """
- Return all schedules that should be shown on the calendar for the authenticated user as a list of CourseSchedule Event instances.
- """
- return super().get(request, *args, **kwargs)
-
- def list(self, request, *arg, **kwargs):
- user = self.request.user
- courses = (Course.objects
- .for_user(user.pk)
- .select_related('course_group', 'course_group__user', 'course_group__user__settings')
- .prefetch_related('schedules'))
- if 'shown_on_calendar' in request.query_params:
- courses = courses.filter(course_group__shown_on_calendar=request.query_params['shown_on_calendar'].lower() == 'true')
-
- _from = parser.parse(request.query_params["from"]).astimezone(timezone.utc) \
- if "from" in request.query_params else None
- to = parser.parse(request.query_params["to"]).astimezone(timezone.utc) \
- if "to" in request.query_params else None
- search = request.query_params["search"].lower() if "search" in request.query_params else None
-
- events = []
- for course in courses:
- events += coursescheduleservice.course_schedules_to_events(course,
- course.schedules.all(),
- _from, to, search)
-
- serializer = EventSerializer(events, many=True)
-
- attachments = list(Attachment.objects.filter(course__in=courses))
- if len(attachments) > 0:
- attachments_serializer = AttachmentSerializer(attachments, many=True)
- for event in serializer.data:
- event['attachments'] = attachments_serializer.data
-
- return Response(serializer.data)
-
-
-@extend_schema(
- tags=['planner.courseschedule.event'],
- deprecated=True,
- exclude=True,
-)
-class CourseScheduleAsEventsListView(HeliumCalendarItemAPIView):
- serializer_class = EventSerializer
- permission_classes = (IsAuthenticated,)
-
- @extend_schema(
- parameters=[
- OpenApiParameter(name='from', type=datetime),
- OpenApiParameter(name='to', type=datetime),
- OpenApiParameter(name='search', description='A search term.', type=str),
- ]
- )
- def get(self, request, *args, **kwargs):
- """
- Return all schedules for the given course as a list of CourseSchedule Event instances.
- """
- return super().get(request, *args, **kwargs)
-
- def list(self, request, *arg, **kwargs):
- user = self.request.user
- try:
- course = (Course.objects
- .for_user(user.pk)
- .select_related('course_group', 'course_group__user', 'course_group__user__settings')
- .get(pk=self.kwargs['course']))
- course_schedules = CourseSchedule.objects.for_user(user.pk).for_course(course.pk)
-
- _from = parser.parse(request.query_params["from"]).astimezone(timezone.utc) \
- if "from" in request.query_params else None
- to = parser.parse(request.query_params["to"]).astimezone(timezone.utc) \
- if "to" in request.query_params else None
- search = request.query_params["search"].lower() if "search" in request.query_params else None
-
- events = coursescheduleservice.course_schedules_to_events(course, course_schedules, _from, to, search)
-
- serializer = EventSerializer(events, many=True)
-
- attachments = list(course.attachments.all())
- if len(attachments) > 0:
- attachments_serializer = AttachmentSerializer(attachments, many=True)
- for event in serializer.data:
- event['attachments'] = attachments_serializer.data
-
- return Response(serializer.data)
- except Course.DoesNotExist:
- raise NotFound('No Course matches the given query.')
- except CourseSchedule.DoesNotExist:
- raise NotFound('No CourseSchedule matches the given query.')
diff --git a/helium/planner/views/apis/coursescheduleviews.py b/helium/planner/views/apis/coursescheduleviews.py
index 47f58ddfb..7152b40b4 100644
--- a/helium/planner/views/apis/coursescheduleviews.py
+++ b/helium/planner/views/apis/coursescheduleviews.py
@@ -4,11 +4,8 @@
import logging
from drf_spectacular.utils import extend_schema, OpenApiExample
-from rest_framework import status
-from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, \
- CreateModelMixin
+from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin, CreateModelMixin
from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
from helium.common.permissions import IsOwner
from helium.common.views.base import HeliumAPIView
@@ -159,8 +156,7 @@ def post(self, request, *args, **kwargs):
@extend_schema(
tags=['planner.courseschedule']
)
-class CourseGroupCourseCourseSchedulesApiDetailView(HeliumAPIView, RetrieveModelMixin, UpdateModelMixin,
- DestroyModelMixin):
+class CourseGroupCourseCourseSchedulesApiDetailView(HeliumAPIView, RetrieveModelMixin, UpdateModelMixin):
serializer_class = CourseScheduleSerializer
permission_classes = (IsAuthenticated, IsOwner, IsCourseGroupOwner, IsCourseOwner)
@@ -190,13 +186,3 @@ def put(self, request, *args, **kwargs):
logger.info(f"CourseSchedule {kwargs['pk']} updated for user {request.user.pk}")
return response
-
- @extend_schema(deprecated=True, exclude=True)
- def delete(self, request, *args, **kwargs):
- """
- Delete the given course schedule instance.
- """
- return Response(
- {'detail': 'Deleting a course schedule is not allowed. Each course must have exactly one schedule.'},
- status=status.HTTP_400_BAD_REQUEST
- )
diff --git a/helium/planner/views/apis/materialviews.py b/helium/planner/views/apis/materialviews.py
index 0edc4907f..ba2d5cbe5 100644
--- a/helium/planner/views/apis/materialviews.py
+++ b/helium/planner/views/apis/materialviews.py
@@ -104,7 +104,7 @@ def post(self, request, *args, **kwargs):
response = self.create(request, *args, **kwargs)
logger.info(
- f"Material {response.data['id']} created in MaterialGroup {request.data['material_group']} for user {request.user.pk}")
+ f"Material {response.data['id']} created in MaterialGroup {kwargs['material_group']} for user {request.user.pk}")
return response
@@ -138,8 +138,6 @@ def put(self, request, *args, **kwargs):
"""
Update the given material instance.
"""
- if 'material_group' in request.data:
- permissions.check_material_group_permission(request.user.pk, request.data['material_group'])
courses = request.data.get('courses', [])
if courses:
for course_id in courses:
diff --git a/helium/planner/views/apis/reminderviews.py b/helium/planner/views/apis/reminderviews.py
index 16746ca38..507c2e121 100644
--- a/helium/planner/views/apis/reminderviews.py
+++ b/helium/planner/views/apis/reminderviews.py
@@ -149,9 +149,9 @@ def get(self, request, *args, **kwargs):
return response
@extend_schema(summary='Update a Reminder')
- def put(self, request, *args, **kwargs):
+ def patch(self, request, *args, **kwargs):
"""
- Update the given reminder instance.
+ Update the given reminder. Fields not supplied are left unchanged.
"""
if 'event' in request.data:
permissions.check_event_permission(request.user.pk, request.data['event'])
@@ -166,17 +166,6 @@ def put(self, request, *args, **kwargs):
return response
- @extend_schema(summary='Partially update a Reminder')
- def patch(self, request, *args, **kwargs):
- """
- Update only the given attributes of the given reminder instance.
- """
- response = self.partial_update(request, *args, **kwargs)
-
- logger.info(f"Reminder {kwargs['pk']} updated for user {request.user.pk}")
-
- return response
-
def perform_destroy(self, instance):
if instance.course_id:
Reminder.objects.filter(
diff --git a/requirements.txt b/requirements.txt
index 6a16775db..d4d80e926 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,6 @@ django_celery_results==2.6.0
drf-spectacular==0.29.0
celery[redis]==5.6.3
celery-redbeat==2.3.3
-twilio==9.10.9
firebase-admin==7.2.0
pyOpenSSL==26.1.0
Pillow==12.2.0