diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 9a1a3d4..10fb458 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -114,7 +114,7 @@ "sha256:3aeeead8f6afe48272db93ced9440cf4eda8b6fd7ee2abb25357b7eb28525b45", "sha256:9a8e637e31682ad36e1ff9f8bcba912fcfc7d7041722bc901a4b948da4d71ea9" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.8.3" }, "aiosignal": { @@ -122,7 +122,7 @@ "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.3.1" }, "asgiref": { @@ -130,7 +130,7 @@ "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.7.2" }, "async-timeout": { @@ -138,7 +138,7 @@ "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==4.0.3" }, "attrs": { @@ -146,7 +146,7 @@ "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==23.1.0" }, "beautifulsoup4": { @@ -543,7 +543,7 @@ "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.0.1" }, "jwcrypto": { @@ -730,7 +730,7 @@ "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==6.0.4" }, "oauthlib": { @@ -849,7 +849,7 @@ "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de", "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==2.8.0" }, "python-dateutil": { @@ -938,7 +938,7 @@ "sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a", "sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==0.7.0" }, "sentry-sdk": { @@ -1181,7 +1181,7 @@ "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78", "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==1.9.2" } }, @@ -1191,7 +1191,7 @@ "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version >= '3.7.0'", "version": "==3.7.2" }, "black": { diff --git a/backend/Platform/settings/base.py b/backend/Platform/settings/base.py index b9d3fa6..08636f4 100644 --- a/backend/Platform/settings/base.py +++ b/backend/Platform/settings/base.py @@ -73,6 +73,7 @@ "options.apps.OptionsConfig", "accounts.apps.AccountsConfig", "identity.apps.IdentityConfig", + "announcements.apps.AnnouncementsConfig", "storages", ] diff --git a/backend/Platform/urls.py b/backend/Platform/urls.py index 9d9b3c0..ef299eb 100644 --- a/backend/Platform/urls.py +++ b/backend/Platform/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path("admin/", admin.site.urls), + path("announcements/", include("announcements.urls", namespace="announcements")), path("accounts/", include("accounts.urls")), path("options/", include("options.urls", namespace="options")), path("identity/", include("identity.urls", namespace="identity")), diff --git a/backend/announcements/__init__.py b/backend/announcements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/announcements/admin.py b/backend/announcements/admin.py new file mode 100644 index 0000000..0825ab7 --- /dev/null +++ b/backend/announcements/admin.py @@ -0,0 +1,6 @@ +from announcements.models import Announcement, Audience +from django.contrib import admin + + +admin.site.register(Audience) +admin.site.register(Announcement) diff --git a/backend/announcements/apps.py b/backend/announcements/apps.py new file mode 100644 index 0000000..47f9f3f --- /dev/null +++ b/backend/announcements/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnnouncementsConfig(AppConfig): + name = "announcements" diff --git a/backend/announcements/management/commands/populate_audiences.py b/backend/announcements/management/commands/populate_audiences.py new file mode 100644 index 0000000..2923d87 --- /dev/null +++ b/backend/announcements/management/commands/populate_audiences.py @@ -0,0 +1,8 @@ +from announcements.models import Audience +from django.core.management import BaseCommand + + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + for audience_name, _ in Audience.AUDIENCE_CHOICES: + Audience.objects.get_or_create(name=audience_name) diff --git a/backend/announcements/migrations/0001_initial.py b/backend/announcements/migrations/0001_initial.py new file mode 100644 index 0000000..87259bd --- /dev/null +++ b/backend/announcements/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 4.2.7 on 2023-11-09 05:29 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Audience", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + choices=[ + ("MOBILE", "Penn Mobile"), + ("OHQ", "OHQ"), + ("CLUBS", "Penn Clubs"), + ("COURSE_PLAN", "Penn Course Plan"), + ("COURSE_REVIEW", "Penn Course Review"), + ("COURSE_ALERT", "Penn Course Alert"), + ], + max_length=20, + ), + ), + ], + ), + migrations.CreateModel( + name="Announcement", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("message", models.TextField()), + ( + "announcement_type", + models.CharField( + choices=[("NOTICE", "Notice"), ("ISSUE", "Issue")], + default="NOTICE", + max_length=20, + ), + ), + ( + "release_time", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("end_time", models.DateTimeField(blank=True, null=True)), + ( + "audiences", + models.ManyToManyField( + related_name="announcements", to="announcements.audience" + ), + ), + ], + ), + ] diff --git a/backend/announcements/migrations/__init__.py b/backend/announcements/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/announcements/models.py b/backend/announcements/models.py new file mode 100644 index 0000000..213d68d --- /dev/null +++ b/backend/announcements/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.utils import timezone + + +class Audience(models.Model): + """ + Represents a product that an announcement is intended for. + """ + + AUDIENCE_MOBILE = "MOBILE" + AUDIENCE_OHQ = "OHQ" + AUDIENCE_CLUBS = "CLUBS" + AUDIENCE_COURSE_PLAN = "COURSE_PLAN" + AUDIENCE_COURSE_REVIEW = "COURSE_REVIEW" + AUDIENCE_COURSE_ALERT = "COURSE_ALERT" + + AUDIENCE_CHOICES = [ + (AUDIENCE_MOBILE, "Penn Mobile"), + (AUDIENCE_OHQ, "OHQ"), + (AUDIENCE_CLUBS, "Penn Clubs"), + (AUDIENCE_COURSE_PLAN, "Penn Course Plan"), + (AUDIENCE_COURSE_REVIEW, "Penn Course Review"), + (AUDIENCE_COURSE_ALERT, "Penn Course Alert"), + ] + + name = models.CharField(choices=AUDIENCE_CHOICES, max_length=20) + + def __str__(self): + return self.name + + +class Announcement(models.Model): + """ + Represents an announcement for any of the Penn Labs services. + """ + + ANNOUNCEMENT_NOTICE = "NOTICE" + ANNOUNCEMENT_ISSUE = "ISSUE" + + ANNOUNCEMENT_CHOICES = [ + (ANNOUNCEMENT_NOTICE, "Notice"), + (ANNOUNCEMENT_ISSUE, "Issue"), + ] + + title = models.CharField( + max_length=255, + blank=True, + null=True, + ) + message = models.TextField() + announcement_type = models.CharField( + max_length=20, + choices=ANNOUNCEMENT_CHOICES, + default=ANNOUNCEMENT_NOTICE, + ) + audiences = models.ManyToManyField("Audience", related_name="announcements") + release_time = models.DateTimeField(default=timezone.now) + end_time = models.DateTimeField(null=True, blank=True) + + def __str__(self): + rtime = self.release_time.strftime("%m-%d-%Y %H:%M:%S") + etime = ( + f" to {self.end_time.strftime('%m-%d-%Y %H:%M:%S')}" + if self.end_time + else "" + ) + aud_str = ",".join([audience.name for audience in self.audiences.all()]) + title_str = f"{self.title}: " if self.title else "" + + return f"[{self.get_announcement_type_display()} for {aud_str}] \ +starting at {rtime}{etime} | {title_str}{self.message}" diff --git a/backend/announcements/permissions.py b/backend/announcements/permissions.py new file mode 100644 index 0000000..2bbbcb0 --- /dev/null +++ b/backend/announcements/permissions.py @@ -0,0 +1,17 @@ +from rest_framework import permissions + + +class AnnouncementPermissions(permissions.BasePermission): + """ + Grants permission if the current user is a superuser. + """ + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + return request.user.is_authenticated and request.user.is_superuser + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + return request.user.is_authenticated and request.user.is_superuser diff --git a/backend/announcements/serializers.py b/backend/announcements/serializers.py new file mode 100644 index 0000000..355b1bb --- /dev/null +++ b/backend/announcements/serializers.py @@ -0,0 +1,64 @@ +from announcements.models import Announcement, Audience +from rest_framework import serializers + + +class AudienceSerializer(serializers.ModelSerializer): + class Meta: + model = Audience + fields = ("name",) + + +class AnnouncementSerializer(serializers.ModelSerializer): + audiences = serializers.SlugRelatedField( + many=True, slug_field="name", queryset=Audience.objects.all() + ) + + class Meta: + model = Announcement + fields = ( + "id", + "title", + "message", + "announcement_type", + "release_time", + "end_time", + "audiences", + ) + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation["audiences"] = [ + audience.name for audience in instance.audiences.all() + ] + return representation + + def to_internal_value(self, data): + audiences = data.get("audiences") + if isinstance(audiences, list): + if not audiences: + raise serializers.ValidationError( + {"detail": "You must provide at least one audience"} + ) + audience_objs = [] + for audience_name in audiences: + audience = Audience.objects.filter(name=audience_name).first() + if not audience: + raise serializers.ValidationError( + {"detail": f"Invalid audience name: {audience_name}"} + ) + audience_objs.append(audience) + data["audiences"] = audience_objs + return super().to_internal_value(data) + + def create(self, validated_data): + audiences = validated_data.pop("audiences") + instance = Announcement.objects.create(**validated_data) + instance.audiences.set(audiences) + return instance + + def update(self, instance, validated_data): + audiences = validated_data.pop("audiences", None) + super().update(instance, validated_data) + if audiences: + instance.audiences.set(audiences) + return instance diff --git a/backend/announcements/urls.py b/backend/announcements/urls.py new file mode 100644 index 0000000..321364d --- /dev/null +++ b/backend/announcements/urls.py @@ -0,0 +1,8 @@ +from announcements.views import AnnouncementsViewSet +from rest_framework import routers + + +app_name = "announcements" +router = routers.SimpleRouter() +router.register("", AnnouncementsViewSet, basename="announcements") +urlpatterns = router.urls diff --git a/backend/announcements/views.py b/backend/announcements/views.py new file mode 100644 index 0000000..1023b07 --- /dev/null +++ b/backend/announcements/views.py @@ -0,0 +1,27 @@ +from announcements.models import Announcement +from announcements.permissions import AnnouncementPermissions +from announcements.serializers import AnnouncementSerializer +from django.db.models import Q +from django.utils import timezone +from rest_framework import viewsets + + +class AnnouncementsViewSet(viewsets.ModelViewSet): + serializer_class = AnnouncementSerializer + permission_classes = [AnnouncementPermissions] + + def get_queryset(self): + # automatically filter for active announcements + queryset = Announcement.objects.filter( + Q(release_time__lte=timezone.now()) + & (Q(end_time__gte=timezone.now()) | Q(end_time__isnull=True)) + ).prefetch_related("audiences") + audiences = self.request.query_params.get("audience") + + if audiences: + audience_names = audiences.split(",") + queryset = queryset.filter( + audiences__name__in=[name.strip().upper() for name in audience_names] + ) + + return queryset.distinct() diff --git a/backend/tests/announcements/test_models.py b/backend/tests/announcements/test_models.py new file mode 100644 index 0000000..2f785ed --- /dev/null +++ b/backend/tests/announcements/test_models.py @@ -0,0 +1,43 @@ +from announcements.models import Announcement, Audience +from django.test import TestCase +from django.utils import timezone + + +class AudienceTestCase(TestCase): + def setUp(self): + self.audience_name = "CLUBS" + self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) + + def test_str(self): + self.assertEqual(str(self.audience), self.audience_name) + + +class AnnouncementTestCase(TestCase): + def setUp(self): + self.audience_name = "CLUBS" + self.title = "Test Announcement" + self.message = "This is a test" + self.announcement_type = "Issue" + self.release_time = timezone.datetime( + year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ) + self.end_time = timezone.datetime( + year=3001, month=1, day=1, tzinfo=timezone.get_current_timezone() + ) + self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) + self.announcement = Announcement.objects.create( + title=self.title, + message=self.message, + announcement_type=Announcement.ANNOUNCEMENT_ISSUE, + release_time=self.release_time, + end_time=self.end_time, + ) + self.announcement.audiences.add(self.audience) + + def test_str(self): + self.assertEqual( + str(self.announcement), + f"[{self.announcement_type} for {self.audience_name}] \ +starting at {self.release_time.strftime('%m-%d-%Y %H:%M:%S')} to \ +{self.end_time.strftime('%m-%d-%Y %H:%M:%S')} | {self.title}: {self.message}", + ) diff --git a/backend/tests/announcements/test_populate_audiences.py b/backend/tests/announcements/test_populate_audiences.py new file mode 100644 index 0000000..6424c3a --- /dev/null +++ b/backend/tests/announcements/test_populate_audiences.py @@ -0,0 +1,15 @@ +from announcements.models import Audience +from django.core.management import call_command +from django.test import TestCase + + +class PopulateAudiencesTestCase(TestCase): + def test_populate_audiences(self): + call_command("populate_audiences") + self.assertTrue(Audience.objects.all().count() > 0) + + def test_populate_twice(self): + call_command("populate_audiences") + count = Audience.objects.all().count() + call_command("populate_audiences") + self.assertEqual(Audience.objects.all().count(), count) diff --git a/backend/tests/announcements/test_serializers.py b/backend/tests/announcements/test_serializers.py new file mode 100644 index 0000000..3fc8c0c --- /dev/null +++ b/backend/tests/announcements/test_serializers.py @@ -0,0 +1,43 @@ +from announcements.models import Announcement, Audience +from announcements.serializers import AnnouncementSerializer, AudienceSerializer +from django.test import TestCase +from django.utils import timezone + + +class AudienceSerializerTestCase(TestCase): + def setUp(self): + self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) + self.serializer = AudienceSerializer(self.audience) + + def test_serializer(self): + data = {"name": self.audience.name} + self.assertEqual(self.serializer.data, data) + + +class AnnouncementSerializerTestCase(TestCase): + def setUp(self): + self.audience_clubs = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) + self.audience_ohq = Audience.objects.create(name=Audience.AUDIENCE_OHQ) + self.announcement = Announcement.objects.create( + title="Test message", + message="This is a test", + announcement_type=Announcement.ANNOUNCEMENT_NOTICE, + release_time=timezone.datetime( + year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + ) + self.announcement.audiences.add(self.audience_clubs) + self.announcement.audiences.add(self.audience_ohq) + self.serializer = AnnouncementSerializer(self.announcement) + + def test_serializer(self): + data = { + "id": self.announcement.id, + "title": self.announcement.title, + "message": self.announcement.message, + "announcement_type": self.announcement.announcement_type, + "release_time": self.announcement.release_time.isoformat(), + "end_time": None, + "audiences": [Audience.AUDIENCE_CLUBS, Audience.AUDIENCE_OHQ], + } + self.assertEqual(self.serializer.data, data) diff --git a/backend/tests/announcements/test_views.py b/backend/tests/announcements/test_views.py new file mode 100644 index 0000000..5b240b9 --- /dev/null +++ b/backend/tests/announcements/test_views.py @@ -0,0 +1,129 @@ +from announcements.models import Announcement, Audience +from announcements.serializers import AnnouncementSerializer +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + + +class AnnouncementsFilterTestCase(TestCase): + def setUp(self): + self.client = Client() + self.audience_clubs = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) + self.audience_ohq = Audience.objects.create(name=Audience.AUDIENCE_OHQ) + self.announcement1 = Announcement.objects.create( + title="Test message", + message="This is a test", + announcement_type=Announcement.ANNOUNCEMENT_NOTICE, + release_time=timezone.datetime( + year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + end_time=timezone.datetime( + year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + ) + self.announcement2 = Announcement.objects.create( + title="Test message 2", + message="This is also a test", + announcement_type=Announcement.ANNOUNCEMENT_NOTICE, + end_time=timezone.datetime( + year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + ) + self.announcement1.audiences.add(self.audience_clubs) + self.announcement2.audiences.add(self.audience_ohq) + + def test_get_active(self): + response = self.client.get("/announcements/") + announcement3 = Announcement.objects.create( + title="Test message 3", + message="This is yet another a test", + announcement_type=Announcement.ANNOUNCEMENT_NOTICE, + release_time=timezone.datetime( + year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + end_time=timezone.datetime( + year=1001, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + ) + announcement3.audiences.add(self.audience_ohq) + self.assertIn(AnnouncementSerializer(self.announcement1).data, response.json()) + self.assertIn(AnnouncementSerializer(self.announcement2).data, response.json()) + self.assertNotIn(AnnouncementSerializer(announcement3).data, response.json()) + + def test_filter_audience(self): + response = self.client.get("/announcements/?audience=clubs") + self.assertIn(AnnouncementSerializer(self.announcement1).data, response.json()) + self.assertNotIn( + AnnouncementSerializer(self.announcement2).data, response.json() + ) + + +class AnnouncementsPermissionTestCase(TestCase): + def setUp(self): + self.client = Client() + + def test_invalid_permission(self): + response = self.client.post( + "/announcements/", + { + "title": "Maintenance Alert", + "message": "We apologize for any inconvenience caused.", + "audiences": ["CLUBS", "COURSE_PLAN", "COURSE_ALERT"], + }, + ) + self.assertEqual(response.status_code, 403) + + +class AnnouncementsModifyTestCase(TestCase): + def setUp(self): + self.client = Client() + for audience_name, _ in Audience.AUDIENCE_CHOICES: + Audience.objects.get_or_create(name=audience_name) + self.announcement = Announcement.objects.create( + title="Test message", + message="This is a test", + announcement_type=Announcement.ANNOUNCEMENT_NOTICE, + release_time=timezone.datetime( + year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + end_time=timezone.datetime( + year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() + ), + ) + self.user = get_user_model().objects.create( + pennid=1, + username="student", + password="secret", + first_name="First", + last_name="Last", + email="test@test.com", + is_superuser=1, + ) + self.client.force_login(self.user) + + def test_create_announcement(self): + response = self.client.post( + "/announcements/", + { + "title": "Maintenance Alert", + "message": "We apologize for any inconvenience caused.", + "audiences": ["CLUBS", "COURSE_PLAN", "COURSE_ALERT"], + }, + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(Announcement.objects.filter(title="Maintenance Alert").exists()) + + def test_update_announcement(self): + response = self.client.patch( + f"/announcements/{self.announcement.id}/", + {"title": "Wow!"}, + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(Announcement.objects.filter(title="Wow!").exists()) + self.assertFalse(Announcement.objects.filter(title="Test message").exists()) + + def test_delete_announcement(self): + response = self.client.delete(f"/announcements/{self.announcement.id}/") + self.assertEqual(response.status_code, 204) + self.assertFalse(Announcement.objects.filter(title="Test message").exists()) diff --git a/k8s/main.ts b/k8s/main.ts index 5f66e7d..e457d1b 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -22,6 +22,7 @@ export class MyChart extends PennLabsChart { port: 443, deployment: { image: backendImage, + tag: "daf9ff9b7ba9cd8026b90955bb35e156b63b7742", secret, secretMounts: [ { @@ -75,6 +76,7 @@ export class MyChart extends PennLabsChart { port: 8080, deployment: { image: devImage, + tag: "daf9ff9b7ba9cd8026b90955bb35e156b63b7742", secret: devSecret, env: [{ name: "DEV_LOGIN",