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