diff --git a/backend/pennmobile/settings/base_postgres.py b/backend/pennmobile/settings/base_postgres.py new file mode 100644 index 00000000..283b3573 --- /dev/null +++ b/backend/pennmobile/settings/base_postgres.py @@ -0,0 +1,202 @@ +""" +Django settings for pennmobile. +Generated by 'django-admin startproject' using Django 2.1.2. +For more information on this file, see +https://docs.djangoproject.com/en/2.1/topics/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.1/ref/settings/ +""" + +import os + +import dj_database_url + + +DOMAINS = os.environ.get("DOMAINS", "example.com").split(",") + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get("SECRET_KEY", "o7ql0!vuk0%rgrh9p2bihq#pege$qqlm@zo#8&t==%&za33m*2") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "pennmobile.admin.PennMobileAdminConfig", # "replaces django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "user.apps.UserConfig", + "dining.apps.DiningConfig", + "laundry.apps.LaundryConfig", + "penndata.apps.PenndataConfig", + "accounts.apps.AccountsConfig", + "identity.apps.IdentityConfig", + "analytics.apps.AnalyticsConfig", + "wrapped.apps.WrappedConfig", + "django_filters", + "debug_toolbar", + "gsr_booking", + "portal", + "options.apps.OptionsConfig", + "sublet", + "phonenumber_field", + "market", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", +] + +ROOT_URLCONF = "pennmobile.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["pennmobile/templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "pennmobile.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/2.1/ref/settings/#databases +DATABASES = { + "default": dj_database_url.config( + default="postgres://pennmobile:password@localhost:5432/pennmobile" + ), +} + + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# Password validation +# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + + +# Authentication Backends + +AUTHENTICATION_BACKENDS = [ + "accounts.backends.LabsUserBackend", + "django.contrib.auth.backends.ModelBackend", +] + +# Internationalization +# https://docs.djangoproject.com/en/2.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "America/New_York" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.1/howto/static-files/ + +STATIC_URL = "/assets/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") + + +# DLA Settings + +PLATFORM_ACCOUNTS = { + "REDIRECT_URI": os.environ.get("LABS_REDIRECT_URI", "http://localhost:8000/accounts/callback/"), + "CLIENT_ID": os.environ.get("CLIENT_ID", "clientid"), + "CLIENT_SECRET": os.environ.get("CLIENT_SECRET", "supersecretclientsecret"), + "PLATFORM_URL": os.environ.get("PLATFORM_URL", "https://platform.pennlabs.org"), + "CUSTOM_ADMIN": False, +} + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + "accounts.authentication.PlatformAuthentication", + ], +} + +# Redis for Celery & Caching +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/1") + +# Celery Settings (read automatically by celery.py) +CELERY_BROKER_URL = REDIS_URL +CELERY_TIMEZONE = TIME_ZONE + +# Laundry API URL +# LAUNDRY_URL = os.environ.get("LAUNDRY_URL", "http://suds.kite.upenn.edu") +LAUNDRY_URL = "https://api.alliancelslabs.com" +LAUNDRY_X_API_KEY = os.environ.get("LAUNDRY_X_API_KEY", None) +LAUNDRY_ALLIANCELS_API_KEY = os.environ.get("LAUNDRY_ALLIANCE_LS_KEY", None) +LAUNDRY_HEADERS = { + "x-api-key": LAUNDRY_X_API_KEY, + "alliancels-auth-token": LAUNDRY_ALLIANCELS_API_KEY, +} + +# Dining API Credentials +DINING_USERNAME = os.environ.get("DINING_USERNAME", None) +DINING_PASSWORD = os.environ.get("DINING_PASSWORD", None) +DINING_ID = os.environ.get("DINING_ID", None) +DINING_SECRET = os.environ.get("DINING_SECRET", None) + +LIBCAL_ID = os.environ.get("LIBCAL_ID", None) +LIBCAL_SECRET = os.environ.get("LIBCAL_SECRET", None) +WHARTON_TOKEN = os.environ.get("WHARTON_TOKEN", None) + +# Upload file storage +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", None) +AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", None) +AWS_STORAGE_BUCKET_NAME = "penn.mobile.portal" +AWS_QUERYSTRING_AUTH = False +AWS_S3_FILE_OVERWRITE = False +AWS_DEFAULT_ACL = "public-read" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.environ.get("SMTP_HOST", "") +EMAIL_USE_TLS = True +EMAIL_PORT = os.environ.get("SMTP_PORT", 587) +EMAIL_HOST_USER = os.environ.get("SMTP_USERNAME", "") +EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD", "") +DEFAULT_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", EMAIL_HOST_USER) diff --git a/backend/tests/wrapped/__init__.py b/backend/tests/wrapped/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/wrapped/test_models.py b/backend/tests/wrapped/test_models.py new file mode 100644 index 00000000..33360d2a --- /dev/null +++ b/backend/tests/wrapped/test_models.py @@ -0,0 +1,123 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.test import TestCase + +from wrapped.models import ( + GlobalStat, + GlobalStatKey, + GlobalStatPageField, + IndividualStat, + IndividualStatKey, + IndividualStatPageField, + Page, + Semester, +) + + +User = get_user_model() + + +# Will add more failing tests + + +class WrappedModelsTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test", password="test") + self.semester = Semester.objects.create(semester="2025fa") + self.semester2 = Semester.objects.create(semester="2025sp") + + self.ind_key = IndividualStatKey.objects.create(key="gsr_hours") + self.glob_key = GlobalStatKey.objects.create(key="total_gsr_hours") + + self.ind_stat = IndividualStat.objects.create( + user=self.user, + key=self.ind_key, + value="5", + semester=self.semester, + ) + self.glob_stat = GlobalStat.objects.create( + key=self.glob_key, + value="1000", + semester=self.semester, + ) + + self.page = Page.objects.create( + name="GSR_Page", + id=1, + template_path="wrapped/gsr_page.html", + duration=timedelta(minutes=1), + ) + self.page2 = Page.objects.create( + name="GSR_Page2", + id=2, + template_path="wrapped/gsr_page2.html", + duration=timedelta(minutes=1), + ) + self.semester.pages.add(self.page) + self.semester.pages.add(self.page2) + + # Through models for page fields + self.ind_field = IndividualStatPageField.objects.create( + individual_stat_key=self.ind_key, + page=self.page, + text_field_name="top", + ) + self.glob_field = GlobalStatPageField.objects.create( + global_stat_key=self.glob_key, + page=self.page, + text_field_name="bottom", + ) + + def test_str_methods(self): + self.assertEqual("GSR_Page", str(self.page)) + self.assertEqual("User: test -- gsr_hours-2025fa : 5", str(self.ind_stat)) + self.assertEqual("Global -- total_gsr_hours-2025fa : 1000", str(self.glob_stat)) + self.assertEqual("2025fa", str(self.semester)) + self.assertEqual("GSR_Page -> top : gsr_hours", str(self.ind_field)) + self.assertEqual("GSR_Page -> bottom : total_gsr_hours", str(self.glob_field)) + + def test_semester_pages(self): + self.assertEqual([self.page, self.page2], list(self.semester.pages.all())) + + def test_unique_together_individualstat(self): + with self.assertRaises(IntegrityError): + IndividualStat.objects.create( + user=self.user, + key=self.ind_key, + value="6", + semester=self.semester, + ) + + def test_unique_together_globalstat(self): + with self.assertRaises(IntegrityError): + GlobalStat.objects.create( + key=self.glob_key, + value="200", + semester=self.semester, + ) + + def test_page_fields(self): + self.assertEqual([self.ind_key], list(self.page.individual_stats.all())) + self.assertEqual([self.glob_key], list(self.page.global_stats.all())) + + def test_stat_page_fields(self): + self.assertEqual(self.ind_key, self.ind_field.individual_stat_key) + self.assertEqual(self.glob_key, self.glob_field.global_stat_key) + self.assertEqual(self.page, self.ind_field.page) + self.assertEqual(self.page, self.glob_field.page) + self.assertEqual("top", self.ind_field.text_field_name) + self.assertEqual("bottom", self.glob_field.text_field_name) + + def test_stat_page_get_value(self): + self.assertEqual("5", self.ind_field.get_value(self.user, self.semester)) + self.assertEqual("1000", self.glob_field.get_value(self.user, self.semester)) + + def updating_semester_non_duplicate(self): + self.semester.semester = "2025T" + self.semester.save() + self.assertEqual(Semester.objects.get(semester="2025T"), self.semester) + self.assertEqual(Semester.objects.get(semester="2025fa"), None) + self.assertEqual(len(Semester.objects.all()), 2) + print(Semester.objects.all()) diff --git a/backend/tests/wrapped/test_routes.py b/backend/tests/wrapped/test_routes.py new file mode 100644 index 00000000..c9788f3c --- /dev/null +++ b/backend/tests/wrapped/test_routes.py @@ -0,0 +1,160 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from wrapped.models import ( + GlobalStat, + GlobalStatKey, + GlobalStatPageField, + IndividualStat, + IndividualStatKey, + IndividualStatPageField, + Page, + Semester, +) + + +User = get_user_model() + + +# Will add more failing tests + + +class WrappedRoutesTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username="test", password="test") + self.client.force_authenticate(user=self.user) + + self.semester = Semester.objects.create(semester="2025fa", current=True) + + self.ind_key = IndividualStatKey.objects.create(key="gsr_hours") + self.glob_key = GlobalStatKey.objects.create(key="total_gsr_hours") + self.glob_key2 = GlobalStatKey.objects.create(key="total_gym_hours") + self.ind_key2 = IndividualStatKey.objects.create(key="gym_hours") + + self.ind_stat = IndividualStat.objects.create( + user=self.user, + key=self.ind_key, + value="5", + semester=self.semester, + ) + + self.ind_stat2 = IndividualStat.objects.create( + user=self.user, + key=self.ind_key2, + value="10", + semester=self.semester, + ) + + self.glob_stat = GlobalStat.objects.create( + key=self.glob_key, + value="1000", + semester=self.semester, + ) + self.glob_stat2 = GlobalStat.objects.create( + key=self.glob_key2, + value="2000", + semester=self.semester, + ) + + self.page = Page.objects.create( + name="GSR_Page", + id=1, + template_path="wrapped/gsr_page.html", + duration=timedelta(minutes=1), + ) + self.page2 = Page.objects.create( + name="GSR_Page2", + id=2, + template_path="wrapped/gsr_page2.html", + duration=timedelta(minutes=1), + ) + self.semester.pages.add(self.page) + self.semester.pages.add(self.page2) + + # Through models for page fields + self.ind_field = IndividualStatPageField.objects.create( + individual_stat_key=self.ind_key, + page=self.page, + text_field_name="top", + ) + self.ind_field2 = IndividualStatPageField.objects.create( + individual_stat_key=self.ind_key2, + page=self.page, + text_field_name="middle", + ) + self.glob_field = GlobalStatPageField.objects.create( + global_stat_key=self.glob_key, + page=self.page, + text_field_name="bottom", + ) + self.glob_field2 = GlobalStatPageField.objects.create( + global_stat_key=self.glob_key2, + page=self.page, + text_field_name="middle_left", + ) + + def test_get_current_semester(self): + response = self.client.get("/wrapped/semester/current/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "semester": "2025fa", + "pages": [ + { + "id": 1, + "name": "GSR_Page", + "template_path": "wrapped/gsr_page.html", + "combined_stats": { + "top": "5", + "middle": "10", + "bottom": "1000", + "middle_left": "2000", + }, + "duration": "00:01:00", + }, + { + "id": 2, + "name": "GSR_Page2", + "template_path": "wrapped/gsr_page2.html", + "combined_stats": {}, + "duration": "00:01:00", + }, + ], + }, + ) + + def test_get_semester(self): + response = self.client.get("/wrapped/semester/2025fa/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "semester": "2025fa", + "pages": [ + { + "id": 1, + "name": "GSR_Page", + "template_path": "wrapped/gsr_page.html", + "combined_stats": { + "top": "5", + "middle": "10", + "bottom": "1000", + "middle_left": "2000", + }, + "duration": "00:01:00", + }, + { + "id": 2, + "name": "GSR_Page2", + "template_path": "wrapped/gsr_page2.html", + "combined_stats": {}, + "duration": "00:01:00", + }, + ], + }, + ) diff --git a/backend/tests/wrapped/test_serializers.py b/backend/tests/wrapped/test_serializers.py new file mode 100644 index 00000000..562aa53f --- /dev/null +++ b/backend/tests/wrapped/test_serializers.py @@ -0,0 +1,133 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from wrapped.models import ( + GlobalStat, + GlobalStatKey, + GlobalStatPageField, + IndividualStat, + IndividualStatKey, + IndividualStatPageField, + Page, + Semester, +) +from wrapped.serializers import PageSerializer, SemesterSerializer + + +User = get_user_model() + + +# Will add more failing tests + + +class WrappedSerializersTestCase(TestCase): + def setUp(self): + + self.user = User.objects.create_user(username="test", password="test") + self.semester = Semester.objects.create(semester="2025fa") + + self.ind_key = IndividualStatKey.objects.create(key="gsr_hours") + self.glob_key = GlobalStatKey.objects.create(key="total_gsr_hours") + self.glob_key2 = GlobalStatKey.objects.create(key="total_gym_hours") + self.ind_key2 = IndividualStatKey.objects.create(key="gym_hours") + + self.ind_stat = IndividualStat.objects.create( + user=self.user, + key=self.ind_key, + value="5", + semester=self.semester, + ) + + self.ind_stat2 = IndividualStat.objects.create( + user=self.user, + key=self.ind_key2, + value="10", + semester=self.semester, + ) + + self.glob_stat = GlobalStat.objects.create( + key=self.glob_key, + value="1000", + semester=self.semester, + ) + self.glob_stat2 = GlobalStat.objects.create( + key=self.glob_key2, + value="2000", + semester=self.semester, + ) + + self.page = Page.objects.create( + name="GSR_Page", + id=1, + template_path="wrapped/gsr_page.html", + duration=timedelta(minutes=1), + ) + self.page2 = Page.objects.create( + name="GSR_Page2", + id=2, + template_path="wrapped/gsr_page2.html", + duration=timedelta(minutes=1), + ) + self.semester.pages.add(self.page) + self.semester.pages.add(self.page2) + + # Through models for page fields + self.ind_field = IndividualStatPageField.objects.create( + individual_stat_key=self.ind_key, + page=self.page, + text_field_name="top", + ) + self.ind_field2 = IndividualStatPageField.objects.create( + individual_stat_key=self.ind_key2, + page=self.page, + text_field_name="middle", + ) + self.glob_field = GlobalStatPageField.objects.create( + global_stat_key=self.glob_key, + page=self.page, + text_field_name="bottom", + ) + self.glob_field2 = GlobalStatPageField.objects.create( + global_stat_key=self.glob_key2, + page=self.page, + text_field_name="middle_left", + ) + + def test_page_serializer(self): + serializer = PageSerializer( + self.page, context={"semester": self.semester, "user": self.user} + ) + data = serializer.data + self.assertEqual(data["name"], "GSR_Page") + self.assertEqual(data["id"], 1) + self.assertEqual(data["template_path"], "wrapped/gsr_page.html") + self.assertEqual(data["duration"], "00:01:00") + self.assertEqual( + data["combined_stats"], + { + "top": "5", + "bottom": "1000", + "middle": "10", + "middle_left": "2000", + }, + ) + + def test_semester_serializer(self): + serializer = SemesterSerializer(self.semester, context={"user": self.user}) + data = serializer.data + self.assertEqual(data["semester"], "2025fa") + self.assertEqual( + data["pages"], + [ + PageSerializer( + self.page, + context={"semester": self.semester, "user": self.user}, + ).data, + PageSerializer( + self.page2, + context={"semester": self.semester, "user": self.user}, + ).data, + ], + ) diff --git a/backend/wrapped/migrations/0006_semester_current_semester_semester_unique_and_more.py b/backend/wrapped/migrations/0006_semester_current_semester_semester_unique_and_more.py new file mode 100644 index 00000000..22cd4d8c --- /dev/null +++ b/backend/wrapped/migrations/0006_semester_current_semester_semester_unique_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.2 on 2025-11-16 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wrapped", "0005_alter_semester_semester"), + ] + + operations = [ + migrations.AddField( + model_name="semester", + name="current", + field=models.BooleanField(default=False), + ), + migrations.AddConstraint( + model_name="semester", + constraint=models.UniqueConstraint(fields=("semester",), name="semester_unique"), + ), + migrations.AddConstraint( + model_name="semester", + constraint=models.UniqueConstraint( + condition=models.Q(("current", True)), + fields=("current",), + name="single_current_semester", + ), + ), + ] diff --git a/backend/wrapped/migrations/0007_semester_pk_manual.py b/backend/wrapped/migrations/0007_semester_pk_manual.py new file mode 100644 index 00000000..3d649527 --- /dev/null +++ b/backend/wrapped/migrations/0007_semester_pk_manual.py @@ -0,0 +1,238 @@ +# Generated by Django 5.0.2 on 2025-12-31 02:30 + +from django.db import migrations, models + + +def print_log(apps, schema_editor): + print("PRINTING STUFF") + + +def forwards_backfill_globalstat_semester_id(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE wrapped_globalstat as gstat + SET semester_id_new = s.id + FROM wrapped_semester s + WHERE gstat.semester_id = s.semester; + """ + ) + + +def forwards_backfill_individualstat_semester_id(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE wrapped_individualstat as istat + SET semester_id_new = s.id + FROM wrapped_semester s + WHERE istat.semester_id = s.semester; + """ + ) + + +def backwards_backfill_globalstat_semester(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE wrapped_globalstat as gstat + SET semester_id = s.semester + FROM wrapped_semester s + WHERE gstat.semester_id_new = s.id; + """ + ) + + +def backwards_backfill_individualstat_semester(apps, schema_editor): + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + UPDATE wrapped_individualstat as istat + SET semester_id = s.semester + FROM wrapped_semester s + WHERE istat.semester_id_new = s.id; + """ + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("wrapped", "0006_semester_current_semester_semester_unique_and_more"), + ] + + operations = [ + # Add autogenerated unique id to Semester + migrations.AddField( + model_name="semester", + name="id", + field=models.BigIntegerField(null=True), + ), + # Populate id field with unique values + migrations.RunSQL( + sql=""" + CREATE SEQUENCE IF NOT EXISTS wrapped_semester_id_seq; + UPDATE wrapped_semester + SET id = nextval('wrapped_semester_id_seq') + WHERE id IS NULL; + ALTER SEQUENCE wrapped_semester_id_seq + OWNED BY wrapped_semester.id; + """, + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AddConstraint( + model_name="semester", + constraint=models.UniqueConstraint(fields=["id"], name="semester_id_unique"), + ), + # Migrate ForeignKeys in GlobalStat to use the new Semester id field + migrations.AddField( + model_name="globalstat", + name="semester_id_new", + field=models.BigIntegerField(null=True), + ), + migrations.AlterField( + model_name="globalstat", + name="semester", + field=models.ForeignKey( + to="wrapped.semester", + to_field="semester", + null=True, + on_delete=models.CASCADE, + related_name="+", + ), + ), + migrations.RunPython( + code=forwards_backfill_globalstat_semester_id, + reverse_code=backwards_backfill_globalstat_semester, + ), + migrations.AlterField( + model_name="globalstat", + name="semester_id_new", + field=models.ForeignKey( + to="wrapped.semester", + to_field="id", + null=False, + on_delete=models.CASCADE, + related_name="+", + ), + ), + migrations.RemoveField( + model_name="globalstat", + name="semester", + ), + migrations.RenameField( + model_name="globalstat", + old_name="semester_id_new", + new_name="semester", + ), + # Migrate ForeignKeys in IndividualStat to use the new Semester id field + migrations.AddField( + model_name="individualstat", + name="semester_id_new", + field=models.BigIntegerField(null=True), + ), + migrations.AlterField( + model_name="individualstat", + name="semester", + field=models.ForeignKey( + to="wrapped.semester", + to_field="semester", + null=True, + on_delete=models.CASCADE, + related_name="+", + ), + ), + migrations.RunPython( + code=forwards_backfill_individualstat_semester_id, + reverse_code=backwards_backfill_individualstat_semester, + ), + migrations.AlterField( + model_name="individualstat", + name="semester_id_new", + field=models.ForeignKey( + to="wrapped.semester", + to_field="id", + null=False, + on_delete=models.CASCADE, + related_name="+", + ), + ), + migrations.RemoveField( + model_name="individualstat", + name="semester", + ), + migrations.RenameField( + model_name="individualstat", + old_name="semester_id_new", + new_name="semester", + ), + # Migrate ForeignKeys in SemesterPage to use the new Semester id field + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + ADD COLUMN semester_id_new bigint; + UPDATE wrapped_semester_pages as spage + SET semester_id_new = s.id + FROM wrapped_semester s + WHERE spage.semester_id = s.semester; + ALTER TABLE wrapped_semester_pages + ALTER COLUMN semester_id_new SET NOT NULL; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + DROP COLUMN semester_id_new; + """, + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + ADD CONSTRAINT wrapped_semester_pages_semester_id_new_fkey + FOREIGN KEY (semester_id_new) + REFERENCES wrapped_semester(id) + ON DELETE CASCADE; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + DROP CONSTRAINT wrapped_semester_pages_semester_id_new_fkey; + """, + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + DROP CONSTRAINT wrapped_semester_pages_semester_id_0e2c9a76_fk; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + ADD CONSTRAINT wrapped_semester_pages_semester_id_0e2c9a76_fk + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(semester) + ON DELETE CASCADE; + """, + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + DROP COLUMN semester_id; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + ADD COLUMN semester_id varchar(16); + UPDATE wrapped_semester_pages as spage + SET semester_id = s.semester + FROM wrapped_semester s + WHERE spage.semester_id_new = s.id; + ALTER TABLE wrapped_semester_pages + ALTER COLUMN semester_id SET NOT NULL; + """, + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + RENAME COLUMN semester_id_new TO semester_id; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + RENAME COLUMN semester_id TO semester_id_new; + """, + ), + ] diff --git a/backend/wrapped/migrations/0008_semester_pk_manual_cleanup.py b/backend/wrapped/migrations/0008_semester_pk_manual_cleanup.py new file mode 100644 index 00000000..49d0416e --- /dev/null +++ b/backend/wrapped/migrations/0008_semester_pk_manual_cleanup.py @@ -0,0 +1,106 @@ +# Generated by Django 5.0.2 on 2025-12-31 02:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("wrapped", "0007_semester_pk_manual"), + ] + + operations = [ + # Drop old primary key and set new primary key on id field + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester DROP CONSTRAINT wrapped_semester_pkey; + ALTER TABLE wrapped_semester ADD PRIMARY KEY (id); + """, + reverse_sql=""" + ALTER TABLE wrapped_semester DROP CONSTRAINT wrapped_semester_pkey; + ALTER TABLE wrapped_semester ADD PRIMARY KEY (semester); + """, + ), + ], + state_operations=[ + migrations.AlterField( + model_name="semester", + name="semester", + field=models.CharField(max_length=16, primary_key=False), + ), + migrations.AlterField( + model_name="semester", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ], + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + DROP CONSTRAINT wrapped_semester_pages_semester_id_new_fkey; + ALTER TABLE wrapped_globalstat + DROP CONSTRAINT wrapped_globalstat_semester_id_d4fe9555_fk_wrapped_semester_id; + ALTER TABLE wrapped_individualstat + DROP CONSTRAINT wrapped_individualst_semester_id_26fd991a_fk_wrapped_s; + """, + reverse_sql=""" + ALTER TABLE wrapped_semester DROP CONSTRAINT wrapped_semester_pkey; + ALTER TABLE wrapped_semester_pages + ADD CONSTRAINT wrapped_semester_pages_semester_id_new_fkey + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + ALTER TABLE wrapped_globalstat + ADD CONSTRAINT wrapped_globalstat_semester_id_d4fe9555_fk_wrapped_semester_id + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + ALTER TABLE wrapped_individualstat + ADD CONSTRAINT wrapped_individualst_semester_id_26fd991a_fk_wrapped_s + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + ALTER TABLE wrapped_semester ADD PRIMARY KEY (id); + """, + ), + migrations.RemoveConstraint( + model_name="semester", + name="semester_id_unique", + ), + migrations.RunSQL( + sql=""" + ALTER TABLE wrapped_semester_pages + ADD CONSTRAINT wrapped_semester_pages_semester_id_new_fkey + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + ALTER TABLE wrapped_globalstat + ADD CONSTRAINT wrapped_globalstat_semester_id_d4fe9555_fk_wrapped_semester_id + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + ALTER TABLE wrapped_individualstat + ADD CONSTRAINT wrapped_individualst_semester_id_26fd991a_fk_wrapped_s + FOREIGN KEY (semester_id) + REFERENCES wrapped_semester(id); + """, + reverse_sql=""" + ALTER TABLE wrapped_semester_pages + DROP CONSTRAINT wrapped_semester_pages_semester_id_new_fkey; + ALTER TABLE wrapped_globalstat + DROP CONSTRAINT wrapped_globalstat_semester_id_d4fe9555_fk_wrapped_semester_id; + ALTER TABLE wrapped_individualstat + DROP CONSTRAINT wrapped_individualst_semester_id_26fd991a_fk_wrapped_s; + """, + ), + migrations.AlterField( + model_name="globalstat", + name="semester", + field=models.ForeignKey(on_delete=models.deletion.CASCADE, to="wrapped.semester"), + ), + migrations.AlterField( + model_name="individualstat", + name="semester", + field=models.ForeignKey(on_delete=models.deletion.CASCADE, to="wrapped.semester"), + ), + ] diff --git a/backend/wrapped/models.py b/backend/wrapped/models.py index a4d71e0a..fbb65e5d 100644 --- a/backend/wrapped/models.py +++ b/backend/wrapped/models.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Q User = get_user_model() @@ -26,8 +27,22 @@ class GlobalStatKey(StatKey): class Semester(models.Model): - semester = models.CharField(max_length=16, primary_key=True, null=False, blank=False) + semester = models.CharField(max_length=16, null=False, blank=False) pages = models.ManyToManyField("Page", blank=True) + current = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["semester"], name="semester_unique"), + models.UniqueConstraint( + fields=["current"], + condition=Q(current=True), + name="single_current_semester", + ), + ] + + def __str__(self): + return self.semester class GlobalStat(models.Model): diff --git a/backend/wrapped/urls.py b/backend/wrapped/urls.py index 4f1e5850..500c41c6 100644 --- a/backend/wrapped/urls.py +++ b/backend/wrapped/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from wrapped.views import SemesterView +from wrapped.views import SemesterCurrentView, SemesterView urlpatterns = [ + path("semester/current/", SemesterCurrentView.as_view(), name="semester-current-detail"), path("semester//", SemesterView.as_view(), name="semester-detail"), ] diff --git a/backend/wrapped/views.py b/backend/wrapped/views.py index defb0769..8537350a 100644 --- a/backend/wrapped/views.py +++ b/backend/wrapped/views.py @@ -13,3 +13,12 @@ def get(self, request, semester_id): semester = Semester.objects.get(semester=semester_id) serializer = SemesterSerializer(semester, context={"user": request.user}) return Response(serializer.data) + + +class SemesterCurrentView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + current_semester = Semester.objects.filter(current=True).first() + serializer = SemesterSerializer(current_semester, context={"user": request.user}) + return Response(serializer.data) diff --git a/sublet/.gitignore b/sublet/.gitignore new file mode 100644 index 00000000..a2ca3363 --- /dev/null +++ b/sublet/.gitignore @@ -0,0 +1,3 @@ +.next/ +node_modules/* +next-env.d.ts \ No newline at end of file