diff --git a/.github/workflows/build-and-deploy.yaml b/.github/workflows/build-and-deploy.yaml index 278214958..cbb6ba54c 100644 --- a/.github/workflows/build-and-deploy.yaml +++ b/.github/workflows/build-and-deploy.yaml @@ -18,13 +18,6 @@ jobs: black: false ruff: true - frontend-check: - name: "Frontend Check" - uses: pennlabs/shared-actions/.github/workflows/react-check.yaml@v0.1 - with: - path: frontend - nodeVersion: 20.11.1 - build-backend: name: Build backend runs-on: ubuntu-latest @@ -47,7 +40,7 @@ jobs: cache-to: type=local,dest=/tmp/.buildx-cache tags: pennlabs/penn-clubs-backend:latest,pennlabs/penn-clubs-backend:${{ github.sha }} outputs: type=docker,dest=/tmp/image.tar - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: build-backend path: /tmp/image.tar @@ -75,11 +68,10 @@ jobs: cache-to: type=local,dest=/tmp/.buildx-cache tags: pennlabs/penn-clubs-frontend:latest,pennlabs/penn-clubs-frontend:${{ github.sha }} outputs: type=docker,dest=/tmp/image.tar - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: build-frontend path: /tmp/image.tar - needs: frontend-check publish: name: Publish Images @@ -87,8 +79,8 @@ jobs: if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v2 - - uses: actions/download-artifact@v2 - - uses: geekyeggo/delete-artifact@v1 + - uses: actions/download-artifact@v4 + - uses: geekyeggo/delete-artifact@v5 with: name: |- build-backend diff --git a/README.md b/README.md index 542249db7..b0d1d97bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Penn Clubs -[](https://github.com/pennlabs/penn-clubs/actions) +[](https://github.com/pennlabs/penn-clubs/actions) [](https://codecov.io/gh/pennlabs/penn-clubs) Official platform for club discovery, recruitment, and events at Penn. @@ -8,8 +8,6 @@ React/Next.js frontend and Django-based REST API. ## Installation -You will need to start both the backend and the frontend to do Penn Clubs development. - You will need to start both the backend and the frontend to develop on Penn Clubs. Clubs supports Mac and Linux/WSL development. Questions? Check out our [extended guide](https://github.com/pennlabs/penn-clubs/wiki/Development-Guide) for FAQs. @@ -95,4 +93,4 @@ To test ticketing locally, you will need to [install](https://github.com/FiloSot - `$ mkcert localhost 127.0.0.1 ::1` - `$ export DOMAIN=https://localhost:3001 NODE_TLS_REJECT_UNAUTHORIZED=0` -Then, after the frontend is running, run `yarn ssl-proxy` **in a new terminal window** and access the application at [https://localhost:3001](https://localhost:3001). \ No newline at end of file +Then, after the frontend is running, run `yarn ssl-proxy` **in a new terminal window** and access the application at [https://localhost:3001](https://localhost:3001). diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index e319b9e1c..593f1e71d 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -396,12 +396,12 @@ }, "django": { "hashes": [ - "sha256:4bd01a8c830bb77a8a3b0e7d8b25b887e536ad17a81ba2dce5476135c73312bd", - "sha256:916423499d75d62da7aa038d19aef23d23498d8df229775eb0a6309ee1013775" + "sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d", + "sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==5.0.4" + "version": "==5.1" }, "django-clone": { "hashes": [ @@ -477,20 +477,21 @@ }, "djangorestframework": { "hashes": [ - "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6", - "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1" + "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", + "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==3.15.1" + "markers": "python_version >= '3.8'", + "version": "==3.15.2" }, "drf-excel": { "hashes": [ - "sha256:2056483e63c45c03dd36169b268400e5f411568ba1b035286ee7e87aef9c1dcb", - "sha256:658d0217672d7d18dac26c483f88f8de33c209f97e9cd306a93b0fada41b86d3" + "sha256:0103e4e3b7a923ac017169e56854f0a7be717e1bbcccd6cabaf7aabf9b5b7f28", + "sha256:6ae4b7c2c3552d633da88a3f93ea1c1d6237b805a50bb76555f1c5ce14521d65" ], "index": "pypi", - "version": "==2.4.0" + "markers": "python_version >= '3.7'", + "version": "==2.4.1" }, "drf-nested-routers": { "hashes": [ @@ -1018,11 +1019,11 @@ }, "openpyxl": { "hashes": [ - "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", - "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" + "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", + "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050" ], - "markers": "python_version >= '3.6'", - "version": "==3.1.2" + "markers": "python_version >= '3.8'", + "version": "==3.1.5" }, "packaging": { "hashes": [ @@ -1469,11 +1470,11 @@ }, "sqlparse": { "hashes": [ - "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", - "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" + "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", + "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" ], "markers": "python_version >= '3.8'", - "version": "==0.5.0" + "version": "==0.5.1" }, "tatsu": { "hashes": [ diff --git a/backend/clubs/admin.py b/backend/clubs/admin.py index f0f3b73ef..73046a164 100644 --- a/backend/clubs/admin.py +++ b/backend/clubs/admin.py @@ -25,6 +25,7 @@ Cart, Club, ClubApplication, + ClubApprovalResponseTemplate, ClubFair, ClubFairBooth, ClubFairRegistration, @@ -324,7 +325,7 @@ def club(self, obj): class AdvisorAdmin(admin.ModelAdmin): search_fields = ("name", "title", "email", "phone", "club__name") - list_display = ("name", "title", "email", "phone", "club", "public") + list_display = ("name", "title", "email", "phone", "club", "visibility") def club(self, obj): return obj.club.name @@ -415,6 +416,10 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin): list_display = ("user", "id", "created_at", "status") +class ClubApprovalResponseTemplateAdmin(admin.ModelAdmin): + search_fields = ("title", "content") + + admin.site.register(Asset) admin.site.register(ApplicationCommittee) admin.site.register(ApplicationExtension) @@ -460,3 +465,4 @@ class ApplicationSubmissionAdmin(admin.ModelAdmin): admin.site.register(TicketTransferRecord) admin.site.register(Cart) admin.site.register(ApplicationCycle) +admin.site.register(ClubApprovalResponseTemplate, ClubApprovalResponseTemplateAdmin) diff --git a/backend/clubs/management/commands/daily_notifications.py b/backend/clubs/management/commands/daily_notifications.py index cf60a5e79..6192e27c9 100644 --- a/backend/clubs/management/commands/daily_notifications.py +++ b/backend/clubs/management/commands/daily_notifications.py @@ -96,7 +96,7 @@ def send_approval_queue_reminder(self): group_name = "Approvers" # only send notifications if it is currently a weekday - if now.isoweekday() not in range(1, 6): + if now.isoweekday() not in range(1, 6) or not settings.REAPPROVAL_QUEUE_OPEN: return False # get users in group to send notification to diff --git a/backend/clubs/management/commands/deactivate.py b/backend/clubs/management/commands/deactivate.py index 78527e52a..f8eb0f427 100644 --- a/backend/clubs/management/commands/deactivate.py +++ b/backend/clubs/management/commands/deactivate.py @@ -1,6 +1,8 @@ import sys +from django.core.cache import cache from django.core.management.base import BaseCommand, CommandError +from django.db import transaction from clubs.models import Club @@ -74,21 +76,28 @@ def handle(self, *args, **kwargs): # deactivate all clubs if deactivate_clubs: - clubs.update(active=False, approved=None, approved_by=None) - - # allow existing approved version to stay on penn clubs website for now - ghosted = 0 - for club in clubs: - if club.history.filter(approved=True).exists(): - club.ghost = True - club._change_reason = ( - "Mark pending approval (yearly renewal process)" - ) - club.save(update_fields=["ghost"]) - ghosted += 1 + num_ghosted = 0 + + with transaction.atomic(): + for club in clubs: + club.active = False + club.approved = None + club.approved_by = None + + # allow existing approved version to stay on website for now + if club.history.filter(approved=True).exists(): + club.ghost = True + club._change_reason = ( + "Mark pending approval (yearly renewal process)" + ) + num_ghosted += 1 + + club.save() + cache.delete(f"clubs:{club.id}-authed") # clear cache + cache.delete(f"clubs:{club.id}-anon") self.stdout.write( - f"{clubs.count()} clubs deactivated! {ghosted} clubs ghosted!" + f"{clubs.count()} clubs deactivated! {num_ghosted} clubs ghosted!" ) # send out renewal emails to all clubs diff --git a/backend/clubs/management/commands/graduate_users.py b/backend/clubs/management/commands/graduate_users.py index bf61ca50d..d6e000f63 100644 --- a/backend/clubs/management/commands/graduate_users.py +++ b/backend/clubs/management/commands/graduate_users.py @@ -9,7 +9,6 @@ class Command(BaseCommand): "Mark all memberships where the student has graduated as inactive. " "This script should be run at the beginning of each year." ) - web_execute = True def handle(self, *args, **kwargs): now = timezone.now() diff --git a/backend/clubs/management/commands/osa_perms_updates.py b/backend/clubs/management/commands/osa_perms_updates.py new file mode 100644 index 000000000..d54d3e802 --- /dev/null +++ b/backend/clubs/management/commands/osa_perms_updates.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand + +from clubs.models import Club + + +class Command(BaseCommand): + help = "Give superuser to hard-coded user accounts affiliated with OSA." + web_execute = True + + def handle(self, *args, **kwargs): + User = get_user_model() + content_type = ContentType.objects.get_for_model(Club) + approve_perm = Permission.objects.get( + codename="approve_club", content_type=content_type + ) + pending_perm = Permission.objects.get( + codename="see_pending_clubs", content_type=content_type + ) + if not settings.OSA_KEYS: + raise ValueError("OSA_KEYS not set in settings") + if not (approvers := Group.objects.filter(name="Approvers").first()): + raise ValueError("Approvers group not found") + for key in settings.OSA_KEYS: + if not key or not (user := User.objects.get(username=key)): + continue + user.is_superuser = True + user.is_staff = True + user.user_permissions.add(approve_perm) + user.user_permissions.add(pending_perm) + approvers.user_set.add(user) + user.save() + approvers.save() diff --git a/backend/clubs/management/commands/populate.py b/backend/clubs/management/commands/populate.py index 892865ce5..310b17804 100644 --- a/backend/clubs/management/commands/populate.py +++ b/backend/clubs/management/commands/populate.py @@ -455,7 +455,7 @@ def get_image(url): department="Accounting Department", email="example@example.com", phone="+12158985000", - defaults={"public": True}, + defaults={"visibility": Advisor.ADVISOR_VISIBILITY_STUDENTS}, ) club.tags.add(tag_undergrad) diff --git a/backend/clubs/management/commands/rank.py b/backend/clubs/management/commands/rank.py index 37bc168ab..5b20afd31 100644 --- a/backend/clubs/management/commands/rank.py +++ b/backend/clubs/management/commands/rank.py @@ -1,8 +1,8 @@ import datetime -import random from math import floor import bleach +import numpy as np from django.core.management.base import BaseCommand from django.utils import timezone @@ -220,8 +220,11 @@ def rank(self): if num_testimonials >= 3: ranking += 5 - # rng - ranking += random.random() * 10 + # random number, mostly shuffles similar clubs with average of 25 points + # but with long right tail to periodically feature less popular clubs + # given ~700 active clubs, multiplier c, expected # clubs with rand > cd + # is 257, 95, 35, 13, 5, 2, 1 for c = 1, 2, 3, 4, 5, 6, 7 + ranking += np.random.standard_exponential() * 25 club.rank = floor(ranking) club.skip_history_when_saving = True diff --git a/backend/clubs/management/commands/send_emails.py b/backend/clubs/management/commands/send_emails.py index 930a8e05b..c7d2f923e 100644 --- a/backend/clubs/management/commands/send_emails.py +++ b/backend/clubs/management/commands/send_emails.py @@ -80,6 +80,7 @@ def add_arguments(self, parser): "hap_intro_remind", "hap_second_round", "hap_partner_communication", + "revised_fair_date", "wc_intro", "osa_email_communication", "ics_calendar_ingestation", @@ -452,6 +453,7 @@ def handle(self, *args, **kwargs): "admin_outreach", "semesterly_email", "update_officers", + "revised_fair_date", }: clubs = Club.objects.all() attachment = None @@ -473,7 +475,7 @@ def handle(self, *args, **kwargs): for club in clubs: if action == "semesterly_email": context = {"code": club.code} - elif action == "update_officers": + elif action == "update_officers" or action == "revised_fair_date": context = { "name": club.name, "url": f"https://pennclubs.com/club/{club.code}/edit/member", diff --git a/backend/clubs/migrations/0112_clubfair_virtual.py b/backend/clubs/migrations/0112_clubfair_virtual.py new file mode 100644 index 000000000..607904d6f --- /dev/null +++ b/backend/clubs/migrations/0112_clubfair_virtual.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-08-21 18:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0111_alter_club_rank_alter_historicalclub_rank"), + ] + + operations = [ + migrations.AddField( + model_name="clubfair", + name="virtual", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/migrations/0113_badge_message.py b/backend/clubs/migrations/0113_badge_message.py new file mode 100644 index 000000000..1abd92746 --- /dev/null +++ b/backend/clubs/migrations/0113_badge_message.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-08-30 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0112_clubfair_virtual"), + ] + + operations = [ + migrations.AddField( + model_name="badge", + name="message", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/clubs/migrations/0114_alter_advisor_public.py b/backend/clubs/migrations/0114_alter_advisor_public.py new file mode 100644 index 000000000..3ab692de9 --- /dev/null +++ b/backend/clubs/migrations/0114_alter_advisor_public.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.4 on 2024-09-29 17:00 + +from django.db import migrations, models + + +def migrate_public_to_enum(apps, schema_editor): + Advisor = apps.get_model("clubs", "Advisor") + # Update 'public' field, assume public=True means only students by default + Advisor.objects.filter(public=False).update(visibility=1) + Advisor.objects.filter(public=True).update(visibility=2) + +def reverse_migrate_public_to_enum(apps, schema_editor): + Advisor = apps.get_model("clubs", "Advisor") + Advisor.objects.filter(visibility=1).update(public=False) + Advisor.objects.filter(visibility__in=[2, 3]).update(public=True) + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0113_badge_message"), + ] + operations = [ + migrations.AddField( + model_name="advisor", + name="visibility", + field=models.IntegerField( + choices=[(1, "Admin Only"), (2, "Signed-in Students"), (3, "Public")], + default=2, + ), + ), + migrations.RunPython(migrate_public_to_enum, reverse_migrate_public_to_enum), + migrations.RemoveField( + model_name="advisor", + name="public", + ), + ] diff --git a/backend/clubs/migrations/0115_club_beta_historicalclub_beta.py b/backend/clubs/migrations/0115_club_beta_historicalclub_beta.py new file mode 100644 index 000000000..832c7afdf --- /dev/null +++ b/backend/clubs/migrations/0115_club_beta_historicalclub_beta.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-10-05 02:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0114_alter_advisor_public"), + ] + + operations = [ + migrations.AddField( + model_name="club", + name="beta", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="historicalclub", + name="beta", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/clubs/migrations/0116_alter_club_approved_on_and_more.py b/backend/clubs/migrations/0116_alter_club_approved_on_and_more.py new file mode 100644 index 000000000..d332c9988 --- /dev/null +++ b/backend/clubs/migrations/0116_alter_club_approved_on_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-10-07 14:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0115_club_beta_historicalclub_beta"), + ] + + operations = [ + migrations.AlterField( + model_name="club", + name="approved_on", + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name="historicalclub", + name="approved_on", + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + ] diff --git a/backend/clubs/migrations/0117_clubapprovalresponsetemplate.py b/backend/clubs/migrations/0117_clubapprovalresponsetemplate.py new file mode 100644 index 000000000..a6ab1a7ce --- /dev/null +++ b/backend/clubs/migrations/0117_clubapprovalresponsetemplate.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.4 on 2024-10-16 02:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("clubs", "0116_alter_club_approved_on_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ClubApprovalResponseTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, unique=True)), + ("content", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="templates", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/clubs/mixins.py b/backend/clubs/mixins.py index 16fc46137..37b148e58 100644 --- a/backend/clubs/mixins.py +++ b/backend/clubs/mixins.py @@ -148,7 +148,7 @@ def get_xlsx_column_name(self, key): if hasattr(serializer_class, "get_xlsx_column_name"): val = serializer_class.get_xlsx_column_name(key) if val is None: - val = key.replace("_", " ") + val = key self._column_cache[key] = val return val @@ -238,7 +238,6 @@ def _lookup_field_formatter(self, field): field_object = model._meta.get_field(source_lookup[-1][:-4]) else: raise e - # format based on field type if isinstance(field_object, (ManyToManyField, ManyToOneRel)): return self._many_to_many_formatter @@ -289,7 +288,6 @@ def finalize_response(self, request, response, *args, **kwargs): response = super(XLSXFormatterMixin, self).finalize_response( request, response, *args, **kwargs ) - # If this is a spreadsheet response, intercept and format. if ( isinstance(response, Response) @@ -324,5 +322,4 @@ def finalize_response(self, request, response, *args, **kwargs): response["Content-Disposition"] = "attachment; filename={}".format( self.get_filename() ) - return response diff --git a/backend/clubs/models.py b/backend/clubs/models.py index daf7f5590..22105c7a0 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -280,7 +280,7 @@ class Club(models.Model): blank=True, ) approved_comment = models.TextField(null=True, blank=True) - approved_on = models.DateTimeField(null=True, blank=True) + approved_on = models.DateTimeField(null=True, blank=True, db_index=True) archived = models.BooleanField(default=False) archived_by = models.ForeignKey( @@ -294,6 +294,7 @@ class Club(models.Model): code = models.SlugField(max_length=255, unique=True, db_index=True) active = models.BooleanField(default=False) + beta = models.BooleanField(default=False) # opts club into all beta features name = models.CharField(max_length=255) subtitle = models.CharField(blank=True, max_length=255) terms = models.CharField(blank=True, max_length=1024) @@ -620,27 +621,23 @@ def get_officer_emails(self): """ emails = [] - # add club contact email if valid - try: - validate_email(self.email) - emails.append(self.email) - except ValidationError: - pass - - # add email for all officers and above - for user in self.membership_set.filter( - role__lte=Membership.ROLE_OFFICER, active=True - ): - emails.append(user.person.email) - - # remove empty emails - emails = [email.strip() for email in emails] - emails = [email for email in emails if email] - - # remove duplicate emails - emails = list(sorted(set(emails))) + # Add club contact email if valid + if self.email: + try: + validate_email(self.email) + emails.append(self.email) + except ValidationError: + pass + + # Add email for all active officers and above + emails.extend( + self.membership_set.filter( + role__lte=Membership.ROLE_OFFICER, active=True + ).values_list("person__email", flat=True) + ) - return emails + # Remove whitespace, empty emails, and duplicates, then sort + return sorted(set(email.strip() for email in emails if email.strip())) def send_confirmation_email(self, request=None): """ @@ -680,6 +677,7 @@ def send_approval_email(self, request=None, change=False): "view_url": settings.VIEW_URL.format(domain=domain, club=self.code), "edit_url": settings.EDIT_URL.format(domain=domain, club=self.code), "change": change, + "reply_emails": settings.OSA_EMAILS + [settings.BRANDING_SITE_EMAIL], } emails = self.get_officer_emails() @@ -687,10 +685,8 @@ def send_approval_email(self, request=None, change=False): if emails: send_mail_helper( name="approval_status", - subject="{}{} {} on {}".format( - "Changes to " if change else "", + subject="{} status update on {}".format( self.name, - "accepted" if self.approved else "not approved", settings.BRANDING_SITE_NAME, ), emails=emails, @@ -782,11 +778,7 @@ def __str__(self): def send_question_mail(self, request=None): domain = get_domain(request) - owner_emails = list( - self.club.membership_set.filter( - role__lte=Membership.ROLE_OFFICER - ).values_list("person__email", flat=True) - ) + emails = self.club.get_officer_emails() context = { "name": self.club.name, @@ -794,11 +786,11 @@ def send_question_mail(self, request=None): "url": settings.QUESTION_URL.format(domain=domain, club=self.club.code), } - if owner_emails: + if emails: send_mail_helper( name="question", subject="Question for {}".format(self.club.name), - emails=owner_emails, + emails=emails, context=context, ) @@ -826,6 +818,7 @@ class ClubFair(models.Model): organization = models.TextField() contact = models.TextField() time = models.TextField(blank=True) + virtual = models.BooleanField(default=False) # these fields are rendered as raw html information = models.TextField(blank=True) @@ -1113,20 +1106,17 @@ def send_request(self, request=None): "full_name": self.person.get_full_name(), } - owner_emails = list( - self.club.membership_set.filter( - role__lte=Membership.ROLE_OFFICER - ).values_list("person__email", flat=True) - ) + emails = self.club.get_officer_emails() - send_mail_helper( - name="request", - subject="Membership Request from {} for {}".format( - self.person.get_full_name(), self.club.name - ), - emails=owner_emails, - context=context, - ) + if emails: + send_mail_helper( + name="request", + subject="Membership Request from {} for {}".format( + self.person.get_full_name(), self.club.name + ), + emails=emails, + context=context, + ) class Meta: unique_together = (("person", "club"),) @@ -1146,7 +1136,20 @@ class Advisor(models.Model): phone = PhoneNumberField(null=False, blank=True) club = models.ForeignKey(Club, on_delete=models.CASCADE) - public = models.BooleanField() + + ADVISOR_VISIBILITY_ADMIN = 1 + ADVISOR_VISIBILITY_STUDENTS = 2 + ADVISOR_VISIBILITY_ALL = 3 + + ADVISOR_VISIBILITY_CHOICES = ( + (ADVISOR_VISIBILITY_ADMIN, "Admin Only"), + (ADVISOR_VISIBILITY_STUDENTS, "Signed-in Students"), + (ADVISOR_VISIBILITY_ALL, "Public"), + ) + + visibility = models.IntegerField( + choices=ADVISOR_VISIBILITY_CHOICES, default=ADVISOR_VISIBILITY_STUDENTS + ) def __str__(self): return self.name @@ -1453,6 +1456,9 @@ class Badge(models.Model): # whether or not users can view and filter by this badge visible = models.BooleanField(default=False) + # optional message to display on club pages with the badge + message = models.TextField(null=True, blank=True) + def __str__(self): return self.label @@ -1918,8 +1924,6 @@ def send_confirmation_email(self): """ Send a confirmation email to the ticket owner after purchase """ - owner = self.owner - output = BytesIO() qr_image = self.get_qr() qr_image.save(output, format="PNG") @@ -1937,8 +1941,9 @@ def send_confirmation_email(self): if self.owner.email: send_mail_helper( name="ticket_confirmation", - subject=f"Ticket confirmation for {owner.get_full_name()}", - emails=[owner.email], + subject=f"Ticket confirmation for {self.owner.get_full_name()}" + f" to {self.event.name}", + emails=[self.owner.email], context=context, attachment={ "filename": "qr_code.png", @@ -1992,6 +1997,24 @@ def send_confirmation_email(self): ) +class ClubApprovalResponseTemplate(models.Model): + """ + Represents a (rejection) template for site administrators to use + during the club approval process. + """ + + author = models.ForeignKey( + get_user_model(), on_delete=models.SET_NULL, null=True, related_name="templates" + ) + title = models.CharField(max_length=255, unique=True) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.title + + @receiver(models.signals.pre_delete, sender=Asset) def asset_delete_cleanup(sender, instance, **kwargs): if instance.file: diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index daf103333..ce634fc9a 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -116,6 +116,8 @@ class ClubPermission(permissions.BasePermission): Anyone should be able to view, if the club is approved. Otherwise, only members or people with permission should be able to view. + + Actions default to owners/officers only unless specified below. """ def has_object_permission(self, request, view, obj): @@ -163,7 +165,7 @@ def has_object_permission(self, request, view, obj): if membership is None: return False # user has to be an owner to delete a club, an officer to edit it - if view.action in ["destroy"]: + if view.action in {"destroy"}: return membership.role <= Membership.ROLE_OWNER else: return membership.role <= Membership.ROLE_OFFICER @@ -171,7 +173,9 @@ def has_object_permission(self, request, view, obj): def has_permission(self, request, view): if view.action in { "children", + "create", "destroy", + "history", "parents", "partial_update", "update", @@ -179,8 +183,6 @@ def has_permission(self, request, view): "upload_file", }: return request.user.is_authenticated - elif view.action in {"create"}: - return request.user.is_authenticated else: return True @@ -223,7 +225,12 @@ def has_object_permission(self, request, view, obj): if not old_type == FAIR_TYPE and new_type == FAIR_TYPE: return False - elif view.action in ["buyers", "create_tickets", "issue_tickets"]: + elif view.action in [ + "buyers", + "create_tickets", + "issue_tickets", + "email_blast", + ]: if not request.user.is_authenticated: return False membership = find_membership_helper(request.user, obj.club) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index 0610b37e6..5597216de 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -31,6 +31,7 @@ Badge, Club, ClubApplication, + ClubApprovalResponseTemplate, ClubFair, ClubFairBooth, ClubVisit, @@ -132,7 +133,7 @@ class BadgeSerializer(serializers.ModelSerializer): class Meta: model = Badge - fields = ("id", "purpose", "label", "color", "description") + fields = ("id", "purpose", "label", "color", "description", "message") class SchoolSerializer(serializers.ModelSerializer): @@ -351,7 +352,7 @@ class AdvisorSerializer( ): class Meta: model = Advisor - fields = ("id", "name", "title", "department", "email", "phone", "public") + fields = ("id", "name", "title", "department", "email", "phone", "visibility") class ClubEventSerializer(serializers.ModelSerializer): @@ -1278,8 +1279,14 @@ def get_approved_by(self, obj): return obj.approved_by.get_full_name() def get_advisor_set(self, obj): + user = self.context["request"].user + visibility_level = ( + Advisor.ADVISOR_VISIBILITY_STUDENTS + if user.is_authenticated + else Advisor.ADVISOR_VISIBILITY_ALL + ) return AdvisorSerializer( - obj.advisor_set.filter(public=True).order_by("name"), + obj.advisor_set.filter(visibility__gte=visibility_level), many=True, read_only=True, context=self.context, @@ -1292,19 +1299,21 @@ def get_is_request(self, obj): return obj.membershiprequest_set.filter(person=user, withdrew=False).exists() def get_target_years(self, obj): - qset = TargetYear.objects.filter(club=obj) + qset = TargetYear.objects.filter(club=obj).select_related("target_years") return [TargetYearSerializer(m).data for m in qset] def get_target_majors(self, obj): - qset = TargetMajor.objects.filter(club=obj) + qset = TargetMajor.objects.filter(club=obj).select_related("target_majors") return [TargetMajorSerializer(m).data for m in qset] def get_target_schools(self, obj): - qset = TargetSchool.objects.filter(club=obj) + qset = TargetSchool.objects.filter(club=obj).select_related("target_schools") return [TargetSchoolSerializer(m).data for m in qset] def get_target_student_types(self, obj): - qset = TargetStudentType.objects.filter(club=obj) + qset = TargetStudentType.objects.filter(club=obj).select_related( + "target_student_types" + ) return [TargetStudentTypeSerializer(m).data for m in qset] def create(self, validated_data): @@ -1314,6 +1323,16 @@ def create(self, validated_data): # New clubs created through the API must always be approved. validated_data["approved"] = None + request = self.context.get("request", None) + perms = request and request.user.has_perm("clubs.approve_club") + + if not perms and ( + not settings.REAPPROVAL_QUEUE_OPEN or not settings.NEW_APPROVAL_QUEUE_OPEN + ): + raise serializers.ValidationError( + "The approval queue is not currently open." + ) + obj = super().create(validated_data) # assign user who created as owner @@ -1513,6 +1532,11 @@ def save(self): """ Override save in order to replace code with slugified name if not specified. """ + if "beta" in self.validated_data: + raise serializers.ValidationError( + "The beta field is not allowed to be set by clubs." + ) + # remove any spaces from the name if "name" in self.validated_data: self.validated_data["name"] = self.validated_data["name"].strip() @@ -1529,7 +1553,7 @@ def save(self): # if key fields were edited, require re-approval needs_reapproval = False if self.instance: - for field in {"name", "image", "description"}: + for field in {"name", "image"}: if field in self.validated_data and not self.validated_data[ field ] == getattr(self.instance, field, None): @@ -1541,6 +1565,11 @@ def save(self): if request and request.user.has_perm("clubs.approve_club"): needs_reapproval = False + if needs_reapproval and not settings.REAPPROVAL_QUEUE_OPEN: + raise serializers.ValidationError( + "The approval queue is not currently open." + ) + has_approved_version = ( self.instance and self.instance.history.filter(approved=True).exists() ) @@ -1710,6 +1739,7 @@ class Meta(ClubListSerializer.Meta): "approved_by", "approved_comment", "badges", + "beta", "created_at", "description", "events", @@ -2324,8 +2354,8 @@ def get_attribute(self, instance): def to_representation(self, value): """ - This is called to get the value for a partcular cell, given the club code. - The entire field object can be though of as a column in the spreadsheet. + This is called to get the value for a particular cell, given the club code. + The entire field object can be thought of as a column in the spreadsheet. """ return self._cached_values.get(value, self._default_value) @@ -2970,11 +3000,34 @@ class Meta: "registration_start_time", "start_time", "time", + "virtual", ) -class AdminNoteSerializer(serializers.ModelSerializer): - club = serializers.SlugRelatedField(queryset=Club.objects.all(), slug_field="code") +class ApprovalHistorySerializer(serializers.ModelSerializer): + approved = serializers.BooleanField() + approved_on = serializers.DateTimeField() + approved_by = serializers.SerializerMethodField("get_approved_by") + approved_comment = serializers.CharField() + history_date = serializers.DateTimeField() + + def get_approved_by(self, obj): + if obj.approved_by is None: + return "Unknown" + return obj.approved_by.get_full_name() + + class Meta: + model = Club + fields = ( + "approved", + "approved_on", + "approved_by", + "approved_comment", + "history_date", + ) + + +class AdminNoteSerializer(ClubRouteMixin, serializers.ModelSerializer): creator = serializers.SerializerMethodField("get_creator") title = serializers.CharField(max_length=255, default="Note") content = serializers.CharField(required=False) @@ -2983,16 +3036,16 @@ def get_creator(self, obj): return obj.creator.get_full_name() def create(self, validated_data): - return AdminNote.objects.create( - creator=self.context["request"].user, - club=validated_data["club"], - title=validated_data["title"], - content=validated_data["content"], - ) + validated_data["creator"] = self.context["request"].user + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data.pop("creator", "") + return super().update(instance, validated_data) class Meta: model = AdminNote - fields = ("id", "creator", "club", "title", "content", "created_at") + fields = ("id", "creator", "title", "content", "created_at") class WritableClubFairSerializer(ClubFairSerializer): @@ -3000,3 +3053,22 @@ class WritableClubFairSerializer(ClubFairSerializer): class Meta(ClubFairSerializer.Meta): pass + + +class ClubApprovalResponseTemplateSerializer(serializers.ModelSerializer): + author = serializers.SerializerMethodField("get_author") + + def get_author(self, obj): + return obj.author.get_full_name() + + def create(self, validated_data): + validated_data["author"] = self.context["request"].user + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data.pop("author", "") + return super().update(instance, validated_data) + + class Meta: + model = ClubApprovalResponseTemplate + fields = ("id", "author", "title", "content", "created_at", "updated_at") diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 4395aa838..48f2f0572 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -12,6 +12,7 @@ BadgeClubViewSet, BadgeViewSet, ClubApplicationViewSet, + ClubApprovalResponseTemplateViewSet, ClubBoothsViewSet, ClubEventViewSet, ClubFairViewSet, @@ -23,6 +24,7 @@ FavoriteCalendarAPIView, FavoriteEventsAPIView, FavoriteViewSet, + HealthView, MajorViewSet, MassInviteAPIView, MeetingZoomAPIView, @@ -92,6 +94,7 @@ basename="wharton", ) router.register(r"submissions", ApplicationSubmissionUserViewSet, basename="submission") +router.register(r"templates", ClubApprovalResponseTemplateViewSet, basename="templates") clubs_router = routers.NestedSimpleRouter(router, r"clubs", lookup="club") clubs_router.register(r"members", MemberViewSet, basename="club-members") @@ -174,6 +177,7 @@ WhartonApplicationStatusAPIView.as_view(), name="wharton-applications-status", ), + path(r"health/", HealthView.as_view(), name="health"), ] urlpatterns += router.urls diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 84506f528..237ac1aea 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -97,6 +97,7 @@ Cart, Club, ClubApplication, + ClubApprovalResponseTemplate, ClubFair, ClubFairBooth, ClubFairRegistration, @@ -155,11 +156,13 @@ ApplicationSubmissionCSVSerializer, ApplicationSubmissionSerializer, ApplicationSubmissionUserSerializer, + ApprovalHistorySerializer, AssetSerializer, AuthenticatedClubSerializer, AuthenticatedMembershipSerializer, BadgeSerializer, ClubApplicationSerializer, + ClubApprovalResponseTemplateSerializer, ClubBoothSerializer, ClubConstitutionSerializer, ClubDiffSerializer, @@ -1154,6 +1157,18 @@ def get_queryset(self): else: return queryset.filter(Q(approved=True) | Q(ghost=True)) + def _has_elevated_view_perms(self, instance): + """ + Determine if the current user has elevated view privileges. + """ + see_pending = self.request.user.has_perm("clubs.see_pending_clubs") + manage_club = self.request.user.has_perm("clubs.manage_club") + is_member = ( + self.request.user.is_authenticated + and instance.membership_set.filter(person=self.request.user).exists() + ) + return see_pending or manage_club or is_member + @action(detail=True, methods=["post"]) def upload(self, request, *args, **kwargs): """ @@ -1195,8 +1210,8 @@ def upload(self, request, *args, **kwargs): """ # ensure user is allowed to upload image club = self.get_object() - key = f"clubs:{club.id}" - cache.delete(key) + cache.delete(f"clubs:{club.id}-authed") + cache.delete(f"clubs:{club.id}-anon") # reset approval status after upload resp = upload_endpoint_helper(request, club, "file", "image", save=False) @@ -1265,6 +1280,49 @@ def upload_file(self, request, *args, **kwargs): return file_upload_endpoint_helper(request, code=club.code) + @action(detail=True, methods=["get"]) + def history(self, request, *args, **kwargs): + """ + Return a simplified approval history for the club. + --- + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: object + properties: + approved: + type: boolean + approved_on: + type: string + format: date-time + approved_by: + type: string + description: > + The full name of the user who approved + the club. + approved_comment: + type: string + history_date: + type: string + format: date-time + description: > + The time in which the specific version + of the club was saved at. + --- + """ + club = self.get_object() + return Response( + ApprovalHistorySerializer( + club.history.order_by("-history_date"), + many=True, + context={"request": request}, + ).data + ) + @action(detail=True, methods=["get"]) def owned_badges(self, request, *args, **kwargs): """ @@ -1360,6 +1418,8 @@ def alumni(self, request, *args, **kwargs): type: string --- """ + if not request.user.is_authenticated: + raise PermissionDenied club = self.get_object() results = collections.defaultdict(list) for first, last, year, show, username in club.membership_set.filter( @@ -2005,9 +2065,18 @@ def list(self, *args, **kwargs): def retrieve(self, *args, **kwargs): """ - Retrieve data about a specific club. Responses cached for 1 hour + Retrieve data about a specific club. Responses cached for 1 hour. Caching is + disabled for users with elevated view perms so that changes without approval + granted don't spill over to public. """ - key = f"clubs:{self.get_object().id}" + club = self.get_object() + + # don't cache if user has elevated view perms + if self._has_elevated_view_perms(club): + return super().retrieve(*args, **kwargs) + + key = f"""clubs:{club.id}-{"authed" if self.request.user.is_authenticated + else "anon"}""" cached = cache.get(key) if cached: return Response(cached) @@ -2021,8 +2090,8 @@ def update(self, request, *args, **kwargs): Invalidate caches """ self.check_approval_permission(request) - key = f"clubs:{self.get_object().id}" - cache.delete(key) + cache.delete(f"clubs:{self.get_object().id}-authed") + cache.delete(f"clubs:{self.get_object().id}-anon") return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): @@ -2030,8 +2099,8 @@ def partial_update(self, request, *args, **kwargs): Invalidate caches """ self.check_approval_permission(request) - key = f"clubs:{self.get_object().id}" - cache.delete(key) + cache.delete(f"clubs:{self.get_object().id}-authed") + cache.delete(f"clubs:{self.get_object().id}-anon") return super().partial_update(request, *args, **kwargs) def perform_destroy(self, instance): @@ -2048,17 +2117,17 @@ def perform_destroy(self, instance): # Send notice to club officers and executor context = { "name": club.name, - "branding_site_name": settings.BRANDING_SITE_NAME, - "branding_site_email": settings.BRANDING_SITE_EMAIL, + "reply_emails": settings.OSA_EMAILS + [settings.BRANDING_SITE_EMAIL], } emails = club.get_officer_emails() + [self.request.user.email] send_mail_helper( name="club_deletion", - subject="Removal of {} from {}".format( + subject="{} status update on {}".format( club.name, settings.BRANDING_SITE_NAME ), emails=emails, context=context, + reply_to=settings.OSA_EMAILS + [settings.BRANDING_SITE_EMAIL], ) def _get_club_diff_queryset(self): @@ -2299,6 +2368,8 @@ def get_serializer_class(self): return ClubConstitutionSerializer if self.action == "notes_about": return NoteSerializer + if self.action == "history": + return ApprovalHistorySerializer if self.action in {"list", "fields"}: if self.request is not None and ( self.request.accepted_renderer.format == "xlsx" @@ -2424,11 +2495,14 @@ class AdvisorSearchFilter(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): public = request.GET.get("public") - if public is not None: public = public.strip().lower() if public in {"true", "false"}: - queryset = queryset.filter(public=public == "true") + queryset = queryset.filter( + visibility__gte=Advisor.ADVISOR_VISIBILITY_STUDENTS + if public == "true" + else Advisor.ADVISOR_VISIBILITY_ADMIN + ) return queryset @@ -2436,7 +2510,7 @@ def filter_queryset(self, request, queryset, view): class AdvisorViewSet(viewsets.ModelViewSet): """ list: - Return a list of advisors for this club. + Return a list of advisors for this club for club administrators. create: Add an advisor to this club. @@ -2455,7 +2529,7 @@ class AdvisorViewSet(viewsets.ModelViewSet): """ serializer_class = AdvisorSerializer - permission_classes = [ClubItemPermission | IsSuperuser] + permission_classes = [ClubSensitiveItemPermission | IsSuperuser] filter_backends = [AdvisorSearchFilter] lookup_field = "id" http_method_names = ["get", "post", "put", "patch", "delete"] @@ -2557,10 +2631,17 @@ def add_to_cart(self, request, *args, **kwargs): event = self.get_object() cart, _ = Cart.objects.get_or_create(owner=self.request.user) + # Check if the event has already ended + if event.end_time < timezone.now(): + return Response( + {"detail": "This event has already ended", "success": False}, + status=status.HTTP_403_FORBIDDEN, + ) + # Cannot add tickets that haven't dropped yet if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: return Response( - {"detail": "Ticket drop time has not yet elapsed"}, + {"detail": "Ticket drop time has not yet elapsed", "success": False}, status=status.HTTP_403_FORBIDDEN, ) @@ -2604,7 +2685,10 @@ def add_to_cart(self, request, *args, **kwargs): if tickets.count() < count: return Response( - {"detail": f"Not enough tickets of type {type} left!"}, + { + "detail": f"Not enough tickets of type {type} left!", + "success": False, + }, status=status.HTTP_403_FORBIDDEN, ) cart.tickets.add(*tickets[:count]) @@ -2963,7 +3047,8 @@ def create_tickets(self, request, *args, **kwargs): event.ticket_drop_time = drop_time event.save() - cache.delete(f"clubs:{event.club.id}") + cache.delete(f"clubs:{event.club.id}-authed") + cache.delete(f"clubs:{event.club.id}-anon") return Response({"detail": "Successfully created tickets"}) @action(detail=True, methods=["post"]) @@ -3119,6 +3204,93 @@ def issue_tickets(self, request, *args, **kwargs): {"success": True, "detail": f"Issued {len(tickets)} tickets", "errors": []} ) + @action(detail=True, methods=["post"]) + def email_blast(self, request, *args, **kwargs): + """ + Send an email blast to all users holding tickets. + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + description: The content of the email blast to send + required: + - content + responses: + "200": + description: Email blast was sent successfully + content: + application/json: + schema: + type: object + properties: + detail: + type: string + description: A message indicating how many + recipients received the blast + "400": + description: Content field was empty or missing + content: + application/json: + schema: + type: object + properties: + detail: + type: string + description: Error message indicating content + was not provided + "404": + description: Event not found + content: + application/json: + schema: + type: object + properties: + detail: + type: string + description: Error message indicating event was + not found + --- + """ + event = self.get_object() + + holder_emails = Ticket.objects.filter( + event=event, owner__isnull=False + ).values_list("owner__email", flat=True) + officer_emails = event.club.get_officer_emails() + emails = list(holder_emails) + list(officer_emails) + + content = request.data.get("content", "").strip() + if not content: + return Response( + {"detail": "Content must be specified"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + send_mail_helper( + name="blast", + subject=f"Update on {event.name} from {event.club.name}", + emails=emails, + context={ + "sender": event.club.name, + "content": request.data.get("content"), + "reply_emails": event.club.get_officer_emails(), + }, + ) + + return Response( + { + "detail": ( + f"Blast sent to {len(holder_emails)} ticket holders " + f"and {len(officer_emails)} officers" + ) + } + ) + @action(detail=True, methods=["post"]) def upload(self, request, *args, **kwargs): """ @@ -3567,7 +3739,8 @@ def get_queryset(self): ) if not self.request.user.is_authenticated: - return questions.filter(approved=True) + # Hide responder if not authenticated + return questions.filter(approved=True).extra(select={"responder": "NULL"}) membership = Membership.objects.filter( club__code=club_code, person=self.request.user @@ -5278,8 +5451,12 @@ def cart(self, request, *args, **kwargs): owner=self.request.user ) + now = timezone.now() + tickets_to_replace = cart.tickets.filter( - Q(owner__isnull=False) | Q(holder__isnull=False) + Q(owner__isnull=False) + | Q(holder__isnull=False) + | Q(event__end_time__lt=now) ).exclude(holder=self.request.user) # In most cases, we won't need to replace, so exit early @@ -5291,16 +5468,30 @@ def cart(self, request, *args, **kwargs): }, ) - # Attempt to replace all tickets that have gone stale + # Attempt to replace all tickets that have gone stale or are for elapsed events replacement_tickets, sold_out_tickets = [], [] tickets_in_cart = cart.tickets.values_list("id", flat=True) tickets_to_replace = tickets_to_replace.select_related("event") for ticket_class in tickets_to_replace.values( - "type", "event", "event__name" + "type", "event", "event__name", "event__end_time" ).annotate(count=Count("id")): # we don't need to lock, since we aren't updating holder/owner + if ticket_class["event__end_time"] < now: + # Event has elapsed, mark all tickets as sold out + sold_out_tickets.append( + { + "type": ticket_class["type"], + "event": { + "id": ticket_class["event"], + "name": ticket_class["event__name"], + }, + "count": ticket_class["count"], + } + ) + continue + available_tickets = Ticket.objects.filter( event=ticket_class["event"], type=ticket_class["type"], @@ -5420,7 +5611,6 @@ def initiate_checkout(self, request, *args, **kwargs): order_info = { "amountDetails": {"totalAmount": "0.00"}, "billTo": { - "reconciliationId": None, "firstName": self.request.user.first_name, "lastName": self.request.user.last_name, "phoneNumber": None, @@ -5429,7 +5619,7 @@ def initiate_checkout(self, request, *args, **kwargs): } # Place hold on tickets for 10 mins - self._place_hold_on_tickets(tickets) + self._place_hold_on_tickets(self.request.user, tickets) # Skip payment process and give tickets to user/buyer self._give_tickets(self.request.user, order_info, cart, None) @@ -5485,7 +5675,7 @@ def initiate_checkout(self, request, *args, **kwargs): cart.save() # Place hold on tickets for 10 mins - self._place_hold_on_tickets(tickets) + self._place_hold_on_tickets(self.request.user, tickets) return Response( { @@ -5738,7 +5928,8 @@ def get_queryset(self): ).select_related("event__club") return Ticket.objects.filter(owner=self.request.user.id) - def _give_tickets(self, user, order_info, cart, reconciliation_id): + @staticmethod + def _give_tickets(user, order_info, cart, reconciliation_id): """ Helper function that gives user/buyer their held tickets and archives the transaction data @@ -5759,25 +5950,26 @@ def _give_tickets(self, user, order_info, cart, reconciliation_id): buyer_phone=order_info["billTo"].get("phoneNumber", None), buyer_email=order_info["billTo"]["email"], ) + tickets.update(owner=user, holder=None, transaction_record=transaction_record) - tickets.update( - owner=self.request.user, holder=None, transaction_record=transaction_record - ) cart.tickets.clear() - for ticket in tickets: - ticket.send_confirmation_email() - Ticket.objects.update_holds() - cart.checkout_context = None cart.save() - def _place_hold_on_tickets(self, tickets): + tickets = Ticket.objects.filter( + owner=user, transaction_record=transaction_record + ) + for ticket in tickets: + ticket.send_confirmation_email() + + @staticmethod + def _place_hold_on_tickets(user, tickets): """ Helper function that places a 10 minute hold on tickets for a user """ holding_expiration = timezone.now() + datetime.timedelta(minutes=10) - tickets.update(holder=self.request.user, holding_expiration=holding_expiration) + tickets.update(holder=user, holding_expiration=holding_expiration) class MemberInviteViewSet(viewsets.ModelViewSet): @@ -6668,6 +6860,52 @@ def remove_clubs_from_exception(self, *args, **kwargs): ) return Response([]) + @action(detail=True, methods=["GET"]) + def club_applications(self, *args, **kwargs): + """ + Retrieve club applications for given cycle + --- + requestBody: + content: {} + responses: + "200": + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + id: + type: integer + application_end_time: + type: string + format: date-time + application_end_time_exception: + type: string + club__name: + type: string + club__code: + type: string + --- + """ + cycle = self.get_object() + + return Response( + ClubApplication.objects.filter(application_cycle=cycle) + .select_related("club") + .values( + "name", + "id", + "application_end_time", + "application_end_time_exception", + "club__name", + "club__code", + ) + ) + @action(detail=True, methods=["GET"]) def applications(self, *args, **kwargs): """ @@ -6676,7 +6914,10 @@ def applications(self, *args, **kwargs): requestBody: {} responses: "200": - content: {} + content: + text/csv: + schema: + type: string --- """ cycle = self.get_object() @@ -7383,8 +7624,10 @@ def get(self, request): happening = fair.start_time <= now - datetime.timedelta(minutes=3) close = fair.start_time >= now - datetime.timedelta(weeks=1) options["FAIR_NAME"] = fair.name + options["FAIR_CONTACT"] = fair.contact options["FAIR_ID"] = fair.id options["FAIR_OPEN"] = happening + options["FAIR_VIRTUAL"] = fair.virtual options["PRE_FAIR"] = not happening and close else: options["FAIR_OPEN"] = False @@ -7516,7 +7759,18 @@ class AdminNoteViewSet(viewsets.ModelViewSet): http_method_names = ["get", "post", "put", "patch", "delete"] def get_queryset(self): - return AdminNote.objects.filter(club__code=self.kwargs.get("club_code")) + return AdminNote.objects.filter( + club__code=self.kwargs.get("club_code") + ).order_by("-created_at") + + +class ClubApprovalResponseTemplateViewSet(viewsets.ModelViewSet): + serializer_class = ClubApprovalResponseTemplateSerializer + permission_classes = [IsSuperuser] + lookup_field = "id" + + def get_queryset(self): + return ClubApprovalResponseTemplate.objects.all().order_by("-created_at") class ScriptExecutionView(APIView): @@ -7609,6 +7863,27 @@ def post(self, request): return Response({"output": output.getvalue()}) +class HealthView(APIView): + def get(self, request): + """ + Health check endpoint to confirm the backend is running. + --- + summary: Health Check + responses: + "200": + content: + application/json: + schema: + type: object + properties: + message: + type: string + enum: ["OK"] + --- + """ + return Response({"message": "OK"}, status=status.HTTP_200_OK) + + def get_initial_context_from_types(types): """ Generate a sample context given the specified types. diff --git a/backend/pennclubs/settings/base.py b/backend/pennclubs/settings/base.py index 26bc09ede..d74fa029a 100644 --- a/backend/pennclubs/settings/base.py +++ b/backend/pennclubs/settings/base.py @@ -204,6 +204,12 @@ OSA_EMAILS = ["vpul-orgs@pobox.upenn.edu"] + +# Controls whether existing clubs can submit for reapproval +REAPPROVAL_QUEUE_OPEN = True +# Controls whether new clubs can submit for initial approval +NEW_APPROVAL_QUEUE_OPEN = True + # File upload settings MEDIA_URL = "/api/media/" @@ -259,3 +265,5 @@ # Cybersource settings CYBERSOURCE_CLIENT_VERSION = "0.15" + +OSA_KEYS = None diff --git a/backend/pennclubs/settings/ci.py b/backend/pennclubs/settings/ci.py index 0f2e707ed..f6b5fa5b5 100644 --- a/backend/pennclubs/settings/ci.py +++ b/backend/pennclubs/settings/ci.py @@ -11,7 +11,7 @@ TEST_OUTPUT_DIR = "test-results" # Use dummy cache for testing -CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}} +CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} del PLATFORM_ACCOUNTS["REDIRECT_URI"] diff --git a/backend/pennclubs/settings/development.py b/backend/pennclubs/settings/development.py index 90c2d1504..720e07a23 100644 --- a/backend/pennclubs/settings/development.py +++ b/backend/pennclubs/settings/development.py @@ -42,3 +42,5 @@ "run_environment": "apitest.cybersource.com", } CYBERSOURCE_TARGET_ORIGIN = "https://localhost:3001" + +OSA_KEYS = ["gwashington"] diff --git a/backend/pennclubs/settings/production.py b/backend/pennclubs/settings/production.py index f8d20a44b..ed445fe2b 100644 --- a/backend/pennclubs/settings/production.py +++ b/backend/pennclubs/settings/production.py @@ -26,7 +26,7 @@ integrations=[DjangoIntegration(cache_spans=True)], send_default_pii=False, enable_tracing=True, - traces_sample_rate=0.1, + traces_sample_rate=0.01, profiles_sample_rate=1.0, ) @@ -89,3 +89,5 @@ "run_environment": "api.cybersource.com", } CYBERSOURCE_TARGET_ORIGIN = "https://pennclubs.com" + +OSA_KEYS = os.getenv("OSA_KEYS", "").split(",") diff --git a/backend/templates/emails/approval_status.html b/backend/templates/emails/approval_status.html index 8671f5227..9d0e78eec 100644 --- a/backend/templates/emails/approval_status.html +++ b/backend/templates/emails/approval_status.html @@ -13,43 +13,49 @@ type: string year: type: number +reply_emails: + type: array + items: + type: string --> {% extends 'emails/base.html' %} {% block content %} - <h2><b>{{ name }}</b> {% if change %}changes{% else %}renewal{% endif %} marked as {% if approved %}approved{% else %}not approved{% endif %} on Penn Clubs</h2> + <h2><b>{{ name }}</b> status update on Penn Clubs</h2> {% if approved %} - <p style="font-size: 1.2em; color: #228B22"> - <b>🎉 Congratulations!</b> Your club {% if change %}changes have{% else %}has{% endif %} been approved by the <b>Office of Student Affairs</b> for the {{ year }} school year! - Your {% if change %}updated{% else %}new{% endif %} club information is now open to the public on Penn Clubs. + <p style="font-size: 1.2em;"> + Your club {% if change %}updates have{% else %}has{% endif %} <b>been approved</b> by the <b>Office of Student Affairs</b>. + Your {% if change %}updated{% else %}new{% endif %} club is now visible to the public on Penn Clubs. </p> {% if approved_comment %} <p style="font-size: 1.2em; padding: 8px; border-left: 5px solid #ccc; white-space: pre-wrap;">{{ approved_comment }}</p> {% endif %} <p style="font-size: 1.2em"> - You do not need to perform any further actions. + No further action is required at this time. </p> <p style="font-size: 1.2em"> - You can view your club <a href="{{ view_url }}">here</a> and make further edits <a href="{{ view_url }}">here</a>. - If you have any feedback about the club renewal process, you can respond to this email or fill out <a href="https://airtable.com/shrCsYFWxCwfwE7cf">this form</a>. + You can view your club <a href="{{ view_url }}">here</a> and make further edits <a href="{{ edit_url }}">here</a>. + If you have any feedback about the club renewal process, please respond to this email or <a href="mailto:{% for email in reply_emails %}{% if not forloop.first %},{% endif %}{{ email }}{% endfor %}">contact the Office of Student Affairs</a>. </p> <p style="font-size: 1.2em"> - Thank you for using Penn Clubs! + Thank you for using Penn Clubs. </p> {% else %} - <p style="font-size: 1.2em; color: #8B2222"> - <b>Oh no!</b> Your club {% if change %}updates have{% else %}renewal has{% endif %} <b>not been approved</b> by the <b>Office of Student Affairs</b>. You will need to make changes to your club information before gaining approval. + <p style="font-size: 1.2em;"> + Your club {% if change %}updates have{% else %}has{% endif %} <b>not been approved</b> by the <b>Office of Student Affairs</b>. Changes to your club's information are required before approval can be granted. </p> - <p style="font-size: 1.2em">The reason your club was not approved is shown below:</p> + <p style="font-size: 1.2em">The feedback regarding your club's status is as follows:</p> <p style="font-size: 1.2em; padding: 8px; border-left: 5px solid #ccc;">{{ approved_comment }}</p> - <p style="font-size: 1.2em">You will need to <a href="{{ edit_url }}">make edits</a> to your club and press the button found <a href="{{ view_url }}">here</a> to apply again for approval.</p> - <p style="font-size: 1.2em">If you run into any issues with the approval process, you can respond to this email.</p> + <p style="font-size: 1.2em">Please <a href="{{ edit_url }}">make the necessary edits</a> to your club information and use the button <a href="{{ view_url }}">here</a> to resubmit for approval.</p> <p style="font-size: 1.2em"> - <a - style="text-decoration: none; padding: 5px 20px; font-size: 1.5em; margin-top: 20px; color: white; background-color: green; border-radius: 3px; font-weight: bold" - href="{{ view_url }}"> - Review {{ name }} - </a> + If you have any questions about the approval process, please feel free to respond to this email or <a href="mailto:{% for email in reply_emails %}{% if not forloop.first %},{% endif %}{{ email }}{% endfor %}">contact the Office of Student Affairs</a>. + </p> + <p style="font-size: 1.2em"> + <a + style="text-decoration: none; padding: 5px 20px; font-size: 1.5em; margin-top: 20px; color: white; background-color: green; border-radius: 3px; font-weight: bold" + href="{{ view_url }}"> + Review {{ name }} + </a> </p> {% endif %} {% endblock %} diff --git a/backend/templates/emails/blast.html b/backend/templates/emails/blast.html new file mode 100644 index 000000000..09545581e --- /dev/null +++ b/backend/templates/emails/blast.html @@ -0,0 +1,25 @@ +<!-- TYPES: +sender: + type: string +content: + type: string +reply_emails: + type: array + items: + type: string +--> +{% extends 'emails/base.html' %} + +{% block content %} + <h2>Message from {{ sender }}</h2> + + <p style="font-size: 1.2em"> + The following message was sent by <b>{{ sender }}</b>: + </p> + + <p style="font-size: 1.2em; padding: 8px; border-left: 5px solid #ccc; white-space: pre-wrap;">{{ content }}</p> + + <p style="font-size: 1.2em"> + If you have any questions, please respond to this email or <a href="mailto:{% for email in reply_emails %}{% if not forloop.first %},{% endif %}{{ email }}{% endfor %}">contact the sender</a>. + </p> +{% endblock %} diff --git a/backend/templates/emails/club_deletion.html b/backend/templates/emails/club_deletion.html index 3d15ab29d..ccaa30e85 100644 --- a/backend/templates/emails/club_deletion.html +++ b/backend/templates/emails/club_deletion.html @@ -1,17 +1,20 @@ <!-- TYPES: name: type: string -branding_site_name: - type: string -branding_site_email: - type: string +reply_emails: + type: array + items: + type: string --> {% extends 'emails/base.html' %} {% block content %} - <h2><b>{{ name }}</b> has been marked as deleted on {{ branding_site_name }}</h2> - <p style="font-size: 1.2em; color: #8B2222"> - <b>Oh no!</b> Your club has <b>been removed</b> from {{ branding_site_name }} by the <b>Office of Student Affairs</b>. + <h2><b>{{ name }}</b> status update on Penn Clubs</h2> + <p style="font-size: 1.2em;"> + Your club has been removed from Penn Clubs by the Office of Student Affairs. + </p> + <p style="font-size: 1.2em"> + If you have any questions or concerns, please + respond to this email or <a href="mailto:{% for email in reply_emails %}{% if not forloop.first %},{% endif %}{{ email }}{% endfor %}">contact the Office of Student Affairs</a>. </p> - <p style="font-size: 1.2em">If you believe this is an error, please contact <a href="mailto:{{ branding_site_email }}">{{ branding_site_email }}</a>.</p> -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/backend/templates/emails/revised_fair_date.html b/backend/templates/emails/revised_fair_date.html new file mode 100644 index 000000000..5704cd37f --- /dev/null +++ b/backend/templates/emails/revised_fair_date.html @@ -0,0 +1,44 @@ +<!-- SUBJECT: [CORRECTION] Updated information on Fall Activities Fair registration --> +<!-- TYPES: +name: + type: string +url: + type: string +--> +{% extends 'emails/base.html' %} {% block content %} +<p style="font-size: 1.2em">Hello,</p> +<p style="font-size: 1.2em"> + You previously received an email from us regarding club re-registration, as + well as information on a virtual SAC fair. This was outdated information on + our part: the activities fair will be in-person from August 27-29. +</p> +<p style="font-size: 1.2em; color: #ff3860; font-weight: bold"> + Re-registration on the Penn Clubs platform, while still necessary, does not + constitute registration for the fair. Please sign up for the fair + <a + href="https://docs.google.com/forms/d/e/1FAIpQLScLn0XrAdkPXgSBoT8bSt0xzWGcmJhwutjuRVmDYVPv8CjVjQ/viewform" + >here</a + > + by August 23rd, 11:59 PM ET. +</p> +<p style="font-size: 1.2em"> + Note that only returning student groups that were University registered last + year may sign up for the fair. +</p> +<p style="font-size: 1.2em"> + We have also enabled editing existing clubs and removed the need to re-queue + after updating club description. In addition, we've received a large number of + requests to transfer club ownership. While we are working through these, + existing owners can also transfer ownership <a href="{{ url }}">here</a> at + any time (please let us know if you do so and had already submitted a + request). +</p> +<p style="font-size: 1.2em"> + Please refer to the official email sent from the Office of Student Affairs for + more information or contact the organizers + <a href="mailto:fair@sacfunded.net">here</a>. +</p> +<p style="font-size: 1.2em">Apologies,</p> +<p style="font-size: 1.2em">Penn Clubs</p> + +{% endblock %} diff --git a/backend/templates/emails/ticket_confirmation.html b/backend/templates/emails/ticket_confirmation.html index 735bd7dbf..b14cd6b0e 100644 --- a/backend/templates/emails/ticket_confirmation.html +++ b/backend/templates/emails/ticket_confirmation.html @@ -21,31 +21,18 @@ <h2>Thanks for using Penn Clubs!</h2> <p style="font-size: 1.2em"> - {{ first_name }}, thank you for your recent acquisition of a ticket to {{ name }} with ticket type {{type }}. + {{ first_name }}, thank you for your recent acquisition of a ticket to <b>{{ name }}</b> with ticket type <b>{{ type }}</b>. </p> <p style="font-size: 1.2em"> - As a reminder, the event starts at {{ start_time }} and ends at {{ end_time }}. - - + As a reminder, the event starts at <b>{{ start_time }}</b> and ends at <b>{{ end_time }}</b>. </p> -<p style="font-size: 1.2em"> Below is a - QR code for - your confirmation. </p> - +<p style="font-size: 1.2em"> Below is a QR code for your confirmation: </p> <img id="now" style="max-width: 60%; width: auto;" alt="Ticket QR code" src="cid:{{ cid }}" /> -<p style="font-size: 1.2em"> Note: all tickets issued by us are <b>non-refundable</b>. </p> - - +<p style="font-size: 1.2em"> All tickets issued by us are <b>non-refundable</b>. You can view your tickets and transfer them to other users through <a href="{{ ticket_url }}">your Penn Clubs profile</a>.</p> -<p style="font-size: 1.2em"> - If you have any questions, feel free to respond to this email. -</p> - -<p style="font-size: 1.2em"> - You can view your tickets and transfer them to other users <a href="{{ ticket_url }}">here</a>. -</p> +<p style="font-size: 1.2em">If you have any questions, feel free to respond to this email.</p> {% endblock %} \ No newline at end of file diff --git a/backend/templates/emails/ticket_transfer.html b/backend/templates/emails/ticket_transfer.html index d4b69f39f..1490da7e1 100644 --- a/backend/templates/emails/ticket_transfer.html +++ b/backend/templates/emails/ticket_transfer.html @@ -17,11 +17,11 @@ <h2>Ticket Transfer Confirmation</h2> <p style="font-size: 1.2em"> - <b>{{ sender_first_name }}</b>, this is confirmation that you have transferred a ticket to <b>{{ receiver_first_name }}</b> (<b>{{ receiver_username }}</b>) for <b>{{ event_name }}</b> with ticket type <b>{{ type }}</b>. + {{ sender_first_name }}, this is confirmation that you have transferred a ticket to <b>{{ receiver_first_name }}</b> (<b>{{ receiver_username }}</b>) for <b>{{ event_name }}</b> with ticket type <b>{{ type }}</b>. </p> <p style="font-size: 1.2em"> - If you believe that this was sent in error or have any questions, feel free to respond to this email. + If you have any questions or believe this was sent in error, feel free to respond to this email. </p> {% endblock %} \ No newline at end of file diff --git a/backend/tests/clubs/test_commands.py b/backend/tests/clubs/test_commands.py index bdc6df470..90ceefda1 100644 --- a/backend/tests/clubs/test_commands.py +++ b/backend/tests/clubs/test_commands.py @@ -15,9 +15,12 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core import mail +from django.core.cache import caches from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse from django.utils import timezone from ics import Calendar from ics import Event as ICSEvent @@ -373,8 +376,11 @@ def test_daily_notifications(self): "django.utils.timezone.now", return_value=now, ): - call_command("daily_notifications", stderr=errors) - + with mock.patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", False): + call_command("daily_notifications", stderr=errors) + self.assertFalse(any(m.to == [self.user1.email] for m in mail.outbox)) + with mock.patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True): + call_command("daily_notifications", stderr=errors) # ensure approval email was sent out self.assertTrue(any(m.to == [self.user1.email] for m in mail.outbox)) @@ -590,6 +596,39 @@ def test_renewal(self): self.assertGreater(len(mail.outbox), current_email_count) + @override_settings( + CACHES={ # don't want to clear prod cache while testing + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_deactivate_invalidates_cache(self): + # Clear the cache before starting the test + caches["default"].clear() + + with open(os.devnull, "w") as f: + call_command("populate", stdout=f) + + club = Club.objects.first() + + # make request to the club detail view to cache it + self.client.get(reverse("clubs-detail", args=(club.code,))) + + # club should now be cached + cache_key = f"clubs:{club.id}-anon" + self.assertIsNotNone(caches["default"].get(cache_key)) + + call_command("deactivate", "all", "--force") + + # club should no longer be cached + self.assertIsNone(caches["default"].get(cache_key)) + + # club should be deactivated and inactive + club.refresh_from_db() + self.assertFalse(club.active) + self.assertIsNone(club.approved) + class MergeDuplicatesTestCase(TestCase): def setUp(self): @@ -694,3 +733,75 @@ def test_expire_membership_invites(self): self.assertFalse(self.expired_invite.active) self.assertTrue(self.active_invite.active) + + +class GraduateUsersTestCase(TestCase): + def setUp(self): + self.club = Club.objects.create(code="test", name="Test Club", active=True) + self.user1 = get_user_model().objects.create_user( + "bfranklin", "bfranklin@seas.upenn.edu", "test" + ) + self.user2 = get_user_model().objects.create_user( + "tjefferson", "tjefferson@seas.upenn.edu", "test" + ) + + # Set graduation years + self.user1.profile.graduation_year = timezone.now().year - 1 + self.user1.profile.save() + self.user2.profile.graduation_year = timezone.now().year + 1 + self.user2.profile.save() + + # Create active memberships + Membership.objects.create(person=self.user1, club=self.club, active=True) + Membership.objects.create(person=self.user2, club=self.club, active=True) + + def test_graduate_users(self): + # Ensure both memberships are active initially + self.assertEqual(Membership.objects.filter(active=True).count(), 2) + + # Run the command + call_command("graduate_users") + + # Check that only the graduated user's membership is inactive + self.assertEqual(Membership.objects.filter(active=True).count(), 1) + self.assertFalse(Membership.objects.get(person=self.user1).active) + self.assertTrue(Membership.objects.get(person=self.user2).active) + + def test_graduate_users_output(self): + # Capture command output + out = io.StringIO() + call_command("graduate_users", stdout=out) + + # Check the output + self.assertIn( + "Updated the membership status of 1 student club relationships!", + out.getvalue(), + ) + + +class OsaPermsUpdatesTestCase(TestCase): + def setUp(self): + self.user1 = get_user_model().objects.create_user("gwashington") + + def test_osa_perms_updates(self): + # Test error when OSA_KEYS is not set + with mock.patch("django.conf.settings.OSA_KEYS", None): + with self.assertRaises(ValueError): + call_command("osa_perms_updates") + self.assertFalse(self.user1.is_superuser) + + with mock.patch("django.conf.settings.OSA_KEYS", ["gwashington"]): + # Test error when Approvers group is not found + with self.assertRaises(ValueError): + call_command("osa_perms_updates") + self.assertFalse(self.user1.is_superuser) + + # Create Approvers group + Group.objects.create(name="Approvers") + call_command("osa_perms_updates") + self.user1.refresh_from_db() + self.assertTrue(self.user1.groups.filter(name="Approvers").exists()) + self.assertTrue(self.user1.is_staff) + self.assertTrue(self.user1.is_superuser) + self.assertTrue(self.user1.has_perm("approve_club")) + self.assertTrue(self.user1.has_perm("see_pending_clubs")) diff --git a/backend/tests/clubs/test_models.py b/backend/tests/clubs/test_models.py index e70ca7fdf..04e42d1d1 100644 --- a/backend/tests/clubs/test_models.py +++ b/backend/tests/clubs/test_models.py @@ -63,6 +63,60 @@ def test_parent_children(self): self.assertEqual(self.club2.parent_orgs.first(), self.club1) self.assertEqual(self.club1.children_orgs.first(), self.club2) + def test_get_officer_emails(self): + # Create test users with various email formats + user1 = get_user_model().objects.create_user( + "user1", "user1@example.com", "password" + ) + user2 = get_user_model().objects.create_user( + "user2", " user2@example.com ", "password" + ) # whitespace + user3 = get_user_model().objects.create_user( + "user3", "", "password" + ) # empty email + user4 = get_user_model().objects.create_user( + "user4", "user4@example.com", "password" + ) + + # Create memberships for the test users + Membership.objects.create( + person=user1, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=user2, club=self.club1, role=Membership.ROLE_OWNER + ) + Membership.objects.create( + person=user3, club=self.club1, role=Membership.ROLE_OFFICER + ) + Membership.objects.create( + person=user4, club=self.club1, role=Membership.ROLE_OFFICER, active=False + ) # alumni + + # Test with valid club email + self.club1.email = "club@example.com" + self.club1.save() + + officer_emails = self.club1.get_officer_emails() + expected_emails = ["club@example.com", "user1@example.com", "user2@example.com"] + self.assertEqual(officer_emails, expected_emails) + + # Ensure alumni are not included + self.assertNotIn("user4@example.com", officer_emails) + + # Test with invalid club email + self.club1.email = "invalid-email" + self.club1.save() + + officer_emails = self.club1.get_officer_emails() + expected_emails = ["user1@example.com", "user2@example.com"] + self.assertEqual(officer_emails, expected_emails) + + # Test with empty club email + self.club1.email = "" + self.club1.save() + officer_emails = self.club1.get_officer_emails() + self.assertEqual(officer_emails, expected_emails) + class ProfileTestCase(TestCase): def test_profile_creation(self): @@ -144,7 +198,10 @@ def setUp(self): code="a", name="a", subtitle="a", founded=date, description="a", size=1 ) self.advisor = Advisor.objects.create( - name="Eric Wang", phone="+12025550133", club=club, public=True + name="Eric Wang", + phone="+12025550133", + club=club, + visibility=Advisor.ADVISOR_VISIBILITY_ALL, ) def test_str(self): diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index a5415961a..8f047b9d8 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -6,6 +6,7 @@ import freezegun from django.contrib.auth import get_user_model +from django.core import mail from django.db.models import Count from django.db.models.deletion import ProtectedError from django.test import TestCase @@ -398,6 +399,44 @@ def test_issue_tickets_insufficient_quantity(self): Ticket.objects.filter(type="normal", holder__isnull=False).count(), 0 ) + def test_email_blast(self): + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OFFICER + ) + self.client.login(username=self.user1.username, password="test") + + ticket1 = self.tickets1[0] + ticket1.owner = self.user2 + ticket1.save() + + resp = self.client.post( + reverse("club-events-email-blast", args=(self.club1.code, self.event1.pk)), + {"content": "Test email blast content"}, + format="json", + ) + + self.assertEqual(resp.status_code, 200, resp.content) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + + self.assertIn(self.user2.email, email.to) + self.assertIn(self.user1.email, email.to) + + self.assertEqual( + email.subject, f"Update on {self.event1.name} from {self.club1.name}" + ) + self.assertIn("Test email blast content", email.body) + + def test_email_blast_empty_content(self): + self.client.login(username=self.user1.username, password="test") + resp = self.client.post( + reverse("club-events-email-blast", args=(self.club1.code, self.event1.pk)), + {"content": ""}, + format="json", + ) + self.assertEqual(resp.status_code, 400, resp.content) + def test_get_tickets_information_no_tickets(self): # Delete all the tickets Ticket.objects.all().delete() @@ -495,6 +534,27 @@ def test_add_to_cart(self): self.assertEqual(cart.tickets.filter(type="normal").count(), 2, cart.tickets) self.assertEqual(cart.tickets.filter(type="premium").count(), 1, cart.tickets) + def test_add_to_cart_elapsed_event(self): + self.client.login(username=self.user1.username, password="test") + + # Set the event end time to the past + self.event1.end_time = timezone.now() - timezone.timedelta(days=1) + self.event1.save() + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 1}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + self.assertIn("This event has already ended", resp.data["detail"], resp.data) + def test_add_to_cart_twice_accumulates(self): self.client.login(username=self.user1.username, password="test") @@ -947,6 +1007,124 @@ def test_get_cart_replacement_required_sold_out(self): to_add = set(map(lambda t: str(t.id), tickets_to_add)) self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add) + def test_get_cart_elapsed_event(self): + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:5] + for ticket in tickets_to_add: + cart.tickets.add(ticket) + cart.save() + + # Set the event end time to the past + self.event1.end_time = timezone.now() - timezone.timedelta(days=1) + self.event1.save() + + resp = self.client.get(reverse("tickets-cart"), format="json") + data = resp.json() + + # The cart should now be empty + self.assertEqual(len(data["tickets"]), 0, data) + + # All tickets should be in the sold out array + self.assertEqual(len(data["sold_out"]), 1, data) + + expected_sold_out = { + "type": self.tickets1[0].type, + "event": { + "id": self.event1.id, + "name": self.event1.name, + }, + "count": 5, + } + for key, val in expected_sold_out.items(): + self.assertEqual(data["sold_out"][0][key], val, data) + + def test_place_hold_on_tickets(self): + from clubs.views import TicketViewSet + + self.client.login(username=self.user1.username, password="test") + + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:3] + cart.tickets.add(*tickets_to_add) + cart.save() + + TicketViewSet._place_hold_on_tickets(self.user1, cart.tickets) + holding_expiration = timezone.now() + timezone.timedelta(minutes=10) + + for ticket in cart.tickets.all(): + self.assertIsNone(ticket.owner) + self.assertEqual(self.user1, ticket.holder) + self.assertAlmostEqual( + holding_expiration, + ticket.holding_expiration, + delta=timedelta(seconds=10), + ) + + # Move Django's internal clock 10 minutes forward + with freezegun.freeze_time(holding_expiration): + Ticket.objects.update_holds() + for ticket in cart.tickets.all(): + self.assertIsNone(ticket.owner) + self.assertIsNone(ticket.holder) + + def test_give_tickets(self): + from clubs.views import TicketViewSet + + self.client.login(username=self.user1.username, password="test") + # Add a few tickets + cart, _ = Cart.objects.get_or_create(owner=self.user1) + tickets_to_add = self.tickets1[:3] + cart.tickets.add(*tickets_to_add) + cart.save() + + order_info = { + "amountDetails": {"totalAmount": TicketViewSet._calculate_cart_total(cart)}, + "billTo": { + "firstName": self.user1.first_name, + "lastName": self.user1.last_name, + "phoneNumber": "3021239234", + "email": self.user1.email, + }, + } + + TicketViewSet._place_hold_on_tickets(self.user1, cart.tickets) + TicketViewSet._give_tickets( + self.user1, + order_info, + cart, + reconciliation_id=MockPaymentResponse().reconciliation_id, + ) + + # Check that tickets are assigned their owner + for ticket in cart.tickets.all(): + self.assertEqual(self.user1, ticket.owner) + self.assertIsNone(ticket.holder) + + # Check that the cart is empty + self.assertEqual(0, cart.tickets.count()) + + # Check that transaction record is created + record_exists = TicketTransactionRecord.objects.filter( + reconciliation_id=MockPaymentResponse().reconciliation_id + ).exists() + self.assertTrue(record_exists) + + # Check that confirmation emails were sent + self.assertEqual(len(mail.outbox), len(tickets_to_add)) + for msg in mail.outbox: + self.assertIn( + f"Ticket confirmation for {self.user1.first_name} " + f"{self.user1.last_name}", + msg.subject, + ) + self.assertIn(self.user1.first_name, msg.body) + self.assertIn(self.event1.name, msg.body) + self.assertIsNotNone(msg.attachments) + def test_initiate_checkout_non_free_tickets(self): self.client.login(username=self.user1.username, password="test") @@ -1088,7 +1266,7 @@ def test_initiate_checkout_only_free_tickets(self): held_tickets = Ticket.objects.filter(holder=self.user1) self.assertEqual(held_tickets.count(), 0, held_tickets) - # Transaction record created + # Check that transaction record is created record_exists = TicketTransactionRecord.objects.filter( reconciliation_id="None" ).exists() @@ -1267,7 +1445,7 @@ def test_complete_checkout(self): held_tickets = Ticket.objects.filter(holder=self.user1) self.assertEqual(held_tickets.count(), 0, held_tickets) - # Transaction record created + # Check that transaction record is created record_exists = TicketTransactionRecord.objects.filter( reconciliation_id=MockPaymentResponse().reconciliation_id ).exists() diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index e829f898a..789c5435f 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -18,11 +18,13 @@ from clubs.filters import DEFAULT_PAGE_SIZE from clubs.models import ( + Advisor, ApplicationSubmission, Asset, Badge, Club, ClubApplication, + ClubApprovalResponseTemplate, ClubFair, ClubFairRegistration, Event, @@ -167,6 +169,8 @@ def setUpTestData(cls): Tag.objects.create(name="Undergraduate") def setUp(self): + cache.clear() # clear the cache between tests + self.client = Client() self.club1 = Club.objects.create( @@ -202,6 +206,77 @@ def setUp(self): responder=self.user1, ) + self.advisor_admin = Advisor.objects.create( + name="Anonymous Avi", + phone="+12025550133", + club=self.club1, + visibility=Advisor.ADVISOR_VISIBILITY_ADMIN, + ) + + self.advisor_students = Advisor.objects.create( + name="Reclusive Rohan", + phone="+12025550133", + club=self.club1, + visibility=Advisor.ADVISOR_VISIBILITY_STUDENTS, + ) + + self.advisor_public = Advisor.objects.create( + name="Jocular Julian", + phone="+12025550133", + club=self.club1, + visibility=Advisor.ADVISOR_VISIBILITY_ALL, + ) + + def test_advisor_visibility(self): + """ + Tests each tier of advisor visibility. + """ + # Anonymous view + self.client.logout() + resp = self.client.get(reverse("clubs-detail", args=(self.club1.code,))) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertEqual(len(resp.data["advisor_set"]), 1) + self.assertEqual(resp.data["advisor_set"][0]["name"], "Jocular Julian") + + # Student view + self.client.login(username=self.user1.username, password="test") + resp = self.client.get(reverse("clubs-detail", args=(self.club1.code,))) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertEqual(len(resp.data["advisor_set"]), 2) + sorted_advisors = sorted( + [advisor["name"] for advisor in resp.data["advisor_set"]] + ) + self.assertEqual(sorted_advisors, ["Jocular Julian", "Reclusive Rohan"]) + + # Admin view + self.client.login(username=self.user5.username, password="test") + resp = self.client.get(reverse("clubs-detail", args=(self.club1.code,))) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertEqual(len(resp.data["advisor_set"]), 3) + sorted_advisors = sorted( + [advisor["name"] for advisor in resp.data["advisor_set"]] + ) + self.assertEqual( + sorted_advisors, ["Anonymous Avi", "Jocular Julian", "Reclusive Rohan"] + ) + + def test_advisor_viewset(self): + # Make sure we can't view advisors via the viewset if not authed + self.client.logout() + resp = self.client.get(reverse("club-advisors-list", args=(self.club1.code,))) + self.assertIn(resp.status_code, [400, 403], resp.content) + self.assertIn("detail", resp.data) + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get(reverse("club-advisors-list", args=(self.club1.code,))) + self.assertIn(resp.status_code, [400, 403], resp.content) + self.assertIn("detail", resp.data) + + self.client.login(username=self.user5.username, password="test") + resp = self.client.get(reverse("club-advisors-list", args=(self.club1.code,))) + self.assertIn(resp.status_code, [200, 201], resp.content) + self.assertEqual(len(resp.data), 3) + def test_club_upload(self): """ Test uploading a club logo. @@ -973,23 +1048,26 @@ def test_club_create_empty(self): """ self.client.login(username=self.user4.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - { - "code": "penn-labs", - "name": "Penn Labs", - "description": "This is an example description.", - "tags": [{"name": "Graduate"}], - "email": "example@example.com", - "facebook": "", - "twitter": "", - "instagram": "", - "website": "", - "linkedin": "", - "github": "", - }, - content_type="application/json", - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "code": "penn-labs", + "name": "Penn Labs", + "description": "This is an example description.", + "tags": [{"name": "Graduate"}], + "email": "example@example.com", + "facebook": "", + "twitter": "", + "instagram": "", + "website": "", + "linkedin": "", + "github": "", + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [200, 201], resp.content) # continue these tests as not a superuser but still logged in @@ -1012,6 +1090,32 @@ def test_club_create_empty(self): codes = [club["code"] for club in resp.data] self.assertNotIn(club.code, codes) + def test_club_create_new_approval_queue_closed(self): + """ + Test creating a club when the new approval queue is closed, but the + reapproval queue is open. + """ + self.client.login(username=self.user4.username, password="test") + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", False + ): + resp = self.client.post( + reverse("clubs-list"), + { + "code": "new-club", + "name": "New Club", + "description": "This is a new club.", + "tags": [{"name": "Undergraduate"}], + "email": "newclub@example.com", + }, + content_type="application/json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn("The approval queue is not currently open.", str(resp.content)) + self.assertFalse(Club.objects.filter(code="new-club").exists()) + def test_club_approve(self): """ Test approving an existing unapproved club. @@ -1036,6 +1140,58 @@ def test_club_approve(self): self.assertIsNotNone(self.club1.approved_on) self.assertIsNotNone(self.club1.approved_by) + def test_club_display_after_deactivation_for_permissioned_vs_non_permissioned(self): + """ + Test club retrieval after deactivation script runs. Non-permissioned users + should see the last approved version of the club. Permissioned users (e.g. + admins, club members) should see the most up-to-date version. + """ + # club is approved before deactivation + self.assertTrue(self.club1.approved) + + call_command("deactivate", "all", "--force") + + club = self.club1 + club.refresh_from_db() + + # after deactivation, club should not be approved and should not have approver + self.assertIsNone(club.approved) + self.assertIsNone(club.approved_by) + + # non-permissioned users should see the last approved version + non_admin_resp = self.client.get(reverse("clubs-detail", args=(club.code,))) + self.assertEqual(non_admin_resp.status_code, 200) + non_admin_data = non_admin_resp.json() + self.assertTrue(non_admin_data["approved"]) + + # permissioned users should see the club as it is in the DB + self.client.login(username=self.user5.username, password="test") + admin_resp = self.client.get(reverse("clubs-detail", args=(club.code,))) + self.assertEqual(admin_resp.status_code, 200) + admin_data = admin_resp.json() + self.assertIsNone(admin_data["approved"]) + self.client.logout() + + cache.clear() + + # reversing the order of operations shouldn't change anything + self.client.login(username=self.user5.username, password="test") + admin_resp = self.client.get(reverse("clubs-detail", args=(club.code,))) + self.assertEqual(admin_resp.status_code, 200) + admin_data = admin_resp.json() + self.assertIsNone(admin_data["approved"]) + self.client.logout() + + non_admin_resp = self.client.get(reverse("clubs-detail", args=(club.code,))) + self.assertEqual(non_admin_resp.status_code, 200) + non_admin_data = non_admin_resp.json() + self.assertTrue(non_admin_data["approved"]) + + # club object itself shouldn't have changed + club.refresh_from_db() + self.assertFalse(club.active) + self.assertIsNone(club.approved) + def test_club_create_url_sanitize(self): """ Test creating clubs with malicious URLs. @@ -1044,20 +1200,23 @@ def test_club_create_url_sanitize(self): exploit_string = "javascript:alert(1)" - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Bad Club", - "tags": [], - "facebook": exploit_string, - "twitter": exploit_string, - "instagram": exploit_string, - "website": exploit_string, - "linkedin": exploit_string, - "github": exploit_string, - }, - content_type="application/json", - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Bad Club", + "tags": [], + "facebook": exploit_string, + "twitter": exploit_string, + "instagram": exploit_string, + "website": exploit_string, + "linkedin": exploit_string, + "github": exploit_string, + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [400, 403], resp.content) def test_club_create_description_sanitize_good(self): @@ -1079,16 +1238,20 @@ def test_club_create_description_sanitize_good(self): <img src=\"/test.png\">""" self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Penn Labs", - "tags": [{"name": "Undergraduate"}], - "description": test_good_string, - "email": "example@example.com", - }, - content_type="application/json", - ) + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Penn Labs", + "tags": [{"name": "Undergraduate"}], + "description": test_good_string, + "email": "example@example.com", + }, + content_type="application/json", + ) cache.clear() self.assertIn(resp.status_code, [200, 201], resp.content) @@ -1105,16 +1268,20 @@ def test_club_create_description_sanitize_bad(self): test_bad_string = '<script>alert(1);</script><img src="javascript:alert(1)">' self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Penn Labs", - "tags": [{"name": "Graduate"}], - "description": test_bad_string, - "email": "example@example.com", - }, - content_type="application/json", - ) + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Penn Labs", + "tags": [{"name": "Graduate"}], + "description": test_bad_string, + "email": "example@example.com", + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [200, 201], resp.content) resp = self.client.get(reverse("clubs-detail", args=("penn-labs",))) @@ -1130,9 +1297,12 @@ def test_club_create_no_input(self): """ self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), {}, content_type="application/json" - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), {}, content_type="application/json" + ) self.assertIn(resp.status_code, [400, 403], resp.content) def test_club_create_nonexistent_tag(self): @@ -1141,35 +1311,42 @@ def test_club_create_nonexistent_tag(self): """ self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Penn Labs", - "description": "We code stuff.", - "email": "contact@pennlabs.org", - "tags": [{"name": "totally definitely nonexistent tag"}], - }, - content_type="application/json", - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Penn Labs", + "description": "We code stuff.", + "email": "contact@pennlabs.org", + "tags": [{"name": "totally definitely nonexistent tag"}], + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [400, 404], resp.content) def test_club_create_no_auth(self): """ Creating a club without authentication should result in an error. """ - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Penn Labs", - "description": "We code stuff.", - "email": "contact@pennlabs.org", - "facebook": "966590693376781", - "twitter": "@Penn", - "instagram": "@uofpenn", - "tags": [], - }, - content_type="application/json", - ) + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Penn Labs", + "description": "We code stuff.", + "email": "contact@pennlabs.org", + "facebook": "966590693376781", + "twitter": "@Penn", + "instagram": "@uofpenn", + "tags": [], + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [400, 403], resp.content) def test_club_create(self): @@ -1185,31 +1362,34 @@ def test_club_create(self): self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - { - "name": "Penn Labs", - "description": "We code stuff.", - "badges": [{"label": "SAC Funded"}], - "tags": [ - {"name": tag1.name}, - {"name": tag2.name}, - {"name": "Graduate"}, - ], - "target_schools": [{"id": school1.id}], - "email": "example@example.com", - "facebook": "https://www.facebook.com/groups/966590693376781/" - + "?ref=nf_target&fref=nf", - "twitter": "https://twitter.com/Penn", - "instagram": "https://www.instagram.com/uofpenn/?hl=en", - "website": "https://pennlabs.org", - "linkedin": "https://www.linkedin.com" - "/school/university-of-pennsylvania/", - "youtube": "https://youtu.be/dQw4w9WgXcQ", - "github": "https://github.com/pennlabs", - }, - content_type="application/json", - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + { + "name": "Penn Labs", + "description": "We code stuff.", + "badges": [{"label": "SAC Funded"}], + "tags": [ + {"name": tag1.name}, + {"name": tag2.name}, + {"name": "Graduate"}, + ], + "target_schools": [{"id": school1.id}], + "email": "example@example.com", + "facebook": "https://www.facebook.com/groups/966590693376781/" + + "?ref=nf_target&fref=nf", + "twitter": "https://twitter.com/Penn", + "instagram": "https://www.instagram.com/uofpenn/?hl=en", + "website": "https://pennlabs.org", + "linkedin": "https://www.linkedin.com" + "/school/university-of-pennsylvania/", + "youtube": "https://youtu.be/dQw4w9WgXcQ", + "github": "https://github.com/pennlabs", + }, + content_type="application/json", + ) self.assertIn(resp.status_code, [200, 201], resp.content) # ensure club was actually created @@ -1249,11 +1429,14 @@ def test_club_create_duplicate(self): """ self.client.login(username=self.user5.username, password="test") - resp = self.client.post( - reverse("clubs-list"), - {"name": "Test Club", "tags": []}, - content_type="application/json", - ) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True), patch( + "django.conf.settings.NEW_APPROVAL_QUEUE_OPEN", True + ): + resp = self.client.post( + reverse("clubs-list"), + {"name": "Test Club", "tags": []}, + content_type="application/json", + ) self.assertIn(resp.status_code, [400, 403], resp.content) def test_club_list_search(self): @@ -1578,16 +1761,22 @@ def test_club_modify(self): """ tag3 = Tag.objects.create(name="College") - self.client.login(username=self.user5.username, password="test") - resp = self.client.patch( - reverse("clubs-detail", args=(self.club1.code,)), - { - "description": "We do stuff.", - "tags": [{"name": tag3.name}, {"name": "Graduate"}], - }, - content_type="application/json", + Membership.objects.create( + person=self.user1, club=self.club1, role=Membership.ROLE_OWNER ) - self.assertIn(resp.status_code, [200, 201], resp.content) + + self.client.login(username=self.user1.username, password="test") + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True): + resp = self.client.patch( + reverse("clubs-detail", args=(self.club1.code,)), + { + "description": "We do stuff.", + "tags": [{"name": tag3.name}, {"name": "Graduate"}], + }, + content_type="application/json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) # ensure that changes were made resp = self.client.get(reverse("clubs-detail", args=(self.club1.code,))) @@ -2199,22 +2388,50 @@ def test_club_sensitive_field_renew(self): # login to officer user self.client.login(username=self.user4.username, password="test") - for field in {"name", "description"}: - # edit sensitive field - resp = self.client.patch( - reverse("clubs-detail", args=(club.code,)), - {field: "New Club Name/Description"}, - content_type="application/json", - ) - self.assertIn(resp.status_code, [200, 201], resp.content) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", False): + for field in {"name"}: + # edit sensitive field + resp = self.client.patch( + reverse("clubs-detail", args=(club.code,)), + {field: "New Club Name/Description"}, + content_type="application/json", + ) + self.assertIn(resp.status_code, [400], resp.content) - # ensure club is marked as not approved - club.refresh_from_db() - self.assertFalse(club.approved) + # ensure club is marked as approved (request didn't go through) + club.refresh_from_db() + self.assertTrue(club.approved) - # reset to approved - club.approved = True - club.save(update_fields=["approved"]) + # store result of approval history query + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + self.assertIn(resp.status_code, [200], resp.content) + previous_history = json.loads(resp.content.decode("utf-8")) + self.assertTrue(previous_history[0]["approved"]) + + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True): + for field in {"name"}: + # edit sensitive field + resp = self.client.patch( + reverse("clubs-detail", args=(club.code,)), + {field: "New Club Name/Description"}, + content_type="application/json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + # find the approval history + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + self.assertIn(resp.status_code, [200], resp.content) + history = json.loads(resp.content.decode("utf-8")) + self.assertEqual(len(history), len(previous_history) + 1) + self.assertFalse(history[0]["approved"]) + + # ensure club is marked as not approved + club.refresh_from_db() + self.assertFalse(club.approved) + + # reset to approved + club.approved = True + club.save(update_fields=["approved"]) # login to superuser account self.client.login(username=self.user5.username, password="test") @@ -2510,6 +2727,7 @@ def test_club_fair_registration(self): end_time=now + datetime.timedelta(days=14), registration_start_time=now - datetime.timedelta(days=1), registration_end_time=now + datetime.timedelta(days=1), + virtual=True, questions=json.dumps( [ { @@ -2659,7 +2877,7 @@ def test_user_profile(self): def test_alumni_page(self): """ - Ensure alumni page can be seen, even for users who are not logged in. + Ensure alumni page can be seen """ now = timezone.now() for i, user in enumerate([self.user1, self.user2, self.user3]): @@ -2672,6 +2890,10 @@ def test_alumni_page(self): # fetch alumni page resp = self.client.get(reverse("clubs-alumni", args=(self.club1.code,))) + self.assertIn(resp.status_code, [403], resp.content) + + self.client.login(username=self.user4.username, password="test") + resp = self.client.get(reverse("clubs-alumni", args=(self.club1.code,))) self.assertIn(resp.status_code, [200], resp.content) data = resp.json() @@ -2688,7 +2910,7 @@ def test_execute_script(self): self.assertIn(resp.status_code, [200], resp.content) self.assertIsInstance(resp.data, list, resp.content) - resp = self.client.post(reverse("scripts"), {"action": "graduate_users"}) + resp = self.client.post(reverse("scripts"), {"action": "find_broken_images"}) self.assertIn(resp.status_code, [200], resp.content) self.assertIn("output", resp.data, resp.content) @@ -2868,3 +3090,77 @@ def test_event_add_meeting(self): self.event1.refresh_from_db() self.assertIn("url", resp.data, resp.content) self.assertTrue(self.event1.url, resp.content) + + def test_club_approval_response_templates(self): + """ + Test operations and permissions for club approval response templates. + """ + + # Log in as superuser + self.client.login(username=self.user5.username, password="test") + + # Create a new template + resp = self.client.post( + reverse("templates-list"), + { + "title": "Test template", + "content": "This is a new template", + }, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 201) + + # Create another template + template = ClubApprovalResponseTemplate.objects.create( + author=self.user5, + title="Another template", + content="This is another template", + ) + + # List templates + resp = self.client.get(reverse("templates-list")) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()), 2) + + # Update a template + resp = self.client.patch( + reverse("templates-detail", args=[template.id]), + {"title": "Updated title"}, + content_type="application/json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Verify update + template.refresh_from_db() + self.assertEqual(template.title, "Updated title") + + # Delete the template + resp = self.client.delete(reverse("templates-detail", args=[template.id])) + self.assertEqual(resp.status_code, 204) + + # Verify the template has been deleted + self.assertIsNone( + ClubApprovalResponseTemplate.objects.filter(id=template.id).first() + ) + + # Test non-superuser access restrictions + self.client.logout() + self.client.login( + username=self.user4.username, password="test" + ) # non-superuser + + # Non-superuser shouldn't be able to create a template + resp = self.client.post( + reverse("templates-list"), + {"title": "Template", "content": "This should not exist"}, + content_type="application/json", + ) + self.assertEqual(resp.status_code, 403) + + +class HealthTestCase(TestCase): + def test_health(self): + url = reverse("health") + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data, {"message": "OK"}) diff --git a/frontend/.babelrc b/frontend/.babelrc deleted file mode 100644 index 2781700cf..000000000 --- a/frontend/.babelrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "presets": ["next/babel"], - "plugins": ["istanbul"], - "env": { - "production": { - "plugins": [["styled-components", { "ssr": true }]] - }, - "development": { - "plugins": ["babel-plugin-styled-components"] - } - } -} diff --git a/frontend/.swcrc b/frontend/.swcrc new file mode 100644 index 000000000..823bd0b8e --- /dev/null +++ b/frontend/.swcrc @@ -0,0 +1,9 @@ +{ + "jsc": { + "experimental": { + "plugins": [ + ["swc-plugin-coverage-instrument", {}] + ] + } + } +} \ No newline at end of file diff --git a/frontend/components/ClubCard.tsx b/frontend/components/ClubCard.tsx index 85541a845..4587aee50 100644 --- a/frontend/components/ClubCard.tsx +++ b/frontend/components/ClubCard.tsx @@ -100,7 +100,9 @@ const ClubCard = ({ club, fullWidth }: ClubCardProps): ReactElement => { const { name, active, approved, subtitle, tags, enables_subscription, code } = club const img = club.image_url - const textDescription = shorten(subtitle || 'This club has no description.') + const textDescription = shorten( + subtitle || 'This club has not provided a mission statement.', + ) return ( <CardWrapper className={fullWidth ? '' : 'column is-half-desktop'}> @@ -116,14 +118,6 @@ const ClubCard = ({ club, fullWidth }: ClubCardProps): ReactElement => { {!active && ( <InactiveTag className="tag is-rounded">Inactive</InactiveTag> )} - {approved === null && ( - <InactiveTag className="tag is-rounded"> - Pending Approval - </InactiveTag> - )} - {approved === false && ( - <InactiveTag className="tag is-rounded">Rejected</InactiveTag> - )} <TagGroup tags={tags} /> </div> {img && ( diff --git a/frontend/components/ClubDisplay.tsx b/frontend/components/ClubDisplay.tsx index 7bbafa6d2..c1c75922b 100644 --- a/frontend/components/ClubDisplay.tsx +++ b/frontend/components/ClubDisplay.tsx @@ -1,9 +1,10 @@ import { ReactElement, useEffect } from 'react' +import Masonry from 'react-masonry-css' import styled from 'styled-components' import ClubCard from '../components/ClubCard' import ClubTableRow from '../components/ClubTableRow' -import { mediaMaxWidth, SM } from '../constants/measurements' +import { getNumberFromPx, mediaMaxWidth, SM } from '../constants/measurements' import { Club, Tag } from '../types' const ClubTableRowWrapper = styled.div` @@ -14,6 +15,26 @@ const ClubTableRowWrapper = styled.div` } ` +const StyledMasonry = styled(Masonry)` + display: flex; + width: auto; + margin: -12px; + + .masonry-column { + background-clip: padding-box; + } + + .masonry-column > div { + width: 100% !important; + } + + ${mediaMaxWidth(SM)} { + .masonry-column { + width: 100% !important; + } + } +` + type ClubDisplayProps = { displayClubs: Club[] tags: Tag[] @@ -50,11 +71,18 @@ const ClubDisplay = ({ if (display === 'cards') { return ( - <div className="columns is-multiline is-desktop is-tablet"> + <StyledMasonry + breakpointCols={{ + default: 2, + [getNumberFromPx(SM)]: 1, + }} + className="masonry-grid" + columnClassName="masonry-column" + > {displayClubs.map((club) => ( <ClubCard key={club.code} club={club} /> ))} - </div> + </StyledMasonry> ) } diff --git a/frontend/components/ClubEditPage.tsx b/frontend/components/ClubEditPage.tsx index 462fa002f..72445905e 100644 --- a/frontend/components/ClubEditPage.tsx +++ b/frontend/components/ClubEditPage.tsx @@ -26,6 +26,7 @@ import { School, StudentType, Tag, + UserInfo, VisitType, Year, } from '../types' @@ -42,6 +43,7 @@ import { SHOW_ORG_MANAGEMENT, SITE_NAME, } from '../utils/branding' +import AdminNoteCard from './ClubEditPage/AdminNoteCard' import AdvisorCard from './ClubEditPage/AdvisorCard' import AnalyticsCard from './ClubEditPage/AnalyticsCard' import ApplicationsCard from './ClubEditPage/ApplicationsCard' @@ -74,6 +76,7 @@ type ClubFormProps = { tags: Tag[] studentTypes: StudentType[] tab?: string | null + userInfo?: UserInfo } const ClubForm = ({ @@ -85,6 +88,7 @@ const ClubForm = ({ studentTypes, clubId, tab, + userInfo, }: ClubFormProps): ReactElement => { const [club, setClub] = useState<Club | null>(null) const [isEdit, setIsEdit] = useState<boolean>(typeof clubId !== 'undefined') @@ -237,6 +241,15 @@ const ClubForm = ({ /> ), }, + ...(userInfo !== undefined && userInfo.is_superuser + ? [ + { + name: 'notes', + label: 'Administrator Notes', + content: <AdminNoteCard club={club} />, + }, + ] + : []), { name: 'member', label: OBJECT_TAB_MEMBERSHIP_LABEL, diff --git a/frontend/components/ClubEditPage/AdminNoteCard.tsx b/frontend/components/ClubEditPage/AdminNoteCard.tsx new file mode 100644 index 000000000..810eba016 --- /dev/null +++ b/frontend/components/ClubEditPage/AdminNoteCard.tsx @@ -0,0 +1,46 @@ +import { Field } from 'formik' +import moment from 'moment-timezone' +import { ReactElement } from 'react' + +import { Club } from '../../types' +import { TextField } from '../FormComponents' +import { ModelForm } from '../ModelForm' +import BaseCard from './BaseCard' + +type AdminNoteCardProps = { + club: Club +} + +export default function AdminNoteCard({ + club, +}: AdminNoteCardProps): ReactElement { + const noteTableFields = [ + { label: 'Author', name: 'creator' }, + { label: 'Note', name: 'content' }, + { + label: 'Created On', + name: 'created_at', + converter: (field) => moment(field).format('MMMM Do, YYYY'), + }, + ] + + return ( + <BaseCard title="Notes"> + <p className="mb-3"> + Below is a list of notes about {club.name}. These notes are only visible + to site administrators. + </p> + <ModelForm + baseUrl={`/clubs/${club.code}/adminnotes/`} + fields={ + <> + <Field name="content" as={TextField} type="textarea" required /> + </> + } + tableFields={noteTableFields} + searchableColumns={['content']} + noun="Note" + /> + </BaseCard> + ) +} diff --git a/frontend/components/ClubEditPage/AdvisorCard.tsx b/frontend/components/ClubEditPage/AdvisorCard.tsx index b8762a60f..7be10aae5 100644 --- a/frontend/components/ClubEditPage/AdvisorCard.tsx +++ b/frontend/components/ClubEditPage/AdvisorCard.tsx @@ -1,17 +1,10 @@ import { Field } from 'formik' import { ReactElement, useState } from 'react' -import styled from 'styled-components' -import { RED } from '../../constants/colors' -import { Advisor, Club } from '../../types' -import { - OBJECT_NAME_SINGULAR, - SHOW_MEMBERS, - SITE_ID, - SITE_NAME, -} from '../../utils/branding' +import { Advisor, AdvisorVisibilityType, Club } from '../../types' +import { OBJECT_NAME_SINGULAR, SHOW_MEMBERS } from '../../utils/branding' import { Text } from '../common' -import { CheckboxField, TextField } from '../FormComponents' +import { SelectField, TextField } from '../FormComponents' import { ModelForm } from '../ModelForm' import BaseCard from './BaseCard' @@ -20,10 +13,20 @@ type Props = { validateAdvisors?: (valid: boolean) => void } -const RequireText = styled.p` - color: ${RED}; - margin-top: 1rem; -` +export const VISIBILITY_TYPES = [ + { + value: AdvisorVisibilityType.AdminOnly, + label: 'Admin Only', + }, + { + value: AdvisorVisibilityType.Students, + label: 'Students (Logged In)', + }, + { + value: AdvisorVisibilityType.All, + label: 'All (Public, External)', + }, +] export default function AdvisorCard({ club, @@ -39,7 +42,7 @@ export default function AdvisorCard({ if (newAdvisors.length) { validCount = newAdvisors.filter( (advisor) => - (advisor._status || !advisor._errorMessage) && advisor.public, + (advisor._status || !advisor._errorMessage) && advisor.visibility, ).length } if (validateAdvisors) { @@ -56,19 +59,25 @@ export default function AdvisorCard({ <Field name="email" as={TextField} type="email" /> <Field name="phone" as={TextField} /> <Field - name="public" - as={CheckboxField} + name="visibility" label="Show contact information to the public?" + as={SelectField} + required + choices={VISIBILITY_TYPES} + serialize={({ value }) => value} + isMulti={false} + valueDeserialize={(val) => + VISIBILITY_TYPES.find((x) => x.value === val) + } /> </> ) return ( <> - <BaseCard title="Public Points of Contact"> + <BaseCard title="Points of Contact"> <Text> - Provide points of contact for your organization. These public points - of contact will be shown to the public. + Provide points of contact for your organization. {SHOW_MEMBERS && ( <> {' '} @@ -82,32 +91,8 @@ export default function AdvisorCard({ <ModelForm onUpdate={updateAdvisors} baseUrl={`/clubs/${club.code}/advisors/`} - listParams="&public=true" - defaultObject={{ public: true }} - initialData={club.advisor_set.filter( - ({ public: isPublic }) => isPublic, - )} - fields={fields} - /> - {SITE_ID === 'fyh' && advisorsCount <= 0 && ( - <RequireText> - * At least one public point of contact is required. - </RequireText> - )} - </BaseCard> - - <BaseCard title="Internal Points of Contact"> - <Text> - These private points of contact will be shown to only {SITE_NAME}{' '} - administrators. - </Text> - <ModelForm - baseUrl={`/clubs/${club.code}/advisors/`} - listParams="&public=false" - defaultObject={{ public: false }} - initialData={club.advisor_set.filter( - ({ public: isPublic }) => !isPublic, - )} + defaultObject={{ public: AdvisorVisibilityType.Students }} + initialData={club.advisor_set} fields={fields} /> </BaseCard> diff --git a/frontend/components/ClubEditPage/ApplicationsPage.tsx b/frontend/components/ClubEditPage/ApplicationsPage.tsx index fae8f65bf..99d353ebc 100644 --- a/frontend/components/ClubEditPage/ApplicationsPage.tsx +++ b/frontend/components/ClubEditPage/ApplicationsPage.tsx @@ -418,8 +418,8 @@ export default function ApplicationsPage({ if (applications.length !== 0) { setApplications(applications) setCurrentApplication({ - ...applications[0], - name: format_app_name(applications[0]), + ...applications[applications.length - 1], + name: format_app_name(applications[applications.length - 1]), }) } }) @@ -532,7 +532,7 @@ export default function ApplicationsPage({ CSV. </Text> <Select - options={applications.map((application) => { + options={applications.toReversed().map((application) => { return { ...application, value: application.id, diff --git a/frontend/components/ClubEditPage/ClubEditCard.tsx b/frontend/components/ClubEditPage/ClubEditCard.tsx index 3b1061226..58e380d88 100644 --- a/frontend/components/ClubEditPage/ClubEditCard.tsx +++ b/frontend/components/ClubEditPage/ClubEditCard.tsx @@ -31,14 +31,18 @@ import { FORM_TAG_DESCRIPTION, FORM_TARGET_DESCRIPTION, MEMBERSHIP_ROLE_NAMES, + NEW_APPROVAL_QUEUE_ENABLED, OBJECT_NAME_SINGULAR, OBJECT_NAME_TITLE_SINGULAR, OBJECT_TAB_ADMISSION_LABEL, + REAPPROVAL_QUEUE_ENABLED, SHOW_RANK_ALGORITHM, SITE_ID, SITE_NAME, } from '../../utils/branding' -import { Checkbox, CheckboxLabel, Contact, Text } from '../common' +import { ModalContent } from '../ClubPage/Actions' +import { LiveBanner, LiveSub, LiveTitle } from '../ClubPage/LiveEventsDialog' +import { Checkbox, CheckboxLabel, Contact, Modal, Text } from '../common' import { CheckboxField, CheckboxTextField, @@ -147,6 +151,49 @@ const Card = ({ </div> ) } +interface EmailModalProps { + closeModal: () => void + email: string + setEmail: (inp: string) => void + confirmSubmission: () => void +} + +const EmailModal = ({ + closeModal, + email, + setEmail, + confirmSubmission, +}: EmailModalProps): ReactElement => { + return ( + <Modal + width={'450px'} + show={true} + closeModal={closeModal} + marginBottom={false} + > + <div className="card-content mb-2"> + <Text className="has-text-danger"> + This email will be visible to the public. + <br /> + We recommend that you don’t use a personal email, and instead use a + club email. + </Text> + <Field + name="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + className="input mb-5" + style={{ maxWidth: '350px' }} + ></Field> + <div> + <button onClick={confirmSubmission} className="button is-primary"> + Confirm + </button> + </div> + </div> + </Modal> + ) +} /** * Remove fields in an object that are not part of a whitelist. @@ -186,6 +233,7 @@ export default function ClubEditCard({ isEdit, onSubmit = () => Promise.resolve(undefined), }: ClubEditCardProps): ReactElement { + const [showRankModal, setShowRankModal] = useState<boolean>(false) const [showTargetFields, setShowTargetFields] = useState<boolean>( !!( club.target_majors?.length || @@ -224,6 +272,8 @@ export default function ClubEditCard({ ), ) + const [emailModal, showEmailModal] = useState<boolean>(false) + const submit = (data, { setSubmitting, setStatus }): Promise<void> => { const photo = data.image if (photo !== null) { @@ -394,12 +444,65 @@ export default function ClubEditCard({ { name: 'General', type: 'group', + description: ( + <div className="mb-4"> + <a onClick={() => setShowRankModal(true)}> + How does filling out this information affect your club? + </a> + <Modal + show={showRankModal} + closeModal={() => setShowRankModal(false)} + marginBottom={false} + width="80%" + > + <ModalContent className="content mb-4"> + <h2>How we calculate club rankings</h2> + <hr /> + <h5> + The following positively affects your club's ranking in homepage + search results: + </h5> + <ul> + <li> + Upcoming events with filled out name, description, and image + </li> + <li>Upcoming, open applications for membership</li> + <li> + Having at least 3 active officers, plus a bonus for any + additional non-officer member on the platform + </li> + <li> + Having between 3 and 7 useful tags (please email <Contact />{' '} + if none apply) + </li> + <li> + Posting a public (non-personal) contact email and 2 or more + social links + </li> + <li> + Having a club logo image uploaded and subtitle filled out + </li> + <li> + Filling out a club mission with images and detail (rewarded up + to 1000 words) + </li> + <li>Displaying 3 or more student testimonials (experiences)</li> + <li>Filling out the {FIELD_PARTICIPATION_LABEL} section</li> + <li> + Updating the club listing recently (within the last 8 months) + </li> + </ul> + </ModalContent> + </Modal> + </div> + ), fields: [ { name: 'name', type: 'text', required: true, label: `${OBJECT_NAME_TITLE_SINGULAR} Name`, + disabled: !REAPPROVAL_QUEUE_ENABLED, help: isEdit ? ( <> If you would like to change your {OBJECT_NAME_SINGULAR} URL in @@ -442,10 +545,11 @@ export default function ClubEditCard({ }, { name: 'description', + label: 'Club Mission', required: true, - help: `Changing this field will require reapproval from the ${APPROVAL_AUTHORITY}.`, - placeholder: `Type your ${OBJECT_NAME_SINGULAR} description here!`, + placeholder: `Type your ${OBJECT_NAME_SINGULAR} mission here!`, type: 'html', + hidden: !REAPPROVAL_QUEUE_ENABLED, }, { name: 'tags', @@ -461,6 +565,7 @@ export default function ClubEditCard({ accept: 'image/*', type: 'image', label: `${OBJECT_NAME_TITLE_SINGULAR} Logo`, + disabled: !REAPPROVAL_QUEUE_ENABLED, }, { name: 'size', @@ -790,6 +895,7 @@ export default function ClubEditCard({ const creationDefaults = { subtitle: '', + email: '', email_public: true, accepting_members: false, size: CLUB_SIZES[0].value, @@ -811,9 +917,57 @@ export default function ClubEditCard({ : creationDefaults return ( - <Formik initialValues={initialValues} onSubmit={submit} enableReinitialize> - {({ dirty, isSubmitting }) => ( + <Formik + initialValues={initialValues} + onSubmit={(values, actions) => + submit({ ...values, emailOverride: false }, actions) + } + enableReinitialize + validate={(values) => { + const errors: { email?: string } = {} + if (values.email.includes('upenn.edu') && !emailModal) { + showEmailModal(true) + errors.email = 'Please confirm your email' + } + return errors + }} + validateOnChange={false} + validateOnBlur={false} + > + {({ dirty, isSubmitting, setFieldValue, submitForm, values }) => ( <Form> + {emailModal && ( + <EmailModal + closeModal={() => showEmailModal(false)} + email={values.email} + setEmail={(newEmail) => setFieldValue('email', newEmail)} + confirmSubmission={() => { + showEmailModal(false) + submitForm() + }} + /> + )} + {!REAPPROVAL_QUEUE_ENABLED && ( + <LiveBanner> + <LiveTitle>Queue Closed for Summer Break</LiveTitle> + <LiveSub> + No edits to existing clubs or applications for new clubs will be + submitted for review to OSA. + </LiveSub> + </LiveBanner> + )} + {!NEW_APPROVAL_QUEUE_ENABLED && + REAPPROVAL_QUEUE_ENABLED && + !isEdit && ( + <LiveBanner> + <LiveTitle>Queue Closed for New Clubs</LiveTitle> + <LiveSub> + Submissions for new clubs are closed for the time being. + Please reach out to the Office of Student Affairs at + vpul-pennosa@pobox.upenn.edu with any questions. + </LiveSub> + </LiveBanner> + )} <FormStyle isHorizontal> {fields.map(({ name, description, fields, hidden }, i) => { if (hidden) { @@ -871,7 +1025,11 @@ export default function ClubEditCard({ ) })} <button - disabled={!dirty || isSubmitting} + disabled={ + !dirty || + isSubmitting || + (!NEW_APPROVAL_QUEUE_ENABLED && !isEdit) + } type="submit" className="button is-primary is-large" > diff --git a/frontend/components/ClubEditPage/EventsCard.tsx b/frontend/components/ClubEditPage/EventsCard.tsx index 1a741614f..cdd765d91 100644 --- a/frontend/components/ClubEditPage/EventsCard.tsx +++ b/frontend/components/ClubEditPage/EventsCard.tsx @@ -401,7 +401,7 @@ const CreateContainer = styled.div` align-items: center; ` -const CreateTickets = ({ event }: { event: ClubEvent }) => { +const CreateTickets = ({ event, club }: { event: ClubEvent; club: Club }) => { const [show, setShow] = useState(false) const showModal = () => setShow(true) const hideModal = () => setShow(false) @@ -431,7 +431,11 @@ const CreateTickets = ({ event }: { event: ClubEvent }) => { closeModal={hideModal} marginBottom={false} > - <TicketsModal event={event} onSuccessfulSubmit={hideModal} /> + <TicketsModal + club={club} + event={event} + onSuccessfulSubmit={hideModal} + /> </Modal> )} </CreateContainer> @@ -476,7 +480,7 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement { }} /> <Line /> - <CreateTickets event={event} /> + <CreateTickets event={event} club={club} /> <Line /> <div ref={eventDetailsRef}> <EventPreview event={event} /> diff --git a/frontend/components/ClubEditPage/TicketsModal.tsx b/frontend/components/ClubEditPage/TicketsModal.tsx index 7eec51535..12553498e 100644 --- a/frontend/components/ClubEditPage/TicketsModal.tsx +++ b/frontend/components/ClubEditPage/TicketsModal.tsx @@ -14,7 +14,7 @@ import { } from '../../constants/colors' import { BORDER_RADIUS } from '../../constants/measurements' import { BODY_FONT } from '../../constants/styles' -import { ClubEvent } from '../../types' +import { Club, ClubEvent } from '../../types' import { doApiRequest } from '../../utils' import CoverPhoto from '../EventPage/CoverPhoto' @@ -56,6 +56,7 @@ const notify = ( type TicketItemProps = { ticket: Ticket + club: Club onChange?: (ticket: Ticket) => void onDelete?: () => void deletable: boolean @@ -63,6 +64,7 @@ type TicketItemProps = { const TicketItem: React.FC<TicketItemProps> = ({ ticket: propTicket, + club, onChange, onDelete, deletable, @@ -122,7 +124,7 @@ const TicketItem: React.FC<TicketItemProps> = ({ className="input" value={ticket.price ?? ''} placeholder="Ticket Price" - disabled={!TICKETING_PAYMENT_ENABLED} + disabled={!TICKETING_PAYMENT_ENABLED && !club.beta} onChange={(e) => { const price = e.target.value setTicket({ ...ticket, price }) @@ -221,9 +223,11 @@ type Ticket = { const TicketsModal = ({ event, + club, onSuccessfulSubmit, }: { event: ClubEvent + club: Club onSuccessfulSubmit: () => void }): ReactElement => { const { large_image_url, image_url, club_name, name, id } = event @@ -333,6 +337,7 @@ const TicketsModal = ({ <TicketItem key={index} ticket={ticket} + club={club} deletable={tickets.length > 1} onChange={(newTicket) => { setTickets((t) => diff --git a/frontend/components/ClubPage/Actions.tsx b/frontend/components/ClubPage/Actions.tsx index a0ee5afeb..8d0073f48 100644 --- a/frontend/components/ClubPage/Actions.tsx +++ b/frontend/components/ClubPage/Actions.tsx @@ -12,12 +12,7 @@ import { mediaMaxWidth, mediaMinWidth, SM } from '~/constants' import { BORDER, MEDIUM_GRAY, WHITE } from '../../constants/colors' import { CLUB_APPLY_ROUTE, CLUB_EDIT_ROUTE } from '../../constants/routes' -import { - Club, - ClubApplicationRequired, - QuestionAnswer, - UserInfo, -} from '../../types' +import { Club, ClubApplicationRequired, QuestionAnswer } from '../../types' import { apiCheckPermission, apiSetLikeStatus, doApiRequest } from '../../utils' import { FIELD_PARTICIPATION_LABEL, @@ -85,7 +80,7 @@ const ActionButton = styled.a` type ActionsProps = { club: Club - userInfo: UserInfo + authenticated: boolean style?: CSSProperties className?: string updateRequests: (code: string) => Promise<void> @@ -294,7 +289,7 @@ const Actions = ({ updateRequests={updateRequests} /> )} - {SHOW_APPLICATIONS && !isMembershipOpen && club.accepting_members && ( + {SHOW_APPLICATIONS && !isMembershipOpen && !inClub && ( <Link legacyBehavior href={CLUB_APPLY_ROUTE()} diff --git a/frontend/components/ClubPage/ClubApprovalDialog.tsx b/frontend/components/ClubPage/ClubApprovalDialog.tsx index 6ce2c975f..cace58b99 100644 --- a/frontend/components/ClubPage/ClubApprovalDialog.tsx +++ b/frontend/components/ClubPage/ClubApprovalDialog.tsx @@ -1,9 +1,12 @@ +import { EmotionJSX } from '@emotion/react/types/jsx-namespace' +import moment from 'moment-timezone' import { useRouter } from 'next/router' import { ReactElement, useEffect, useState } from 'react' +import Select from 'react-select' import { CLUB_SETTINGS_ROUTE } from '~/constants/routes' -import { Club, ClubFair, MembershipRank, UserInfo } from '../../types' +import { Club, ClubFair, MembershipRank, Template, UserInfo } from '../../types' import { apiCheckPermission, doApiRequest, @@ -17,6 +20,7 @@ import { SITE_NAME, } from '../../utils/branding' import { Contact, Icon, Modal, Text, TextQuote } from '../common' +import { Chevron } from '../DropdownFilter' import { ModalContent } from './Actions' type Props = { @@ -29,13 +33,114 @@ type ConfirmParams = { message: ReactElement | string } +type HistoricItem = { + approved: boolean | null + approved_on: string | null + approved_by: string | null + approved_comment: string | null + history_date: string +} + +const ClubHistoryDropdown = ({ history }: { history: HistoricItem[] }) => { + const [active, setActive] = useState<boolean>(false) + const [reason, setReason] = useState<string | null>(null) + const getReason = (item: HistoricItem): EmotionJSX.Element | string => { + return item.approved_comment ? ( + item.approved_comment.length > 100 ? ( + <span + style={{ + cursor: 'pointer', + textDecoration: 'underline', + }} + onClick={() => setReason(item.approved_comment)} + > + View Reason + </span> + ) : ( + item.approved_comment + ) + ) : ( + 'No reason provided' + ) + } + return ( + <> + <div + style={{ + cursor: 'pointer', + }} + className="mt-2" + onClick={() => setActive(!active)} + > + {active ? 'Hide' : 'Show'} History + <Chevron + name="chevron-down" + alt="toggle dropdown" + open={active} + color="inherit" + className="ml-1" + /> + </div> + <Modal + show={reason !== null} + closeModal={() => setReason(null)} + marginBottom={false} + width="80%" + > + <ModalContent>{reason}</ModalContent> + </Modal> + {active && ( + <div style={{ maxHeight: '300px', overflowY: 'auto' }}> + {history.map((item, i) => ( + <div + key={i} + className="mt-2" + style={{ + fontSize: '70%', + }} + > + {item.approved === true ? ( + <TextQuote className="py-0"> + <b>Approved</b> by <b>{item.approved_by}</b> on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')}{' '} + - {getReason(item)} + </TextQuote> + ) : item.approved === false ? ( + <TextQuote className="py-0"> + <b>Rejected</b> by <b>{item.approved_by}</b> on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')}{' '} + - {getReason(item)} + </TextQuote> + ) : ( + <TextQuote className="py-0"> + <b>Submitted for re-approval</b> on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')} + </TextQuote> + )} + </div> + ))} + </div> + )} + </> + ) +} + const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { const router = useRouter() const year = getCurrentSchoolYear() + const [history, setHistory] = useState<HistoricItem[]>([]) const [comment, setComment] = useState<string>(club.approved_comment || '') const [loading, setLoading] = useState<boolean>(false) const [confirmModal, setConfirmModal] = useState<ConfirmParams | null>(null) const [fairs, setFairs] = useState<ClubFair[]>([]) + const [templates, setTemplates] = useState<Template[]>([]) + const [selectedTemplates, setSelectedTemplates] = useState<Template[]>([]) const canApprove = apiCheckPermission('clubs.approve_club') const seeFairStatus = apiCheckPermission('clubs.see_fair_status') @@ -54,7 +159,37 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { .then((resp) => resp.json()) .then(setFairs) } - }, []) + + if (canApprove) { + doApiRequest('/templates/?format=json') + .then((resp) => resp.json()) + .then(setTemplates) + } + + if (isOfficer || canApprove) { + doApiRequest(`/clubs/${club.code}/history/?format=json`) + .then((resp) => resp.json()) + .then((data) => { + // Get last version of club for each change in approved status + const lastVersions: HistoricItem[] = [] + + for (let i = data.length - 1; i >= 0; i--) { + const item = data[i] + const lastItem = lastVersions[lastVersions.length - 1] + + if (item.approved !== lastItem?.approved || !lastItem) { + lastVersions.push(item) + } + } + lastVersions.reverse() // Avoids O(n^2) of unshift() method + setHistory(lastVersions) + }) + } + + setComment( + selectedTemplates.map((template) => template.content).join('\n\n'), + ) + }, [selectedTemplates]) return ( <> @@ -117,6 +252,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { > <Icon name="x" /> Revoke Approval </button> + <ClubHistoryDropdown history={history} /> </div> )} {(club.active || canDeleteClub) && club.approved !== true ? ( @@ -200,6 +336,43 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { className="textarea mb-4" placeholder="Enter approval or rejection notes here! Your notes will be emailed to the requester when you approve or reject this request." ></textarea> + <div className="field is-grouped mb-3"> + <div className="control is-expanded"> + <Select + isMulti + isClearable + placeholder="Select templates" + options={templates.map((template) => ({ + value: template.id, + label: template.title, + content: template.content, + author: template.author, + }))} + onChange={(selectedOptions) => { + if (selectedOptions) { + const selected = selectedOptions.map((option) => ({ + id: option.value, + title: option.label, + content: option.content, + author: option.author, + })) + setSelectedTemplates(selected) + } else { + setSelectedTemplates([]) + } + }} + /> + </div> + <div className="control"> + <button + className="button is-primary" + onClick={() => router.push('/admin/templates')} + > + <Icon name="edit" /> + Edit Templates + </button> + </div> + </div> </> )} <div className="buttons"> @@ -316,6 +489,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { </button> </> )} + <ClubHistoryDropdown history={history} /> </div> ) : null} {(seeFairStatus || isOfficer) && fairs.length > 0 && ( diff --git a/frontend/components/ClubPage/Description.tsx b/frontend/components/ClubPage/Description.tsx index 7ce9b30e1..48f1d7231 100644 --- a/frontend/components/ClubPage/Description.tsx +++ b/frontend/components/ClubPage/Description.tsx @@ -20,7 +20,7 @@ type Props = { const Description = ({ club }: Props): ReactElement => ( <Wrapper> <div style={{ width: '100%' }}> - <StrongText>Description</StrongText> + <StrongText>Club Mission</StrongText> <div className="content" dangerouslySetInnerHTML={{ diff --git a/frontend/components/ClubPage/EventCarousel.tsx b/frontend/components/ClubPage/EventCarousel.tsx new file mode 100644 index 000000000..0916d1087 --- /dev/null +++ b/frontend/components/ClubPage/EventCarousel.tsx @@ -0,0 +1,100 @@ +import 'swiper/css' +import 'swiper/css/navigation' +import 'swiper/css/pagination' + +import React, { useState } from 'react' +import styled from 'styled-components' +import { Navigation, Pagination } from 'swiper/modules' +import { Swiper, SwiperSlide } from 'swiper/react' + +import { ClubEvent } from '../../types' +import { Icon, StrongText } from '../common' +import Modal from '../common/Modal' +import EventCard from '../EventPage/EventCard' +import EventModal from '../EventPage/EventModal' + +const Arrow = styled.div` + z-index: 100; + opacity: 0.6; + transition-duration: 300ms; + cursor: pointer; + padding: 12px; + position: absolute; + top: 50%; + + &:hover { + opacity: 1; + } +` + +const CarouselWrapper = styled.div` + padding: 10px 35px; +` + +type EventsProps = { + data: ClubEvent[] +} + +const EventCarousel = ({ data }: EventsProps) => { + const [show, setShow] = useState(false) + const [modalData, setModalData] = useState<ClubEvent>() + + const showModal = (entry: ClubEvent) => { + setModalData(entry) + setShow(true) + } + const hideModal = () => setShow(false) + + return ( + <div + style={{ + position: 'relative', + }} + > + <div> + <StrongText className="mb-0">Events</StrongText> + <small>Click on an event to get more details.</small> + </div> + + <CarouselWrapper> + <Swiper + modules={[Navigation, Pagination]} + spaceBetween={50} + navigation={{ nextEl: '.arrow-left', prevEl: '.arrow-right' }} + draggable + scrollbar={{ draggable: true }} + centeredSlides + centeredSlidesBounds + slidesPerView="auto" + > + {data.map((entry, index) => ( + <SwiperSlide + key={index} + style={{ + maxWidth: '250px', + cursor: 'pointer', + boxSizing: 'border-box', + }} + onClick={() => showModal(entry)} + > + <EventCard event={entry} key={index} /> + </SwiperSlide> + ))} + </Swiper> + </CarouselWrapper> + <Arrow className="arrow-left" style={{ right: '-15px' }}> + <Icon name="chevron-right" size={'2.2rem'} /> + </Arrow> + <Arrow className=" arrow-right" style={{ left: '-15px' }}> + <Icon name="chevron-left" size={'2.2rem'} /> + </Arrow> + {show && ( + <Modal show={show} closeModal={hideModal} marginBottom={false}> + {modalData && <EventModal event={modalData} />} + </Modal> + )} + </div> + ) +} + +export default EventCarousel diff --git a/frontend/components/ClubPage/Events.tsx b/frontend/components/ClubPage/Events.tsx index 1c5422c18..daff4c790 100644 --- a/frontend/components/ClubPage/Events.tsx +++ b/frontend/components/ClubPage/Events.tsx @@ -6,8 +6,6 @@ import { DARK_BLUE, HOVER_GRAY, PURPLE, WHITE } from '../../constants/colors' import { M2, M3 } from '../../constants/measurements' import { ClubEvent, ClubEventType } from '../../types' import { Card, Icon, StrongText } from '../common' -import Modal from '../common/Modal' -import EventModal from '../EventPage/EventModal' type EventsProps = { data: ClubEvent[] @@ -31,7 +29,8 @@ const Wrapper = styled.div` margin-bottom: 0.5rem; display: flex; cursor: pointer; - border-radius: 3px; + border-radius: 8px; + padding: 2px; &:hover { background-color: ${HOVER_GRAY}; @@ -70,11 +69,6 @@ const Event = ({ entry }: { entry: ClubEvent }): ReactElement => { </SmallParagraph> </div> </Wrapper> - {show && ( - <Modal show={show} closeModal={hideModal} marginBottom={false}> - <EventModal event={entry} showDetailsButton={false} /> - </Modal> - )} </> ) } diff --git a/frontend/components/ClubPage/LiveEventsDialog.tsx b/frontend/components/ClubPage/LiveEventsDialog.tsx index f43eab824..c73fad89a 100644 --- a/frontend/components/ClubPage/LiveEventsDialog.tsx +++ b/frontend/components/ClubPage/LiveEventsDialog.tsx @@ -13,7 +13,7 @@ import { ClubEventType, MembershipRank } from '../../types' import { doApiRequest, useSetting } from '../../utils' import { FAIR_NAME, MEMBERSHIP_ROLE_NAMES } from '../../utils/branding' -const LiveBanner = styled.div` +export const LiveBanner = styled.div` padding: 20px; border-radius: 5px; background-image: radial-gradient( @@ -37,13 +37,13 @@ const LiveBanner = styled.div` margin-bottom: 10px; ` -const LiveTitle = styled.div` +export const LiveTitle = styled.div` font-size: ${M4}; font-weight: bold; color: white; ` -const LiveSub = styled.div` +export const LiveSub = styled.div` margin-top: -3px; margin-bottom: 3px; font-size: ${M2}; @@ -73,13 +73,16 @@ const WhiteButton = styled.a` interface LiveEventsDialogProps { isPreFair: boolean isFair: boolean + isVirtual: boolean } const LiveEventsDialog = ({ isPreFair, isFair, + isVirtual, }: LiveEventsDialogProps): ReactElement | null => { const fairName = useSetting('FAIR_NAME') + const fairContactEmail = useSetting('FAIR_CONTACT') const [liveEventCount, setLiveEventCount] = useState<number>(0) useEffect(() => { @@ -99,7 +102,7 @@ const LiveEventsDialog = ({ return ( <LiveBanner> - {isPreFair && ( + {isPreFair && isVirtual && ( <Link legacyBehavior href={FAIR_OFFICER_GUIDE_ROUTE} @@ -111,7 +114,7 @@ const LiveEventsDialog = ({ </WhiteButton> </Link> )} - {isFair && ( + {isFair && isVirtual && ( <Link href={LIVE_EVENTS} as={LIVE_EVENTS} passHref legacyBehavior> <WhiteButton>See Live Events</WhiteButton> </Link> @@ -121,8 +124,8 @@ const LiveEventsDialog = ({ </Link> <LiveTitle>{fairName}</LiveTitle> <LiveSub> - {liveEventCount === 0 ? ( - `Get ready for the virtual ${FAIR_NAME} fair! If you need help registering email contact@pennclubs.com` + {liveEventCount === 0 || !isVirtual ? ( + `Get ready for the ${isVirtual ? 'virtual ' : ''}${FAIR_NAME} fair! For any issues with registering, email ${fairContactEmail}.` ) : ( <> {liveEventCount}{' '} diff --git a/frontend/components/ClubPage/QuestionList.tsx b/frontend/components/ClubPage/QuestionList.tsx index bd684856d..e1a7c7518 100644 --- a/frontend/components/ClubPage/QuestionList.tsx +++ b/frontend/components/ClubPage/QuestionList.tsx @@ -39,7 +39,6 @@ const QuestionList = ({ sortBy, }: QuestionListProps): ReactElement => { const [formSubmitted, setFormSubmitted] = useState(false) - const handleSubmit = (data, { setSubmitting, setStatus }) => { doApiRequest(`/clubs/${code}/questions/?format=json`, { method: 'POST', diff --git a/frontend/components/DisplayButtons.tsx b/frontend/components/DisplayButtons.tsx index d45633674..3f44181cd 100644 --- a/frontend/components/DisplayButtons.tsx +++ b/frontend/components/DisplayButtons.tsx @@ -74,10 +74,10 @@ const DisplayButtons = ({ <Link href="/create" className="button is-small is-primary"> <Icon name="plus" - alt={`create ${OBJECT_NAME_SINGULAR}`} + alt={`register ${OBJECT_NAME_SINGULAR}`} style={iconStylesDark} /> - Add {OBJECT_NAME_TITLE_SINGULAR} + Register {OBJECT_NAME_TITLE_SINGULAR} </Link> )} </DisplayButtonsTag> diff --git a/frontend/components/DropdownFilter.tsx b/frontend/components/DropdownFilter.tsx index 3805f3089..0ad45306b 100644 --- a/frontend/components/DropdownFilter.tsx +++ b/frontend/components/DropdownFilter.tsx @@ -73,17 +73,17 @@ const TableContainer = styled.div` } ` -const Chevron = styled(Icon)<{ open?: boolean }>` +export const Chevron = styled(Icon)<{ open?: boolean; color?: string }>` cursor: pointer; - color: ${CLUBS_GREY}; + color: ${({ color }) => color ?? CLUBS_GREY}; transform: rotate(0deg) translateY(0); transition: transform ${ANIMATION_DURATION}ms ease; - ${({ open }) => open && 'transform: rotate(180deg) translateY(-4px);'} + ${({ open }) => open && 'transform: rotate(180deg);'} ${mediaMaxWidth(MD)} { margin-top: 0.1em !important; margin-left: 0.1em !important; - color: ${LIGHT_GRAY}; + color: ${({ color }) => color ?? LIGHT_GRAY}; ${({ open }) => open && 'transform: rotate(180deg)'} } ` diff --git a/frontend/components/EmbedOption.tsx b/frontend/components/EmbedOption.tsx index 2f4e8c9d1..54af4f99a 100644 --- a/frontend/components/EmbedOption.tsx +++ b/frontend/components/EmbedOption.tsx @@ -184,9 +184,8 @@ const EmbedOption = (props: Props): ReactElement => { <h1>Embed Content</h1> <p> You can use this tool to embed multimedia content into your club - description. If you run into any issues using the tool, please - contact <Contact />. Here are examples of some of the things you can - embed. + mission. If you run into any issues using the tool, please contact{' '} + <Contact />. Here are examples of some of the things you can embed. </p> <div className="content mb-3"> <ul> diff --git a/frontend/components/EventPage/EventModal.tsx b/frontend/components/EventPage/EventModal.tsx index 34ffd7ad1..322258880 100644 --- a/frontend/components/EventPage/EventModal.tsx +++ b/frontend/components/EventPage/EventModal.tsx @@ -1,18 +1,14 @@ import Color from 'color' -import Link from 'next/link' import React, { ReactElement, useEffect, useState } from 'react' import TimeAgo from 'react-timeago' import styled from 'styled-components' -import { Icon } from '../../components/common' -import { CLUB_ROUTE, ZOOM_BLUE } from '../../constants' +import { Icon, Text } from '../../components/common' +import { ZOOM_BLUE } from '../../constants' import { MEDIUM_GRAY } from '../../constants/colors' import { ClubEvent } from '../../types' import { doApiRequest } from '../../utils' -import { - OBJECT_NAME_SINGULAR, - OBJECT_NAME_TITLE_SINGULAR, -} from '../../utils/branding' +import { OBJECT_NAME_SINGULAR } from '../../utils/branding' import { ClubName, EventLink, EventName } from './common' import CoverPhoto from './CoverPhoto' import DateInterval from './DateInterval' @@ -121,39 +117,6 @@ const LiveEventUpdater = ({ return null } -/** - * Buttons that allow you to bookmark and subscribe to a club. - */ -const ActionButtons = ({ - club: code, - isTicketEvent, - setDisplayTicketModal, - ticketCount, - userHasTickets, -}): ReactElement | null => { - return ( - <> - {isTicketEvent && ( - <> - <Link href="/"> - <button - disabled={ticketCount === 0} - className="button is-success is-small" - > - {ticketCount === 0 && !userHasTickets - ? ' SOLD OUT' - : userHasTickets - ? ' View Tickets' - : ' Get Tickets'}{' '} - <Icon name="credit-card" className="ml-2" /> - </button> - </Link> - </> - )} - </> - ) -} - type LiveStatsData = { attending: number attended: number @@ -196,31 +159,27 @@ export const LiveStats = ({ const EventModal = (props: { event: ClubEvent - showDetailsButton?: boolean onLinkClicked?: () => void }): ReactElement => { - const { event, showDetailsButton, onLinkClicked } = props + const { event, onLinkClicked } = props const { large_image_url, image_url, - club, club_name, start_time, end_time, - // ticketed, + ticketed, name, url, description, + id, } = event - const ticketed = true const [userCount, setUserCount] = useState<LiveStatsData | null>(null) - const [ticketCount, setTicketCount] = useState<number | null>(null) + const [tickets, setTickets] = useState<Record< + string, + { total: number; available: number; price: number } + > | null>(null) const [userHasTickets, setUserHasTickets] = useState<boolean | null>(null) - const [displayTicketModal, setDisplayTicketModal] = useState<boolean>(false) - const [availableTickets, setAvailableTickets] = useState<Array<JSON> | null>( - null, - ) - const now = new Date() const startDate = new Date(start_time) const endDate = new Date(end_time) @@ -235,18 +194,26 @@ const EventModal = (props: { setUserCount(resp) }) } - // TODO: CHANGE TO event.ticketed instead of true when that is added if (ticketed) { - setTicketCount(0) // TODO: CHANGE BACK TO 0 doApiRequest(`/events/${event.id}/tickets/`) .then((resp) => resp.json()) .then((resp) => { - if (resp.available) { - setAvailableTickets(resp.available) - for (let i = 0; i < resp.available.length; i++) { - setTicketCount(ticketCount + resp.available[i].count) - } - } + const ticketMap = resp.totals.reduce( + (acc, cur) => ({ + ...acc, + [cur.type]: { + total: cur.count, + available: + resp.available.find((t) => t.type === cur.type)?.count ?? 0, + price: cur.price, + }, + }), + {}, + ) as Record< + string, + { total: number; available: number; price: number } + > + setTickets(ticketMap) }) setUserHasTickets(false) doApiRequest(`/tickets/`) @@ -264,7 +231,7 @@ const EventModal = (props: { useEffect(refreshLiveData, []) - return !displayTicketModal ? ( + return ( <ModalContainer> <CoverPhoto image={large_image_url ?? image_url} @@ -307,43 +274,27 @@ const EventModal = (props: { ))}{' '} {userCount != null && <LiveStats stats={userCount} />} <StyledDescription contents={description} /> - {showDetailsButton !== false && event.club != null && ( - <div className="is-clearfix"> - <div className="buttons is-pulled-right"> - {club != null && ( - <ActionButtons - club={club} - isTicketEvent={ticketed} - setDisplayTicketModal={setDisplayTicketModal} - ticketCount={ticketCount} - userHasTickets={userHasTickets} - /> - )} - <Link - legacyBehavior - href={CLUB_ROUTE()} - as={CLUB_ROUTE(event.club)} - > - <a - className="button is-link is-small" - onClick={(e) => { - if (!event.club) { - e.preventDefault() - } - }} - > - See {OBJECT_NAME_TITLE_SINGULAR} Details{' '} - <Icon name="chevrons-right" className="ml-2" /> - </a> - </Link> - </div> + {tickets && + Object.entries(tickets).map(([type, counts]) => ( + <Text key={type}> + {type}: {counts.available} tickets available / {counts.total}{' '} + total + </Text> + ))} + <div className="is-clearfix"> + <div className="buttons is-pulled-right"> + {userHasTickets && ( + <a className="button is-secondary" href="/settings/#Tickets"> + Owned Tickets + </a> + )} + <a className="button is-primary" href={`/events/${id}`}> + Event Page + </a> </div> - )} + </div> </EventDetails> </ModalContainer> - ) : ( - // TODO: THIS IS NOTHING RN, SHOULD DISPLAY ALL AVAILABLE TICKETS - <ModalContainer>""</ModalContainer> ) } diff --git a/frontend/components/EventPage/HappeningNow.tsx b/frontend/components/EventPage/HappeningNow.tsx index 52d97b434..555c402fa 100644 --- a/frontend/components/EventPage/HappeningNow.tsx +++ b/frontend/components/EventPage/HappeningNow.tsx @@ -16,13 +16,13 @@ const blink = keyframes` ` const HappeningNowContainer = styled.p<{ - urgent?: boolean + $urgent?: boolean floatRight?: boolean }>` font-size: 14px; font-weight: 500; ${({ floatRight }) => (floatRight ? 'float: right;' : '')} - ${({ urgent }) => (urgent ? UrgentText : '')} + ${({ $urgent }) => ($urgent ? UrgentText : '')} ` const HappeningNow = (props: { diff --git a/frontend/components/FormComponents.tsx b/frontend/components/FormComponents.tsx index c606c7e65..73a1c6178 100644 --- a/frontend/components/FormComponents.tsx +++ b/frontend/components/FormComponents.tsx @@ -643,6 +643,7 @@ export const FileField = useFieldWrapper( value, isImage = false, canDelete = false, + disabled = false, }: BasicFormField & AnyHack): ReactElement => { const { setFieldValue } = useFormikContext() @@ -681,7 +682,12 @@ export const FileField = useFieldWrapper( <img style={{ maxWidth: 300 }} src={imageUrl} /> ))} <div className="file"> - <label className="file-label"> + <label + className="file-label" + style={{ + cursor: disabled ? 'not-allowed' : 'pointer', + }} + > <input className="file-input" type="file" @@ -693,11 +699,14 @@ export const FileField = useFieldWrapper( }} onBlur={onBlur} placeholder={placeholder} + onClick={(e) => { + if (disabled) { + e.preventDefault() + } + }} /> <span className="file-cta"> - <span className="file-label"> - Choose a {isImage ? 'image' : 'file'}... - </span> + Choose a {isImage ? 'image' : 'file'}... </span> </label> {imageUrl && (canDelete || isImage) && ( diff --git a/frontend/components/Header/Burger.tsx b/frontend/components/Header/Burger.tsx index a9c9d89b9..0a76bd1c1 100644 --- a/frontend/components/Header/Burger.tsx +++ b/frontend/components/Header/Burger.tsx @@ -4,6 +4,9 @@ const Burger = ({ toggle }: BurgerProps): ReactElement => ( <a role="button" className="navbar-burger burger" + style={{ + marginLeft: '8px', + }} aria-label="menu" aria-expanded="false" data-target="navbarBasicExample" diff --git a/frontend/components/Header/Links.tsx b/frontend/components/Header/Links.tsx index 4eb38f118..18e773019 100644 --- a/frontend/components/Header/Links.tsx +++ b/frontend/components/Header/Links.tsx @@ -55,6 +55,20 @@ const LoginButton = styled.a` } ` +export const MobileLoginButton = styled(LoginButton)` + display: none; + margin-right: 0; + ${mediaMaxWidth(MD)} { + display: inline-flex; + } +` +const DesktopLoginButton = styled(LoginButton)` + margin-left: 20px; + ${mediaMaxWidth(MD)} { + display: none; + } +` + const StyledLinkAnchor = styled.a` padding: ${LINK_MARGIN} 20px; color: ${BANNER_TEXT} !important; @@ -116,13 +130,13 @@ const Links = ({ userInfo, authenticated, show }: Props): ReactElement => { FAQ </StyledLink> {authenticated === false && ( - <LoginButton + <DesktopLoginButton className="button" href={`${LOGIN_URL}?next=${router.asPath}`} onClick={() => logEvent('login', 'click')} > Login - </LoginButton> + </DesktopLoginButton> )} {authenticated && userInfo && ( <StyledLink href={SETTINGS_ROUTE}> diff --git a/frontend/components/Header/index.tsx b/frontend/components/Header/index.tsx index f38c68f8f..eb97e7ecc 100644 --- a/frontend/components/Header/index.tsx +++ b/frontend/components/Header/index.tsx @@ -1,7 +1,11 @@ import Link from 'next/link' +import { useRouter } from 'next/router' import { ReactElement, useEffect, useState } from 'react' import styled from 'styled-components' +import { LOGIN_URL } from '~/utils' +import { logEvent } from '~/utils/analytics' + import { BANNER_BG, BANNER_TEXT, BORDER } from '../../constants/colors' import { ANIMATION_DURATION, @@ -31,7 +35,7 @@ import { import Burger from './Burger' import Feedback from './Feedback' import Heading from './Head' -import Links from './Links' +import Links, { MobileLoginButton } from './Links' const Nav = styled.nav` height: ${NAV_HEIGHT}; @@ -164,6 +168,7 @@ const isHub = SITE_ID === 'fyh' const Header = ({ authenticated, userInfo }: HeaderProps): ReactElement => { const [show, setShow] = useState(false) + const router = useRouter() const toggle = () => setShow(!show) @@ -184,7 +189,24 @@ const Header = ({ authenticated, userInfo }: HeaderProps): ReactElement => { <Title>{SITE_NAME}</Title> </LogoItem> </Link> - <Burger toggle={toggle} /> + <div + style={{ + flex: 1, + display: 'flex', + justifyContent: 'flex-end', + }} + > + {authenticated === false && ( + <MobileLoginButton + className="button" + href={`${LOGIN_URL}?next=${router.asPath}`} + onClick={() => logEvent('login', 'click')} + > + Login + </MobileLoginButton> + )} + <Burger toggle={toggle} /> + </div> </div> <Links userInfo={userInfo} authenticated={authenticated} show={show} /> </Nav> diff --git a/frontend/components/OrderInput.tsx b/frontend/components/OrderInput.tsx index f12d8201e..d9536057a 100644 --- a/frontend/components/OrderInput.tsx +++ b/frontend/components/OrderInput.tsx @@ -19,7 +19,7 @@ import { Icon } from './common' const ORDERINGS = [ { key: 'featured', - name: 'Featured', + name: 'Default', icon: 'star', }, { @@ -32,11 +32,6 @@ const ORDERINGS = [ name: 'Bookmarks', icon: 'bookmark', }, - { - key: 'random', - name: 'Random', - icon: 'shuffle', - }, ] const OrderingWrapper = styled.div` diff --git a/frontend/components/Settings/ClubTab.tsx b/frontend/components/Settings/ClubTab.tsx index 114aa0717..08d8a0dd3 100644 --- a/frontend/components/Settings/ClubTab.tsx +++ b/frontend/components/Settings/ClubTab.tsx @@ -232,7 +232,7 @@ const ClubTab = ({ <> <EmptyState name="button" /> <Center> - <Text isGray> + <Text $isGray> No memberships yet! Browse {OBJECT_NAME_PLURAL}{' '} <Link href="/">here</Link>. </Text> diff --git a/frontend/components/Settings/FairsTab.tsx b/frontend/components/Settings/FairsTab.tsx index 168e76125..9cbd8a0c3 100644 --- a/frontend/components/Settings/FairsTab.tsx +++ b/frontend/components/Settings/FairsTab.tsx @@ -11,6 +11,7 @@ import { } from '../../utils/branding' import { Icon, Text } from '../common' import { + CheckboxField, DateTimeField, DynamicQuestionField, RichTextField, @@ -84,6 +85,11 @@ const FairsTab = ({ fairs }: FairsTabProps): ReactElement => { MembershipRank.Officer ].toLowerCase()}s on the registration page.`} /> + <Field + name="virtual" + as={CheckboxField} + helpText="Check this box if your fair is virtual." + /> <Field name="questions" as={DynamicQuestionField} diff --git a/frontend/components/Settings/QueueTab.tsx b/frontend/components/Settings/QueueTab.tsx index a4470a2e2..e8e7a7faf 100644 --- a/frontend/components/Settings/QueueTab.tsx +++ b/frontend/components/Settings/QueueTab.tsx @@ -1,10 +1,11 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ReactElement, useEffect, useState } from 'react' +import Select from 'react-select' import styled from 'styled-components' import { CLUB_ROUTE } from '../../constants' -import { Club } from '../../types' +import { Club, Template } from '../../types' import { apiCheckPermission, doApiRequest } from '../../utils' import { OBJECT_NAME_PLURAL, @@ -21,6 +22,7 @@ type QueueTableModalProps = { closeModal: () => void bulkAction: (comment: string) => void isApproving: boolean + templates: Template[] } const QueueTableModal = ({ @@ -28,13 +30,23 @@ const QueueTableModal = ({ closeModal, bulkAction, isApproving, + templates, }: QueueTableModalProps): ReactElement => { const [comment, setComment] = useState<string>('') + const [selectedTemplates, setSelectedTemplates] = useState<Template[]>([]) + + useEffect(() => { + setComment( + selectedTemplates.map((template) => template.content).join('\n\n'), + ) + }, [selectedTemplates]) + return ( <Modal show={show} closeModal={() => { setComment('') + setSelectedTemplates([]) closeModal() }} marginBottom={false} @@ -45,6 +57,36 @@ const QueueTableModal = ({ notes will be emailed to the requesters when you{' '} {isApproving ? 'approve' : 'reject'} these requests. </div> + <Select + isMulti + isClearable + placeholder="Select templates" + value={selectedTemplates.map((template) => ({ + value: template.id, + label: template.title, + content: template.content, + author: template.author, + }))} + options={templates.map((template) => ({ + value: template.id, + label: template.title, + content: template.content, + author: template.author, + }))} + onChange={(selectedOptions) => { + if (selectedOptions) { + const selected = selectedOptions.map((option) => ({ + id: option.value, + title: option.label, + content: option.content, + author: option.author, + })) + setSelectedTemplates(selected) + } else { + setSelectedTemplates([]) + } + }} + /> <textarea value={comment} onChange={(e) => setComment(e.target.value)} @@ -52,7 +94,7 @@ const QueueTableModal = ({ placeholder={`${isApproving ? 'approval' : 'rejection'} notes`} ></textarea> <button - className={`mb-2 button ${isApproving ? 'is-success' : 'is-danger'}`} + className={`mt-2 button ${isApproving ? 'is-success' : 'is-danger'}`} onClick={() => { closeModal() bulkAction(comment) @@ -68,10 +110,11 @@ const QueueTableModal = ({ type QueueTableProps = { clubs: Club[] | null + templates: Template[] } /* TODO: refactor with Table component when render and search functionality are disconnected */ -const QueueTable = ({ clubs }: QueueTableProps): ReactElement => { +const QueueTable = ({ clubs, templates }: QueueTableProps): ReactElement => { const router = useRouter() const [selectedCodes, setSelectedCodes] = useState<string[]>([]) const [showModal, setShowModal] = useState<boolean>(false) @@ -106,6 +149,7 @@ const QueueTable = ({ clubs }: QueueTableProps): ReactElement => { closeModal={() => setShowModal(false)} bulkAction={bulkAction} isApproving={approve} + templates={templates} /> <QueueTableHeader> <QueueTableHeaderText> @@ -238,6 +282,7 @@ const QueueTab = (): ReactElement => { const [rejectedClubs, setRejectedClubs] = useState<Club[] | null>(null) const [inactiveClubs, setInactiveClubs] = useState<Club[] | null>(null) const [allClubs, setAllClubs] = useState<boolean[] | null>(null) + const [templates, setTemplates] = useState<Template[]>([]) const canApprove = apiCheckPermission('clubs.approve_club') useEffect(() => { @@ -261,6 +306,10 @@ const QueueTab = (): ReactElement => { doApiRequest('/clubs/directory/?format=json') .then((resp) => resp.json()) .then((data) => setAllClubs(data.map((club: Club) => club.approved))) + + doApiRequest('/templates/?format=json') + .then((resp) => resp.json()) + .then(setTemplates) } }, []) @@ -327,7 +376,7 @@ const QueueTab = (): ReactElement => { {approvedClubsCount} Approved {OBJECT_NAME_TITLE} </li> </ul> - <QueueTable clubs={pendingClubs} /> + <QueueTable clubs={pendingClubs} templates={templates} /> <SmallTitle>Other Clubs</SmallTitle> <div className="mt-3 mb-3"> The table below shows a list of {OBJECT_NAME_PLURAL} that have been diff --git a/frontend/components/Settings/RenewTab.tsx b/frontend/components/Settings/RenewTab.tsx index c1413f2b2..0cbdea7a9 100644 --- a/frontend/components/Settings/RenewTab.tsx +++ b/frontend/components/Settings/RenewTab.tsx @@ -68,7 +68,7 @@ const RenewTab = ({ className }: ClubTabProps): ReactElement => { <> <EmptyState name="button" /> <Center> - <Text isGray> + <Text $isGray> You are not listed as an officer for any {OBJECT_NAME_PLURAL} yet. If you would like to request access for an existing{' '} {OBJECT_NAME_SINGULAR}, please send your name, PennKey, and{' '} diff --git a/frontend/components/Settings/TemplatesTab.tsx b/frontend/components/Settings/TemplatesTab.tsx new file mode 100644 index 000000000..37c0959ea --- /dev/null +++ b/frontend/components/Settings/TemplatesTab.tsx @@ -0,0 +1,59 @@ +import { Field } from 'formik' +import moment from 'moment-timezone' +import React, { ReactElement } from 'react' + +import { Template } from '../../types' +import { OBJECT_NAME_SINGULAR } from '../../utils/branding' +import { Text } from '../common' +import { TextField } from '../FormComponents' +import ModelForm from '../ModelForm' + +type TemplatesTabProps = { + templates: Template[] +} + +export default function TemplatesTab({ + templates, +}: TemplatesTabProps): ReactElement { + return ( + <> + <Text> + You can use this page to manage {OBJECT_NAME_SINGULAR} approval response + templates. Since your account has the required permissions, you are able + to view this page. + </Text> + <ModelForm + baseUrl="/templates/" + initialData={templates} + fields={ + <> + <Field + name="title" + as={TextField} + required + helpText={`The unique title of the ${OBJECT_NAME_SINGULAR} approval response template. This will be shown in the template dropdown menu.`} + /> + <Field name="content" as={TextField} type="textarea" required /> + </> + } + tableFields={[ + { name: 'title', label: 'Title' }, + { name: 'content', label: 'Content' }, + { name: 'author', label: 'Author' }, + { + name: 'created_at', + label: 'Date Created', + converter: (field) => moment(field).format('MMMM Do, YYYY'), + }, + { + name: 'updated_at', + label: 'Last Updated', + converter: (field) => moment(field).format('MMMM Do, YYYY'), + }, + ]} + noun="Template" + confirmDeletion={true} + /> + </> + ) +} diff --git a/frontend/components/Settings/TicketsTab/index.tsx b/frontend/components/Settings/TicketsTab/index.tsx index 650a2d6be..ba74c9884 100644 --- a/frontend/components/Settings/TicketsTab/index.tsx +++ b/frontend/components/Settings/TicketsTab/index.tsx @@ -257,7 +257,7 @@ const TicketsTab = ({ className, userInfo }: TicketsTabProps): ReactElement => { <> <EmptyState name="empty_cart" /> <Center> - <Text isGray> + <Text $isGray> No tickets yet! Browse events to find tickets{' '} <Link href="/events">here</Link>. </Text> diff --git a/frontend/components/Settings/WhartonApplicationCycles.tsx b/frontend/components/Settings/WhartonApplicationCycles.tsx index 88bae600b..223b36ad0 100644 --- a/frontend/components/Settings/WhartonApplicationCycles.tsx +++ b/frontend/components/Settings/WhartonApplicationCycles.tsx @@ -13,16 +13,17 @@ import ModelForm from '../ModelForm' const fields = ( <> - <Field name="name" as={TextField} /> - <Field name="start_date" as={DateTimeField} /> - <Field name="end_date" as={DateTimeField} /> - <Field name="release_date" as={DateTimeField} /> + <Field name="name" as={TextField} required /> + <Field name="start_date" as={DateTimeField} required /> + <Field name="end_date" as={DateTimeField} required /> + <Field name="release_date" as={DateTimeField} required /> </> ) type Cycle = { name: string id: number | null + endDate: Date } type ClubOption = { @@ -35,7 +36,8 @@ type ExtensionOption = { clubName: string endDate: Date exception?: boolean - changed: boolean + originalEndDate: Date + originalException: boolean } const ScrollWrapper = styled.div` @@ -44,17 +46,24 @@ const ScrollWrapper = styled.div` height: 40vh; ` +type ClubApplicationWithClub = ClubApplication & { + club__name: string + club__code: number +} + const WhartonApplicationCycles = (): ReactElement => { const [editMembership, setEditMembership] = useState(false) const [membershipCycle, setMembershipCycle] = useState<Cycle>({ name: '', id: null, + endDate: new Date(), }) const [editExtensions, setEditExtensions] = useState(false) const [extensionsCycle, setExtensionsCycle] = useState<Cycle>({ name: '', id: null, + endDate: new Date(), }) const [clubsSelectedMembership, setClubsSelectedMembership] = useState< @@ -81,7 +90,10 @@ const WhartonApplicationCycles = (): ReactElement => { const closeExtensionsModal = (): void => { setEditExtensions(false) // calculate clubs that have changed - const clubsToUpdate = clubsExtensions.filter((x) => x.changed) + const clubsToUpdate = clubsExtensions.filter( + (x) => + x.originalEndDate !== x.endDate || x.originalException !== x.exception, + ) // split into clubs with exceptions and clubs without const clubsExceptions = clubsToUpdate.filter((x) => x.exception) const clubsNoExceptions = clubsToUpdate.filter((x) => !x.exception) @@ -147,18 +159,23 @@ const WhartonApplicationCycles = (): ReactElement => { useEffect(() => { if (extensionsCycle && extensionsCycle.id != null) { - doApiRequest(`/cycles/${extensionsCycle.id}/clubs?format=json`) + doApiRequest( + `/cycles/${extensionsCycle.id}/club_applications?format=json`, + ) .then((resp) => resp.json()) .then((data) => { - const initialOptions = data.map((club: ClubApplication) => { - return { - id: club.id, - clubName: club.name, - endDate: new Date(club.application_end_time), - exception: club.application_end_time_exception, - changed: false, - } - }) + const initialOptions = data.map( + (application: ClubApplicationWithClub) => { + return { + id: application.id, + clubName: application.club__name, + endDate: new Date(application.application_end_time), + exception: application.application_end_time_exception, + originalEndDate: new Date(application.application_end_time), + originalException: application.application_end_time_exception, + } + }, + ) setClubsExtensions(initialOptions) }) } @@ -190,7 +207,11 @@ const WhartonApplicationCycles = (): ReactElement => { <button className="button is-info is-small" onClick={() => { - setMembershipCycle({ name: object.name, id: object.id }) + setMembershipCycle({ + name: object.name, + id: object.id, + endDate: new Date(object.end_date), + }) setEditMembership(true) setEditExtensions(false) }} @@ -200,7 +221,11 @@ const WhartonApplicationCycles = (): ReactElement => { <button className="button is-info is-small" onClick={() => { - setExtensionsCycle({ name: object.name, id: object.id }) + setExtensionsCycle({ + name: object.name, + id: object.id, + endDate: new Date(object.end_date), + }) setEditExtensions(true) setEditMembership(false) }} @@ -290,7 +315,6 @@ const WhartonApplicationCycles = (): ReactElement => { selected={club.endDate} onChange={(date) => { club.endDate = date - club.changed = true setClubsExtensions([...clubsExtensions]) }} /> @@ -299,7 +323,6 @@ const WhartonApplicationCycles = (): ReactElement => { <Checkbox onChange={(e) => { club.exception = e.target.checked - club.changed = true setClubsExtensions([...clubsExtensions]) }} checked={ @@ -320,9 +343,27 @@ const WhartonApplicationCycles = (): ReactElement => { className="button is-primary" style={{ position: 'absolute', bottom: 10, right: 10 }} onClick={closeExtensionsModal} + disabled={clubsExtensions.some( + (x) => + // For the case where we change end date without giving an exception to a club without one + !x.exception && + !x.originalException && + x.endDate.getTime() !== extensionsCycle.endDate.getTime(), + )} > Submit </button> + {clubsExtensions.some( + (x) => + !x.exception && + !x.originalException && + x.endDate.getTime() !== extensionsCycle.endDate.getTime(), + ) && ( + <p className="is-danger"> + To change the end date for a club, you must also check its + exception box. + </p> + )} </> )} </Modal> diff --git a/frontend/components/Tickets/CartTickets.tsx b/frontend/components/Tickets/CartTickets.tsx index f593b3097..49d8f640b 100644 --- a/frontend/components/Tickets/CartTickets.tsx +++ b/frontend/components/Tickets/CartTickets.tsx @@ -114,13 +114,14 @@ export interface CartTicketsProps { * @param tickets - Original array of tickets * @returns Array of tickets condensed into unique types */ -const combineTickets = (tickets: EventTicket[]): CountedEventTicket[] => +const combineTickets = (tickets: EventTicket[]): CountedEventTicketStatus[] => Object.values( tickets.reduce( (acc, ticket) => ({ ...acc, [`${ticket.event.id}_${ticket.type}`]: { ...ticket, + pendingEdit: false, count: (acc[`${ticket.event.id}_${ticket.type}`]?.count ?? 0) + 1, }, }), @@ -182,12 +183,17 @@ const useCheckout = (paid: boolean) => { } } +interface CountedEventTicketStatus extends CountedEventTicket { + pendingEdit: boolean +} + const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { const navigate = useRouter() - const [removeModal, setRemoveModal] = useState<CountedEventTicket | null>( - null, - ) - const [countedTickets, setCountedTickets] = useState<CountedEventTicket[]>([]) + const [removeModal, setRemoveModal] = + useState<CountedEventTicketStatus | null>(null) + const [countedTickets, setCountedTickets] = useState< + CountedEventTicketStatus[] + >([]) const atLeastOnePaid = tickets.some((ticket) => parseFloat(ticket.price) > 0) const { @@ -207,7 +213,7 @@ const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { .forEach( (ticket) => { toast.error( - `${ticket.event.name} - ${ticket.type} is sold out and ${ticket.count} ticket${ticket.count && ticket.count > 1 ? 's have' : ' has'} been removed from your cart.`, + `${ticket.event.name} - ${ticket.type} is no longer available and ${ticket.count} ticket${ticket.count && ticket.count > 1 ? 's have' : ' has'} been removed from your cart.`, { style: { color: WHITE }, autoClose: false, @@ -225,12 +231,25 @@ const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { checkout() } - function handleUpdateTicket(ticket: CountedEventTicket, newCount?: number) { + function handleUpdateTicket( + ticket: CountedEventTicketStatus, + newCount?: number, + propogateCount?: (count: number) => void, + ) { let reqPromise if (!ticket.count || newCount === ticket.count) { return } + + function flipPendingEdit(value: boolean) { + setCountedTickets( + countedTickets.map((t) => + t.id === ticket.id ? { ...t, pendingEdit: value } : t, + ), + ) + } + flipPendingEdit(true) if (newCount && newCount > ticket.count) { reqPromise = doApiRequest(`/events/${ticket.event.id}/add_to_cart/`, { method: 'POST', @@ -268,8 +287,11 @@ const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { toast.error(res.detail, { style: { color: WHITE }, }) + propogateCount?.(ticket.count ?? 0) + flipPendingEdit(false) return } + flipPendingEdit(false) toast.success(res.detail) // TODO: a less naive approach to updating the cart setCountedTickets( @@ -313,7 +335,7 @@ const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { }} > <Subtitle>Your cart is empty</Subtitle> - <Text isGray> + <Text $isGray> To add tickets to your cart, visit the event page and select the tickets you wish to purchase. If you believe this is an error, please contact support at @@ -365,9 +387,9 @@ const CartTickets: React.FC<CartTicketsProps> = ({ tickets, soldOut }) => { ticket={ticket} hideActions removable - editable - onChange={(count) => { - handleUpdateTicket(ticket, count) + editable={!ticket.pendingEdit} + onChange={(count, propogateCount) => { + handleUpdateTicket(ticket, count, propogateCount) }} onRemove={() => { setRemoveModal(ticket) diff --git a/frontend/components/Tickets/PaymentForm.tsx b/frontend/components/Tickets/PaymentForm.tsx index d45d5ebad..8333ace82 100644 --- a/frontend/components/Tickets/PaymentForm.tsx +++ b/frontend/components/Tickets/PaymentForm.tsx @@ -36,7 +36,7 @@ const Payment: React.FC<PaymentProps> = ({ return ( <> <Script - src="https://apitest.cybersource.com/up/v1/assets/0.15/SecureAcceptance.js" + src="https://api.cybersource.com/up/v1/assets/0.22.0/SecureAcceptance.js" async onLoad={onLoad} /> diff --git a/frontend/components/Tickets/TicketCard.tsx b/frontend/components/Tickets/TicketCard.tsx index 2d88b6337..1986c919a 100644 --- a/frontend/components/Tickets/TicketCard.tsx +++ b/frontend/components/Tickets/TicketCard.tsx @@ -132,7 +132,8 @@ export const TicketCard = ({ indexProps?: TicketCardIndexProps onRemove?: () => void - onChange?: (count: number) => void + // Optimistically update the ticket count but revert if server request fails + onChange?: (count: number, propogateCount: (number) => void) => void onClick?: () => void viewModal?: (type: ModalType) => void @@ -196,7 +197,10 @@ export const TicketCard = ({ onKeyDown={(e) => { if (e.key === 'Enter') { setIsEditMode(false) - onChange?.(parseInt(e.currentTarget.value ?? ticket.count)) + onChange?.( + parseInt(e.currentTarget.value ?? ticket.count), + setTicketCount, + ) } }} /> @@ -256,12 +260,12 @@ export const TicketCard = ({ css={css` font-size: 12px; position: absolute; - right: 12px; - top: 12px; + right: 8px; + top: 8px; `} > - {datetimeData.dayDuration < 0 ? '-' : '+'}{' '} - {Math.abs(datetimeData.dayDuration)} + {datetimeData.dayDuration < 0 ? '-' : '+'} + {Math.abs(datetimeData.dayDuration)}d </div> )} </Title> @@ -318,7 +322,7 @@ export const TicketCard = ({ `} onClick={() => { if (isEditMode) { - onChange?.(ticketCount || ticket.count!) + onChange?.(ticketCount || ticket.count!, setTicketCount) } setIsEditMode(!isEditMode) }} diff --git a/frontend/components/common/TagGroup.tsx b/frontend/components/common/TagGroup.tsx index f8e117870..64d9a8045 100644 --- a/frontend/components/common/TagGroup.tsx +++ b/frontend/components/common/TagGroup.tsx @@ -14,26 +14,50 @@ function isBadge(tag): tag is Badge { export const TagGroup = ({ tags = [] }: TagGroupProps): ReactElement | null => { if (!tags || !tags.length) return null + // sometimes there will be duplicate badges, or a badge will end up with the same content as a tag + // when there are duplicate badges, choose one arbitrarily + // when there's a tag and a badge with the same content, render the badge + + const uniqueTags = tags.reduce((acc, tag) => { + const key = isBadge(tag) ? tag.label : tag.name + if (!acc.has(key)) { + acc.set(key, tag) + } else if (isBadge(tag)) { + acc.set(key, tag) + } + return acc + }, new Map()) + return ( <> - {tags.map((tag) => - isBadge(tag) ? ( - <DefaultTag - key={`${tag.id}-badge`} - color={tag.color} - className="tag is-rounded" - > - {tag.label} - </DefaultTag> - ) : ( - <BlueTag - key={`${tag.id}-tag`} - className="tag is-rounded has-text-white" - > - {tag.name} - </BlueTag> - ), - )} + {Array.from(uniqueTags.values()) // display badges after tags + .sort((a, b) => { + if (isBadge(a) && !isBadge(b)) return 1 + if (!isBadge(a) && isBadge(b)) return -1 + if (isBadge(a) && isBadge(b)) { + // badges should be sorted by color + return a.color.localeCompare(b.color) + } + return 0 + }) + .map((tag) => + isBadge(tag) ? ( + <DefaultTag + key={`${tag.id}-badge`} + color={tag.color} + className="tag is-rounded" + > + {tag.label} + </DefaultTag> + ) : ( + <BlueTag + key={`${tag.id}-tag`} + className="tag is-rounded has-text-white" + > + {tag.name} + </BlueTag> + ), + )} </> ) } diff --git a/frontend/components/common/Typography.tsx b/frontend/components/common/Typography.tsx index ed6806d36..c3c467732 100644 --- a/frontend/components/common/Typography.tsx +++ b/frontend/components/common/Typography.tsx @@ -7,11 +7,11 @@ import { WHITE, } from '../../constants/colors' -export const Text = styled.p<{ isGray?: boolean; color?: string }>` +export const Text = styled.p<{ $isGray?: boolean; color?: string }>` font-size: 1rem; margin-bottom: 1rem; line-height: 1.5; - ${({ isGray }) => (isGray ? `color: ${CLUBS_GREY};` : '')} + ${({ $isGray }) => ($isGray ? `color: ${CLUBS_GREY};` : '')} ${({ color }) => (color ? `color: ${color};` : '')} ` diff --git a/frontend/constants/measurements.ts b/frontend/constants/measurements.ts index f53f50eda..4a9492823 100644 --- a/frontend/constants/measurements.ts +++ b/frontend/constants/measurements.ts @@ -57,6 +57,8 @@ export const mediaMinWidth = (width: string): string => `@media screen and (min-width: ${width})` export const mediaMaxWidth = (width: string): string => `@media screen and (max-width: ${width})` +export const getNumberFromPx = (px: string): number => + parseInt(px.replace('px', ''), 10) export const SM = '768px' export const MD = '992px' diff --git a/frontend/next.config.js b/frontend/next.config.js index 54532dcf2..a03d87a74 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -6,6 +6,9 @@ module.exports = { }) return config }, + compiler: { + styledComponents: true, + }, async rewrites() { return [ { diff --git a/frontend/package.json b/frontend/package.json index 19a0e20f1..cf9c57252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,10 @@ "@sentry/browser": "^7.101.1", "@sentry/node": "^7.101.1", "@svgr/webpack": "^8.1.0", + "@swc/core": "^1.9.3", "@types/moment-timezone": "^0.5.30", "@zeit/next-source-maps": "0.0.4-canary.1", "babel-loader": "^9.1.3", - "babel-plugin-istanbul": "^6.1.1", - "babel-plugin-styled-components": "^2.1.4", "babel-preset-next": "^1.2.0", "body-parser": "^1.20.2", "body-scroll-lock": "^4.0.0-beta.0", @@ -49,6 +48,7 @@ "react-ga": "^3.3.1", "react-lazy-load": "^4.0.1", "react-linkify": "^1.0.0-alpha", + "react-masonry-css": "^1.0.16", "react-places-autocomplete": "^7.3.0", "react-select": "^5.8.0", "react-table": "^7.8.0", @@ -58,6 +58,7 @@ "react-vis": "^1.12.1", "showdown": "^2.1.0", "styled-components": "^6.1.8", + "swiper": "^11.1.12", "use-places-autocomplete": "^4.0.1", "yarn": "^1.22.21" }, @@ -111,10 +112,12 @@ "local-ssl-proxy": "^2.0.5", "nyc": "^15.0.0", "prettier": "^3.2.5", + "swc-plugin-coverage-instrument": "^0.0.25", "typescript": "^5.3.3", "wait-on": "^7.2.0" }, "engines": { "node": "^20.0.0" - } -} \ No newline at end of file + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/frontend/pages/admin/[[...slug]].tsx b/frontend/pages/admin/[[...slug]].tsx index 22cc78d1e..c6341b37e 100644 --- a/frontend/pages/admin/[[...slug]].tsx +++ b/frontend/pages/admin/[[...slug]].tsx @@ -11,15 +11,17 @@ import { NextPageContext } from 'next' import { useRouter } from 'next/router' import React, { ReactElement } from 'react' import renderPage from 'renderPage' -import { Badge, ClubFair, Report, Tag } from 'types' +import { Badge, ClubFair, Report, Tag, Template } from 'types' import { apiCheckPermission, doBulkLookup } from 'utils' +import TemplatesTab from '~/components/Settings/TemplatesTab' import { ADMIN_ROUTE, BG_GRADIENT, WHITE } from '~/constants' function AdminPage({ userInfo, tags, badges, + templates, clubfairs, scripts, fair, @@ -58,6 +60,11 @@ function AdminPage({ label: 'Approval Queue', content: () => <QueueTab />, }, + { + name: 'templates', + label: 'Approval Templates', + content: () => <TemplatesTab templates={templates} />, + }, { name: 'fair', label: 'Fair Management', @@ -99,6 +106,7 @@ function AdminPage({ type BulkResp = { tags: Tag[] badges: Badge[] + templates: Template[] clubfairs: ClubFair[] scripts: any[] reports: Report[] @@ -109,6 +117,7 @@ AdminPage.getInitialProps = async (ctx: NextPageContext) => { [ 'tags', ['badges', '/badges/?all=true&format=json'], + 'templates', 'clubfairs', 'scripts', ['reports', '/reports/?format=json'], diff --git a/frontend/pages/club/[club]/alumni.tsx b/frontend/pages/club/[club]/alumni.tsx index 6a4ebdea3..f33a6c7b5 100644 --- a/frontend/pages/club/[club]/alumni.tsx +++ b/frontend/pages/club/[club]/alumni.tsx @@ -4,19 +4,28 @@ import { NextPageContext } from 'next' import Link from 'next/link' import { ReactElement } from 'react' import renderPage from 'renderPage' -import { Club } from 'types' +import { Club, UserInfo } from 'types' import { doApiRequest } from 'utils' +import AuthPrompt from '~/components/common/AuthPrompt' import { CLUB_ROUTE, PROFILE_ROUTE } from '~/constants' type AlumniPageProps = { club: Club + userInfo?: UserInfo alumni: | { [year: string]: { name: string; username: string | null }[] } | { detail: string } } -const AlumniPage = ({ club, alumni }: AlumniPageProps): ReactElement => { +const AlumniPage = ({ + club, + alumni, + userInfo, +}: AlumniPageProps): ReactElement => { + if (!userInfo) { + return <AuthPrompt /> + } return ( <> <ClubMetadata club={club} /> diff --git a/frontend/pages/club/[club]/application/[application]/index.tsx b/frontend/pages/club/[club]/application/[application]/index.tsx index b06390f0d..69e8bd1ee 100644 --- a/frontend/pages/club/[club]/application/[application]/index.tsx +++ b/frontend/pages/club/[club]/application/[application]/index.tsx @@ -5,7 +5,8 @@ import { Container, Icon, Title } from 'components/common' import { Field, Form, Formik } from 'formik' import moment from 'moment' import { NextPageContext } from 'next' -import React, { ReactElement, useState } from 'react' +import { ReactElement, useState } from 'react' +import TimeAgo from 'react-timeago' import renderPage from 'renderPage' import styled from 'styled-components' import { @@ -16,6 +17,7 @@ import { } from 'types' import { doApiRequest } from 'utils' +import AuthPrompt from '~/components/common/AuthPrompt' import { SelectField, TextField } from '~/components/FormComponents' type ApplicationPageProps = { @@ -106,11 +108,30 @@ export function formatQuestionType( } const ApplicationPage = ({ + userInfo, club, application, questions, initialValues, -}: ApplicationPageProps): ReactElement => { +}): ReactElement => { + if (!userInfo) { + return <AuthPrompt /> + } + + // Second condition will be replaced with perms check or question nullity check once backend is updated + // eslint-disable-next-line no-constant-condition + if (new Date() < new Date(application.application_start_time) && false) { + return ( + <Container paddingTop> + <Title>Application Not Open</Title> + <p> + This application is not open yet. Please check back{' '} + <TimeAgo date={application.application_start_time} />. + </p> + </Container> + ) + } + const [errors, setErrors] = useState<string | null>(null) const [saved, setSaved] = useState<boolean>(false) const [currentCommittee, setCurrentCommittee] = useState<{ diff --git a/frontend/pages/club/[club]/apply.tsx b/frontend/pages/club/[club]/apply.tsx index 2cd034cba..9b2fc8203 100644 --- a/frontend/pages/club/[club]/apply.tsx +++ b/frontend/pages/club/[club]/apply.tsx @@ -14,6 +14,7 @@ import { } from 'components/common' import { NextPageContext } from 'next' import Link from 'next/link' +import { useRouter } from 'next/router' import { ReactElement, useState } from 'react' import TimeAgo from 'react-timeago' import renderPage from 'renderPage' @@ -35,6 +36,7 @@ type Props = { } const ApplyPage = ({ club, applications }: Props): ReactElement => { + const router = useRouter() const [updatedIsRequest, setUpdatedIsRequest] = useState<boolean>( club.is_request, ) @@ -170,13 +172,15 @@ const ApplyPage = ({ club, applications }: Props): ReactElement => { {new Date(app.result_release_time).toLocaleString()} ( <TimeAgo date={app.result_release_time} />) </div> - <a - href={app.external_url} - rel="noopener noreferrer" + <button + onClick={() => { + router.push(app.external_url) + }} className="button is-success mt-3" + disabled={new Date() < new Date(app.application_start_time)} > <Icon name="edit" /> Apply - </a> + </button> </div> ))} <Subtitle>Already a member?</Subtitle> diff --git a/frontend/pages/club/[club]/index.tsx b/frontend/pages/club/[club]/index.tsx index 0c14b3b4b..b6bd88f39 100644 --- a/frontend/pages/club/[club]/index.tsx +++ b/frontend/pages/club/[club]/index.tsx @@ -3,7 +3,6 @@ import { DesktopActions, MobileActions } from 'components/ClubPage/Actions' import AdvisorList from 'components/ClubPage/AdvisorList' import ClubApprovalDialog from 'components/ClubPage/ClubApprovalDialog' import Description from 'components/ClubPage/Description' -import Events from 'components/ClubPage/Events' import FilesList from 'components/ClubPage/FilesList' import Header from 'components/ClubPage/Header' import InfoBox from 'components/ClubPage/InfoBox' @@ -43,6 +42,7 @@ import { SITE_NAME, } from 'utils/branding' +import EventCarousel from '~/components/ClubPage/EventCarousel' import { CLUB_ALUMNI_ROUTE, CLUB_ORG_ROUTE } from '~/constants' import { CLUBS_RED, SNOW, WHITE } from '~/constants/colors' import { M0, M2, M3 } from '~/constants/measurements' @@ -178,15 +178,23 @@ const ClubPage = ({ testimonials, signature_events: signatureEvents, } = club - return ( <WideContainer background={SNOW} fullHeight> <ClubMetadata club={club} /> {userInfo != null && ( <ClubApprovalDialog club={club} userInfo={userInfo} /> )} + {club.badges.length > 0 && + club.badges + .filter((badge) => badge.message && badge.message.length > 0) + .map((badge) => ( + <div className="notification is-info is-light" key={badge.id}> + <Icon name="alert-circle" style={{ marginTop: '-3px' }} />{' '} + {badge.message} + </div> + ))} <div className="columns"> - <div className="column"> + <div className="column is-two-thirds"> {isActive || ( <InactiveCard $bordered @@ -217,14 +225,11 @@ const ClubPage = ({ pending approval from the {APPROVAL_AUTHORITY}. </div> )} - - {userInfo != null && ( - <MobileActions - club={club} - userInfo={userInfo} - updateRequests={updateRequests} - /> - )} + <MobileActions + club={club} + authenticated={userInfo !== undefined} + updateRequests={updateRequests} + /> <StyledCard $bordered> <Description club={club} /> </StyledCard> @@ -268,15 +273,14 @@ const ClubPage = ({ <MemberList club={club} /> </> )} + {events.length > 0 && <EventCarousel data={events} />} </div> <div className="column is-one-third"> - {userInfo && ( - <DesktopActions - club={club} - userInfo={userInfo} - updateRequests={updateRequests} - /> - )} + <DesktopActions + club={club} + authenticated={userInfo !== undefined} + updateRequests={updateRequests} + /> <QAButton onClick={scrollToQuestions}> {questions.length > 0 ? `Click here to see the ${questions.length} question${ @@ -296,7 +300,6 @@ const ClubPage = ({ <div dangerouslySetInnerHTML={{ __html: involvement }} /> </StyledCard> )} - <Events data={events} /> {isClubFieldShown('signature_events') && signatureEvents && !!signatureEvents.length && ( @@ -312,17 +315,19 @@ const ClubPage = ({ <div className="mb-3"> <StrongText>Additional Pages</StrongText> <ul> - <li> - <Link - legacyBehavior - href={CLUB_ALUMNI_ROUTE()} - as={CLUB_ALUMNI_ROUTE(club.code)} - > - <a> - <Icon name="database" /> Alumni - </a> - </Link> - </li> + {userInfo && ( + <li> + <Link + legacyBehavior + href={CLUB_ALUMNI_ROUTE()} + as={CLUB_ALUMNI_ROUTE(club.code)} + > + <a> + <Icon name="database" /> Alumni + </a> + </Link> + </li> + )} <li> <Link legacyBehavior diff --git a/frontend/pages/club/[club]/renew.tsx b/frontend/pages/club/[club]/renew.tsx index 4222c3533..4390583e5 100644 --- a/frontend/pages/club/[club]/renew.tsx +++ b/frontend/pages/club/[club]/renew.tsx @@ -98,40 +98,54 @@ const PolicyBox = ({ onChecked = () => undefined }: Props): ReactElement => { const policies = [ { - name: 'Campus Membership', + name: 'Nondiscrimination', content: ( <div> - Membership in registered campus organizations must be open to all - persons without regard to race, color, sex, sexual or affectional - orientation, religion, national or ethnic origin, handicap, or - disability. Under Title IX of the U.S. Education Act Amendment of - 1972, certain exemptions may be granted for intercollegiate and - intramural athletics, fraternities and sororities, and musical groups - based on vocal range. Members of all campus organizations must conduct - themselves at all times in a mature and responsible manner. + In alignment with the University of Pennsylvania’s Nondiscrimination + Statement, student-run organizations are expected to uphold a + commitment to non-discrimination. This means that these organizations + will not engage in discriminatory practices based on factors such as + race, color, sex, sexual orientation, gender identity, religion, + creed, national or ethnic origin, citizenship status, age, disability, + veteran status, or any other legally protected class status. It is + essential for student organizations to recognize the University’s + dedication to providing academic, social, and recreational programs + and services that are equally accessible to all, and as such, all + student-run organizations are required to conduct themselves and their + activities in accordance with this commitment. </div> ), }, { - name: 'Legal Regulations', + name: 'Antihazing', content: ( <div> - The rights and property of all persons are to be respected regardless - of time or place. Failure to comply with University, City, State, or - Federal laws and regulations can result in appropriate disciplinary - action. Members of campus organizations are expected to adhere to - standards of conduct established by Divisions and Departments of the - University. + Student organizations must fully comply with the University of + Pennsylvania’s Antihazing Regulations, as outlined in the{' '} + <a href="https://catalog.upenn.edu/pennbook/">Pennbook</a>. To ensure + compliance, students are encouraged to thoroughly review the + definition of hazing and the illustrative examples provided within the + the Antihazing Regulations. Additionally, it is vital for students to + understand the potential consequences of violating these regulations, + which may encompass University sanctions affecting both individuals + and organizations, as well as potential legal ramifications under + state law. </div> ), }, { - name: 'Hazing', + name: 'Compliance', content: ( <div> - The University is an association of equals who, in working together, - comprise a scholarly community. Hazing is inconsistent with the goals - and purpose of the University and is explicitly forbidden. + Student organizations are obligated to adhere to all policies and + procedures established by the University of Pennsylvania. This + includes, but is not limited to, the policies outlined in the{' '} + <a href="https://catalog.upenn.edu/pennbook/">Pennbook</a> and the{' '} + <a href="https://catalog.upenn.edu/pennbook/code-of-student-conduct/"> + Code of Student Conduct + </a> + . Furthermore, student organizations are expected to operate in + compliance with all relevant local, state, and federal laws. </div> ), }, @@ -226,6 +240,77 @@ const RenewPage = (props: RenewPageProps): ReactElement => { const year = getCurrentSchoolYear() + const prerequisites = [ + { + name: 'Membership Requirements', + content: ( + <div style={{ display: 'inline-block' }}> + Each club must have at least eight active members, with a minimum of + three members designated as officers. + </div> + ), + }, + { + name: 'Group Contacts', + content: ( + <div> + Listed group contacts are members of the organization who have + significant understanding of, and influence on, group operations. + </div> + ), + }, + { + name: 'Club Operations', + content: ( + <div> + Student organizations are to be initiated by, organized, primarily + comprised of and solely led by undergraduate or graduate students. + </div> + ), + }, + { + name: 'Training and Workshops', + content: ( + <div> + Organizations must complete any required student organization + trainings or workshops, such as the Student Organization Summit. + </div> + ), + }, + { + name: 'Branding Compliance', + content: ( + <div> + Clubs must have logos that adhere to university branding standards and + ensure consistent use across all platforms, including social media and + websites. Refer to the{' '} + <a href="https://universitylife.upenn.edu/student-brand-guidelines/"> + Student Branding Guidelines + </a>{' '} + for more information. + </div> + ), + }, + { + name: 'University Affiliation', + content: ( + <div> + The club mission must clearly state that the group is a student + organization at the University. + </div> + ), + }, + { + name: 'Policy Adherence', + content: ( + <div> + All groups must comply with the relevant policies and guidelines for + student organizations. + </div> + ), + }, + ] + const steps = [ { name: 'Introduction', @@ -254,39 +339,41 @@ const RenewPage = (props: RenewPageProps): ReactElement => { )} <TextInfoBox> <p> - The annual club registration process is a procedure conducted by - the{' '} + The annual club registration process, conducted by the{' '} <a target="_blank" href={APPROVAL_AUTHORITY_URL}> {APPROVAL_AUTHORITY} - </a>{' '} - to ensure that student-run clubs are officially registered and + </a> + , ensures that student-run clubs are officially registered and permitted to operate on campus for the upcoming academic year. - During this process, clubs are required to submit update their - club officers and membership roster information; if applicable, - update primary contact information fulfill any other requirements - set by the University. + During this process, clubs are required to submit updated officer + and membership roster information and, if applicable, update their + primary contact details. </p> <p> - The purpose of the annual club registration process is to maintain - a well-organized and vibrant campus community, allowing students - to explore various interests and engage in extracurricular - activities. By registering each year, clubs reaffirm their - commitment to following school policies, uphold their mission, and - demonstrate their ongoing relevance to the student body. + The purpose of this process is to maintain a well-organized and + vibrant campus community, enabling students to explore various + interests and engage in extracurricular activities. By registering + each year, clubs reaffirm their commitment to following university + policies, uphold their mission, and demonstrate their ongoing + relevance to the student body. </p> <p> - Benefits of club registration include access to funding - opportunities, the ability to reserve campus facilities for - events, eligibility to participate in campus-wide events like the - annual activities’ fairs, and access to resources and support from - the student affairs office or other university departments. + Registration identifies the organization as active and grants + access to essential university resources, such as reserving space, + accessing electronic resources, appropriate use of the Penn name, + potential funding opportunities, participation in activities + fairs, and the ability to advertise as a student-run organization + at the University of Pennsylvania. </p> <p> - Overall, the annual club registration process plays a crucial role - in fostering a diverse and active campus life, enriching the - educational experience of students, and promoting a sense of - community and belonging. + To successfully register or re-register your organization, the + following prerequisites must be met: </p> + {prerequisites.map(({ name, content }) => ( + <p> + <b>{name}</b>: {content} + </p> + ))} <p> If you have any questions about the club registration process, please contact the Office of Student Affairs at diff --git a/frontend/pages/events/[id].tsx b/frontend/pages/events/[id].tsx index dc7727b84..49469c1aa 100644 --- a/frontend/pages/events/[id].tsx +++ b/frontend/pages/events/[id].tsx @@ -201,6 +201,7 @@ const GetTicketItem: React.FC<TicketItemProps> = ({ </p> <Input type="number" + pattern="[0-9]*" className="input" min={0} max={max} @@ -208,7 +209,7 @@ const GetTicketItem: React.FC<TicketItemProps> = ({ step={1} placeholder="Ticket Count" onChange={handleCountChange} - style={{ flex: '0 0 auto', maxWidth: '48px' }} + style={{ flex: '0 0 auto', maxWidth: '64px' }} /> </div> </div> @@ -361,10 +362,12 @@ const EventPage: React.FC<EventPageProps> = ({ ))} <button className="button is-primary is-fullwidth mt-4" - disabled={totalAvailableTickets === 0} + disabled={ + totalAvailableTickets === 0 || endTime < DateTime.now() + } onClick={() => setShowTicketModal(true)} > - Get Tickets + {endTime < DateTime.now() ? 'Event Ended' : 'Get Tickets'} </button> </Card> )} diff --git a/frontend/pages/fair.tsx b/frontend/pages/fair.tsx index 0bfd8aa38..8992a3861 100644 --- a/frontend/pages/fair.tsx +++ b/frontend/pages/fair.tsx @@ -12,7 +12,7 @@ import { ReactElement, useEffect, useState } from 'react' import renderPage from 'renderPage' import { ClubFair } from 'types' import { cache, doApiRequest, useSetting } from 'utils' -import { FAIR_NAME } from 'utils/branding' +import { SUPPORT_EMAIL } from 'utils/branding' import { CLUB_ROUTE, SNOW } from '~/constants' @@ -39,10 +39,8 @@ const FairPage = ({ ) const isPreFair = useSetting('PRE_FAIR') const fairName = fair?.name ?? useSetting('FAIR_NAME') ?? 'Upcoming Fair' - const fairOrgName = fair?.organization ?? 'partner organization' - const fairContact = fair?.contact ?? 'the partner organization' - const fairTime = fair?.time ?? 'TBD' - const fairAdditionalInfo = fair?.information ?? '' + const fairContact = + fair?.contact ?? useSetting('FAIR_CONTACT') ?? SUPPORT_EMAIL /** * Open up the fair on the designated time client side if we're close. @@ -64,44 +62,76 @@ const FairPage = ({ } }, []) + if (!isPreFair && !isFairOpen && !isOverride) { + return ( + <Container background={SNOW}> + <Metadata title="Upcoming Fair Guide" /> + <InfoPageTitle>Upcoming Fair Student Guide</InfoPageTitle> + <div className="content"> + <div className="notification is-warning"> + <Icon name="alert-triangle" /> There is currently no fair currently + occurring or upcoming. If you believe this is an error, please + contact <Contact />. + </div> + </div> + </Container> + ) + } return ( <Container background={SNOW}> <Metadata title={fairName as string} /> <InfoPageTitle>{fairName} – Student Guide</InfoPageTitle> <div className="content"> - {!isPreFair && !isFairOpen && !isOverride && ( - <div className="notification is-warning"> - <Icon name="alert-triangle" /> There is currently no {FAIR_NAME}{' '} - fair that is currently occurring or upcoming. If you believe this is - an error, please contact <Contact />. - </div> - )} + {fair ? ( + <> + <p> + The {fair.name} sponsored by the {fair.organization}, will be held + from {fair.start_time.split('T')[0]} to{' '} + {fair.end_time.split('T')[0]}! + </p> - <p> - The 2023 Fall Activities Fair sponsored by the Student Activities - Council(SAC), will be held from August 29th to August 31st! This event - will showcase various student-run clubs, with each day dedicated to - highlighting different club categories. The fair will take place on - College Green from 12p-4p each day. - </p> - <p> - To participate, sign-up will be facilitated through Penn Clubs and - will coincide with the annual club registration process. Only - returning undergraduate student-run groups that were registered in - Penn Clubs last year are eligible to sign-up for the Fall Activities - Fair. - </p> - <p> - To secure your club’s spot, be sure to register before the deadline on - August 22nd. Once the registration process is complete, registered - clubs will receive information about their scheduled day for the Fair - by August 24th. - </p> - <p> - For any inquiries or clarifications about the Fair, don't hesitate to - reach out to SAC at fair@sacfunded.net. We look forward to seeing you - there! - </p> + {fair.information.trim().length !== 0 && ( + <div> + <h3>Student Information</h3> + <div + dangerouslySetInnerHTML={{ + __html: fair.information, + }} + /> + </div> + )} + + <br /> + + {fair.registration_information.trim().length !== 0 && ( + <div> + <h3>Registration Information</h3> + <div + dangerouslySetInnerHTML={{ + __html: fair.registration_information, + }} + /> + </div> + )} + + <br /> + + <p> + To secure your club’s spot, be sure to register before the + deadline on {fair.registration_end_time.split('T')[0]}. + </p> + <p> + For any inquiries or clarifications about the Fair, don't hesitate + to reach out to {fair.contact}. We look forward to seeing you + there! + </p> + </> + ) : ( + <p> + The {fairName} is currently ongoing! Please check with {fairContact}{' '} + for more information. + </p> + )} <div className="columns mt-3"> {events.map(({ start_time, end_time, events }, i): ReactElement => { const parsedDate = moment(start_time).tz('America/New_York') diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 3360beeef..a03535c6b 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -278,6 +278,7 @@ const searchIsEmpty = (input: SearchInput): boolean => { const Splash = (props: SplashProps): ReactElement => { const fairIsOpen = useSetting('FAIR_OPEN') + const fairIsVirtual = useSetting('FAIR_VIRTUAL') const preFair = useSetting('PRE_FAIR') const renewalBanner = useSetting('CLUB_REGISTRATION') const currentSearch = useRef<SearchInput>({}) @@ -595,7 +596,11 @@ const Splash = (props: SplashProps): ReactElement => { /> {(preFair || fairIsOpen) && ( - <LiveEventsDialog isPreFair={!!preFair} isFair={!!fairIsOpen} /> + <LiveEventsDialog + isPreFair={!!preFair} + isFair={!!fairIsOpen} + isVirtual={!!fairIsVirtual} + /> )} {renewalBanner && <ListRenewalDialog />} diff --git a/frontend/pages/rank.tsx b/frontend/pages/rank.tsx deleted file mode 100644 index 06debc3d8..000000000 --- a/frontend/pages/rank.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { - Contact, - Container, - Icon, - InfoPageTitle, - Metadata, - StrongText, - Text, -} from 'components/common' -import { ReactElement } from 'react' -import renderPage from 'renderPage' -import styled from 'styled-components' -import { - FIELD_PARTICIPATION_LABEL, - OBJECT_NAME_PLURAL, - OBJECT_NAME_SINGULAR, - OBJECT_NAME_TITLE, - OBJECT_NAME_TITLE_SINGULAR, - SHOW_RANK_ALGORITHM, - SITE_NAME, -} from 'utils/branding' - -import { GREEN, SNOW } from '~/constants/colors' - -const RankItem = styled.div` - padding: 0.75em; - margin-top: 15px; - display: flex; - - & p { - margin-bottom: 0; - } - - & ul { - display: block; - font-size: 0.9em; - margin-left: 1em; - } -` - -const LargeIconWrapper = styled.div` - flex-basis: 80px; - margin-right: 10px; -` - -const LargeIcon = styled(Icon)` - width: 75px; - height: 75px; - padding: 5px; - - @media (max-width: 769px) { - & { - width: 45px; - height: 45px; - } - } -` - -type RankItemData = { - name: string - description: string | ReactElement - points?: [number, string][] -} - -type RankListProps = { - items: RankItemData[] -} - -const RankList = ({ items }: RankListProps): ReactElement => { - return ( - <div className="is-clearfix mb-5"> - {items.map(({ name, description, points }) => ( - <RankItem key={name}> - <LargeIconWrapper> - <LargeIcon - name="check-circle" - alt="check" - style={{ color: GREEN }} - /> - </LargeIconWrapper> - <div> - <b>{name}</b> - <Text>{description}</Text> - {points && ( - <ul> - {points.map(([num, desc], i) => ( - <li key={i}> - <b>{num > 0 ? `+${num}` : num}</b>: {desc} - </li> - ))} - </ul> - )} - </div> - </RankItem> - ))} - </div> - ) -} - -const Rank = (): ReactElement => ( - <Container background={SNOW}> - <Metadata title={`${OBJECT_NAME_TITLE_SINGULAR} Ordering`} /> - <InfoPageTitle> - {OBJECT_NAME_TITLE_SINGULAR} Recommendation Algorithm - </InfoPageTitle> - {SHOW_RANK_ALGORITHM || ( - <div className="notification is-info"> - <Icon name="alert-circle" /> The {OBJECT_NAME_SINGULAR} recommendation - algorithm is not fully configured for {SITE_NAME}. The categories listed - below may or may not be taken into consideration when ordering{' '} - {OBJECT_NAME_PLURAL} on the home page. - </div> - )} - <StrongText>How are {OBJECT_NAME_PLURAL} ordered?</StrongText> - <Text> - The order that {OBJECT_NAME_PLURAL} appear on the home page for the - default ordering method is determined by several criteria. A - recommendation algorithm uses these criteria to ensure that students - receive the best experience when browsing for new {OBJECT_NAME_PLURAL} and - that {OBJECT_NAME_PLURAL} can effectively reach their target demographic. - </Text> - <StrongText> - How does the {OBJECT_NAME_SINGULAR} recommendation algorithm work? - </StrongText> - <Text> - The recommendation algorithm uses the following non-targeted criteria to - determine how to order {OBJECT_NAME_PLURAL} on the home page.{' '} - {OBJECT_NAME_TITLE} are ordered by points, and then this ordering as - adjusted based on personalized data. The points obtained from these - categories is calculated and saved once per day at 4 AM, so make your - changes early! The criteria are: - </Text> - <RankList - items={[ - { - name: 'Upcoming Events', - description: `If your ${OBJECT_NAME_SINGULAR} has upcoming events registered on ${SITE_NAME}, it will be prioritized on the home page a short period before and during the event. Only events shorter than 16 hours are eligible.`, - points: [ - [10, 'Participating in upcoming activities fair'], - [10, 'At least one upcoming event is today'], - [ - 10, - 'All upcoming events today have a complete picture and description', - ], - [5, 'At least one upcoming event in the next week'], - [ - 5, - 'All upcoming events this week have a complete picture and description', - ], - ], - }, - { - name: 'Upcoming Applications', - description: `If a ${OBJECT_NAME_SINGULAR} application is currently open for your ${OBJECT_NAME_SINGULAR}, it will be prioritized while that application is still open.`, - points: [ - [25, `Has at least one open ${OBJECT_NAME_SINGULAR} application`], - ], - }, - { - name: 'Membership', - description: `Having your ${OBJECT_NAME_SINGULAR} members displayed on ${SITE_NAME} provides more points of contact for questions about your ${OBJECT_NAME_SINGULAR}.`, - points: [ - [15, 'At least 3 active officers'], - [10, 'At least 3 active members'], - [0.1, 'For every non-officer member'], - ], - }, - { - name: 'Useful Tags', - description: ( - <> - Adding relevant tags to your {OBJECT_NAME_SINGULAR} can help - prospective students find the {OBJECT_NAME_PLURAL} that they are - interested in. If you cannot find at least 2 relevant tags for - your {OBJECT_NAME_SINGULAR}, please email <Contact /> and we will - work with you to find something appropriate. - </> - ), - points: [ - [15, 'Has anywhere between 3 and 7 tags'], - [7, 'Has more than 7 tags'], - ], - }, - { - name: 'Contact Information', - description: ( - <> - Having contact information is important for prospective members - who want to know more about the {OBJECT_NAME_SINGULAR}. Social - links can be used to give students a better idea of what you do - and the events that you hold. - </> - ), - points: [ - [10, 'Has a public email'], - [10, 'Has 2 or more social links'], - ], - }, - { - name: 'Bookmarks', - description: ( - <> - Bookmarks are a method for Penn students to show interest in your - {OBJECT_NAME_SINGULAR}. The more bookmarks you have, the higher - your {OBJECT_NAME_SINGULAR} will appear. - </> - ), - points: [[0.04, 'For each bookmark']], - }, - { - name: 'Logo Image', - description: ( - <> - Adding a logo to your {OBJECT_NAME_SINGULAR} can make your{' '} - {OBJECT_NAME_SINGULAR} more recognizable. The logo is shown on the - homepage before the user clicks on your {OBJECT_NAME_SINGULAR}. - </> - ), - points: [[15, 'Has a logo']], - }, - { - name: `${OBJECT_NAME_TITLE} Subtitle`, - description: ( - <> - Adding a subtitle is a quick change that can give students more - information about your {OBJECT_NAME_SINGULAR} without having to - visit your {OBJECT_NAME_SINGULAR} - page. The subtitle is shown on the homepage before the user clicks - on your {OBJECT_NAME_SINGULAR}. - </> - ), - points: [ - [5, 'Has a subtitle'], - [-10, 'Did not change default subtitle'], - ], - }, - { - name: `${OBJECT_NAME_TITLE} Description`, - description: ( - <> - Adding a description helps students learn more about whether or - not a {OBJECT_NAME_SINGULAR} is a good fit for them.{' '} - {OBJECT_NAME_TITLE} without a description will therefore appear - lower on the homepage. Longer and more detailed descriptions are - awarded bonus points. - </> - ), - points: [ - [10, 'At least 25 characters'], - [10, 'At least 250 characters'], - [10, 'At least 1000 characters'], - [3, 'Having images in your description'], - ], - }, - { - name: 'Student Experiences', - description: ( - <> - Adding some testimonials help students gain perspective on what - participating in the {OBJECT_NAME_SINGULAR} is like. - </> - ), - points: [ - [10, 'At least one testimonial'], - [5, 'At least 3 testimonials'], - ], - }, - { - name: FIELD_PARTICIPATION_LABEL, - description: `Prospective members want to know how to participate in your ${OBJECT_NAME_SINGULAR}. Omitting this section will result in a large ordering penalty.`, - points: [[-30, `Empty ${FIELD_PARTICIPATION_LABEL} section`]], - }, - { - name: `Is ${OBJECT_NAME_TITLE_SINGULAR} Updated`, - description: `${OBJECT_NAME_TITLE} that have not been updated in the last 8 months will receive a small ordering penalty.`, - points: [[-10, 'No updates for 8 months']], - }, - { - name: `Is ${OBJECT_NAME_TITLE_SINGULAR} Active`, - description: ( - <> - {OBJECT_NAME_TITLE} that are marked as inactive will be shifted to - the very bottom of the list. You can easily renew your{' '} - {OBJECT_NAME_SINGULAR} from the settings tab in the manage{' '} - {OBJECT_NAME_SINGULAR} page. - </> - ), - points: [[-1000, `For inactive ${OBJECT_NAME_PLURAL}`]], - }, - { - name: 'Random Factor', - description: `A random factor is applied periodically in order to ensure that students see new ${OBJECT_NAME_PLURAL} when they visit ${SITE_NAME}.`, - points: [ - [10, 'Random number between 0 and this number, updated daily'], - ], - }, - ]} - /> - <Text> - The algorithm also attempts to personalize search results for logged in - users, based on the following criteria: - </Text> - <RankList - items={[ - { - name: 'Matches Target Tags', - description: ( - <> - Adding tags will case the {OBJECT_NAME_SINGULAR} to appear higher - on the home page for students who are interested in those tags.{' '} - {OBJECT_NAME_TITLE} that have specified fewer tags are more likely - to appear higher than {OBJECT_NAME_PLURAL} that have specified - more tags, for relevant students. - </> - ), - }, - { - name: 'Matches Target Schools', - description: ( - <> - Adding target schools will cause the {OBJECT_NAME_SINGULAR} to - appear higher on the home page for students in those schools.{' '} - {OBJECT_NAME_TITLE} that have specified fewer schools are more - likely to appear higher than {OBJECT_NAME_PLURAL} that have - specified more schools, for relevant students. Specifying all of - the schools is the same as specifying none of them. - </> - ), - }, - { - name: 'Matches Target Majors', - description: ( - <> - Adding target majors will cause the {OBJECT_NAME_SINGULAR} to - appear higher on the home page for students in those majors.{' '} - {OBJECT_NAME_TITLE} that have specified fewer majors are more - likely to appear higher than {OBJECT_NAME_PLURAL} that have - specified more majors, for relevant students. Specifying 10 or - more majors is the same as specifying no majors. - </> - ), - }, - { - name: 'Matches Target Years', - description: ( - <> - Adding target years will cause the {OBJECT_NAME_SINGULAR} to - appear higher on the home page for students in those years.{' '} - {OBJECT_NAME_TITLE} - that have specified fewer years are more likely to appear higher - than {OBJECT_NAME_PLURAL} that have specified more years, for - relevant students. Specifying all of the years is the same as - specifying none of them. - </> - ), - }, - ]} - /> - </Container> -) - -export default renderPage(Rank) diff --git a/frontend/pages/tickets/[[...slug]].tsx b/frontend/pages/tickets/[[...slug]].tsx index 979c40e8e..d49b0c257 100644 --- a/frontend/pages/tickets/[[...slug]].tsx +++ b/frontend/pages/tickets/[[...slug]].tsx @@ -221,7 +221,7 @@ const TicketCard = ({ ticket, event, buyersPerm }: TicketCardProps) => { ) const contents = await resp.json() if (contents.success) { - toast.info(contents.message, { hideProgressBar: true }) + toast.info(contents.detail, { hideProgressBar: true }) } else { // eslint-disable-next-line no-console console.error(contents.errors) diff --git a/frontend/pages/user/[user]/index.tsx b/frontend/pages/user/[user]/index.tsx index 9c3349500..01eeb8a84 100644 --- a/frontend/pages/user/[user]/index.tsx +++ b/frontend/pages/user/[user]/index.tsx @@ -82,8 +82,7 @@ const UserProfilePage = ({ <Metadata title="User Profile" /> <AuthPrompt title="Oh no!" hasLogin={!authenticated}> You cannot view the profile for this user. This user might not exist - or have set their profile to private.{' '} - <span className="has-text-grey">{profile.detail}</span> + or have set their profile to private. </AuthPrompt> </> ) diff --git a/frontend/types.ts b/frontend/types.ts index a7bf7a785..6e03ad955 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -61,7 +61,7 @@ export interface ClubEvent { large_image_url: string | null location: string | null name: string - ticketed: string + ticketed: boolean pinned: boolean start_time: string type: ClubEventType @@ -80,6 +80,12 @@ export interface CountedEventTicket extends EventTicket { count?: number } +export enum AdvisorVisibilityType { + AdminOnly = 1, + Students = 2, + All = 3, +} + export interface ClubApplication { id: number name: string @@ -126,6 +132,7 @@ export interface Badge { color: string purpose: string description: string + message?: string } export interface QuestionAnswer { @@ -146,7 +153,7 @@ export interface Advisor { department: string email: string phone: string - public: boolean + visibility: AdvisorVisibilityType } export interface Club { @@ -160,6 +167,7 @@ export interface Club { approved_comment: string | null available_virtually: boolean badges: Badge[] + beta: boolean code: string description: string email: string @@ -418,3 +426,10 @@ export type TicketAvailability = { totals: TicketEntry[] available: TicketEntry[] } + +export type Template = { + id: number + author: string + title: string + content: string +} diff --git a/frontend/utils.tsx b/frontend/utils.tsx index e32bfb0ba..bc911d72e 100644 --- a/frontend/utils.tsx +++ b/frontend/utils.tsx @@ -91,7 +91,7 @@ export const SITE_ORIGIN = publicRuntimeConfig.SITE_ORIGIN export const API_BASE_URL = `${SITE_ORIGIN}/api` export const EMPTY_DESCRIPTION = - '<span style="color:#666">This club has not added a description yet.</span>' + '<span style="color:#666">This club has not added a club mission yet.</span>' export const LOGIN_URL = `${API_BASE_URL}/accounts/login/` export const LOGOUT_URL = `${API_BASE_URL}/accounts/logout/` diff --git a/frontend/utils/branding.tsx b/frontend/utils/branding.tsx index beb289d94..a37877c60 100644 --- a/frontend/utils/branding.tsx +++ b/frontend/utils/branding.tsx @@ -251,7 +251,9 @@ const sites = { }, } -export const TICKETING_PAYMENT_ENABLED = false +export const TICKETING_PAYMENT_ENABLED = true +export const REAPPROVAL_QUEUE_ENABLED = true +export const NEW_APPROVAL_QUEUE_ENABLED = true export const SITE_ID = site export const SITE_NAME = sites[site].SITE_NAME export const SITE_SUBTITLE = sites[site].SITE_SUBTITLE diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cad33ed5d..05f7abbbe 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -292,7 +292,7 @@ dependencies: "@babel/types" "^7.12.13" -"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5": +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0" integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w== @@ -742,7 +742,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-jsx@^7.22.5", "@babel/plugin-syntax-jsx@^7.23.3": +"@babel/plugin-syntax-jsx@^7.23.3": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz#8f2e4f8a9b5f9aa16067e142c1ac9cd9f810f473" integrity sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg== @@ -3374,6 +3374,80 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" +"@swc/core-darwin-arm64@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.9.3.tgz#cca13f7ce6e1099612a7ba017f4923857d3a4d5f" + integrity sha512-hGfl/KTic/QY4tB9DkTbNuxy5cV4IeejpPD4zo+Lzt4iLlDWIeANL4Fkg67FiVceNJboqg48CUX+APhDHO5G1w== + +"@swc/core-darwin-x64@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.9.3.tgz#01376c6c2caea5dd0c235f21ebc7e41238153c86" + integrity sha512-IaRq05ZLdtgF5h9CzlcgaNHyg4VXuiStnOFpfNEMuI5fm5afP2S0FHq8WdakUz5WppsbddTdplL+vpeApt/WCQ== + +"@swc/core-linux-arm-gnueabihf@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.9.3.tgz#4a9705903cebfc8e3e2bee71a42f7c88896e61df" + integrity sha512-Pbwe7xYprj/nEnZrNBvZfjnTxlBIcfApAGdz2EROhjpPj+FBqBa3wOogqbsuGGBdCphf8S+KPprL1z+oDWkmSQ== + +"@swc/core-linux-arm64-gnu@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.9.3.tgz#722aefc00a7abfb031fae7539226c7d7683f5c8d" + integrity sha512-AQ5JZiwNGVV/2K2TVulg0mw/3LYfqpjZO6jDPtR2evNbk9Yt57YsVzS+3vHSlUBQDRV9/jqMuZYVU3P13xrk+g== + +"@swc/core-linux-arm64-musl@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.9.3.tgz#6643f683311cc1dcee00970e3d6b4872225bdbd8" + integrity sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg== + +"@swc/core-linux-x64-gnu@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.9.3.tgz#e6f5cefa244409abe1451fbb4575696a870cbd7a" + integrity sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w== + +"@swc/core-linux-x64-musl@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.9.3.tgz#4d45399f7a01389add61febd02da9b12f16abc81" + integrity sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg== + +"@swc/core-win32-arm64-msvc@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.9.3.tgz#8c352bcea558b9a950877cd724f132d7d51a4d80" + integrity sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg== + +"@swc/core-win32-ia32-msvc@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.9.3.tgz#656f78b9c56413dbd590ac259dbe0d563cd8e166" + integrity sha512-rqpzNfpAooSL4UfQnHhkW8aL+oyjqJniDP0qwZfGnjDoJSbtPysHg2LpcOBEdSnEH+uIZq6J96qf0ZFD8AGfXA== + +"@swc/core-win32-x64-msvc@1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.9.3.tgz#9595c177d2c11909558da93b18f37e7c5ae1909c" + integrity sha512-3YJJLQ5suIEHEKc1GHtqVq475guiyqisKSoUnoaRtxkDaW5g1yvPt9IoSLOe2mRs7+FFhGGU693RsBUSwOXSdQ== + +"@swc/core@^1.9.3": + version "1.9.3" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.9.3.tgz#e5bc9b35df2f4a60026c6759c1a6575070339d4f" + integrity sha512-oRj0AFePUhtatX+BscVhnzaAmWjpfAeySpM1TCbxA1rtBDeH/JDhi5yYzAKneDYtVtBvA7ApfeuzhMC9ye4xSg== + dependencies: + "@swc/counter" "^0.1.3" + "@swc/types" "^0.1.17" + optionalDependencies: + "@swc/core-darwin-arm64" "1.9.3" + "@swc/core-darwin-x64" "1.9.3" + "@swc/core-linux-arm-gnueabihf" "1.9.3" + "@swc/core-linux-arm64-gnu" "1.9.3" + "@swc/core-linux-arm64-musl" "1.9.3" + "@swc/core-linux-x64-gnu" "1.9.3" + "@swc/core-linux-x64-musl" "1.9.3" + "@swc/core-win32-arm64-msvc" "1.9.3" + "@swc/core-win32-ia32-msvc" "1.9.3" + "@swc/core-win32-x64-msvc" "1.9.3" + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + "@swc/helpers@0.5.2": version "0.5.2" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" @@ -3381,6 +3455,13 @@ dependencies: tslib "^2.4.0" +"@swc/types@^0.1.17": + version "0.1.17" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.17.tgz#bd1d94e73497f27341bf141abdf4c85230d41e7c" + integrity sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ== + dependencies: + "@swc/counter" "^0.1.3" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -4400,17 +4481,6 @@ babel-plugin-react-require@^3.1.3: resolved "https://registry.yarnpkg.com/babel-plugin-react-require/-/babel-plugin-react-require-3.1.3.tgz#ba3d7305b044a90c35c32c5a9ab943fd68e1638d" integrity sha512-kDXhW2iPTL81x4Ye2aUMdEXQ56JP0sBJmRQRXJPH5FsNB7fOc/YCsHTqHv8IovPyw9Rk07gdd7MVUz8tUmRBCA== -babel-plugin-styled-components@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz#9a1f37c7f32ef927b4b008b529feb4a2c82b1092" - integrity sha512-Xgp9g+A/cG47sUyRwwYxGM4bR/jDRg5N6it/8+HxCnbT5XNKSKDT9xm4oag/osgqjC2It/vH0yXsomOG6k558g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-module-imports" "^7.22.5" - "@babel/plugin-syntax-jsx" "^7.22.5" - lodash "^4.17.21" - picomatch "^2.3.1" - babel-preset-next@^1.2.0: version "1.4.0" resolved "https://registry.yarnpkg.com/babel-preset-next/-/babel-preset-next-1.4.0.tgz#6f20007befb6d888be315a64d772e0d7101b71ca" @@ -4662,15 +4732,10 @@ camelize@^1.0.0: resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= -caniuse-lite@^1.0.30001181: - version "1.0.30001192" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001192.tgz#b848ebc0ab230cf313d194a4775a30155d50ae40" - integrity sha512-63OrUnwJj5T1rUmoyqYTdRWBqFFxZFlyZnRRjDR8NSUQFB6A+j/uBORU/SyJ5WzDLg4SPiZH40hQCBNdZ/jmAw== - -caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001587: - version "1.0.30001588" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz#07f16b65a7f95dba82377096923947fb25bce6e3" - integrity sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ== +caniuse-lite@^1.0.30001181, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001587: + version "1.0.30001683" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001683.tgz" + integrity sha512-iqmNnThZ0n70mNwvxpEC2nBJ037ZHZUoBI5Gorh1Mw6IlEAZujEoU1tXA628iZfzm7R9FvFzxbfdgml82a3k8Q== caseless@~0.12.0: version "0.12.0" @@ -9075,6 +9140,11 @@ react-linkify@^1.0.0-alpha: linkify-it "^2.0.3" tlds "^1.199.0" +react-masonry-css@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" + integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== + react-motion@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" @@ -9960,8 +10030,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9975,6 +10044,13 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -10090,6 +10166,16 @@ svgo@^3.0.2: csso "^5.0.5" picocolors "^1.0.0" +swc-plugin-coverage-instrument@^0.0.25: + version "0.0.25" + resolved "https://registry.yarnpkg.com/swc-plugin-coverage-instrument/-/swc-plugin-coverage-instrument-0.0.25.tgz#111fdca889029eaa9104a62714c4fdd88d35da60" + integrity sha512-7watbzeySulr+7o3NbSaD2icOmEYBKf1uUQs2sBYv1cdWHEsUQETxI/CpXn+Q53vaBU0e3puWPO99OW3DVq34g== + +swiper@^11.1.12: + version "11.1.12" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.1.12.tgz#563b90dd0162925025878c2ec4e136cc46bcb4f4" + integrity sha512-PUkCToYAZMB4kP7z+YfPnkMHOMwMO71g8vUhz2o5INGIgIMb6Sb0XiP6cEJFsiFTd7FRDn5XCbg+KVKPDZqXLw== + synchronous-promise@^2.0.15: version "2.0.17" resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.17.tgz#38901319632f946c982152586f2caf8ddc25c032" @@ -10773,8 +10859,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -10792,6 +10877,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" diff --git a/k8s/main.ts b/k8s/main.ts index 23e9c5629..8276c0b29 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -31,7 +31,7 @@ export class MyChart extends PennLabsChart { new DjangoApplication(this, 'django-wsgi', { deployment: { image: backendImage, - replicas: 4, + replicas: 2, secret: clubsSecret, env: [ { name: 'REDIS_HOST', value: 'penn-clubs-redis' }, @@ -60,101 +60,61 @@ export class MyChart extends PennLabsChart { new ReactApplication(this, 'react', { deployment: { image: frontendImage, - replicas: 3, + replicas: 2, }, domain: { host: clubsDomain, paths: ['/'] }, port: 80, ingressProps, }); - /** FYH */ - const fyhSecret = 'hub-at-penn'; - const fyhDomain = 'hub.provost.upenn.edu'; - - new RedisApplication(this, 'hub-redis', {}); - - new DjangoApplication(this, 'hub-django-asgi', { - deployment: { - image: backendImage, - cmd: ['/usr/local/bin/asgi-run'], - replicas: 1, - secret: fyhSecret, - env: [ - { name: 'REDIS_HOST', value: 'penn-clubs-hub-redis' }, - { name: 'NEXT_PUBLIC_SITE_NAME', value: 'fyh' }, - ], - }, - ingressProps: { - annotations: { - ["ingress.kubernetes.io/protocol"]: "https", - ["traefik.ingress.kubernetes.io/router.middlewares"]: "default-redict-http@kubernetescrd" - } - }, - djangoSettingsModule: 'pennclubs.settings.production', - domains: [{ host: fyhDomain, paths: ['/api'] }], - }); - - new ReactApplication(this, 'hub-react', { - deployment: { - image: frontendImage, - replicas: 1, - env: [ - { name: 'NEXT_PUBLIC_SITE_NAME', value: 'fyh' }, - ], - }, - domain: { host: fyhDomain, paths: ['/'] }, - ingressProps, - port: 80, - }); - /** Cronjobs **/ new CronJob(this, 'rank-clubs', { - schedule: cronTime.everyDayAt(8), + schedule: cronTime.everyDayAt(6, 18), image: backendImage, secret: clubsSecret, cmd: ['python', 'manage.py', 'rank'], }); - new CronJob(this, 'daily-notifications', { - schedule: cronTime.everyDayAt(13), + new CronJob(this, 'update-club-counts', { + schedule: cronTime.everyDayAt(0, 12), image: backendImage, secret: clubsSecret, - cmd: ['python', 'manage.py', 'daily_notifications'], - }); + cmd: ['python', 'manage.py', 'update_club_counts'], + }) - new CronJob(this, 'hub-daily-notifications', { - schedule: cronTime.everyDayAt(13), + new CronJob(this, 'osa-perms-updates', { + schedule: cronTime.every(5).minutes(), image: backendImage, - secret: fyhSecret, - cmd: ['python', 'manage.py', 'daily_notifications'], + secret: clubsSecret, + cmd: ['python', 'manage.py', 'osa_perms_updates'], }); - new CronJob(this, 'calendar-import', { - schedule: cronTime.everyDayAt(12), + new CronJob(this, 'daily-notifications', { + schedule: cronTime.onSpecificDaysAt(['monday', 'wednesday', 'friday'], 10, 0), image: backendImage, secret: clubsSecret, - cmd: ['python', 'manage.py', 'import_calendar_events'], + cmd: ['python', 'manage.py', 'daily_notifications'], }); - new CronJob(this, 'hub-calendar-import', { + new CronJob(this, 'calendar-import', { schedule: cronTime.everyDayAt(12), image: backendImage, - secret: fyhSecret, + secret: clubsSecret, cmd: ['python', 'manage.py', 'import_calendar_events'], }); - new CronJob(this, 'hub-paideia-calendar-import', { + new CronJob(this, 'expire-stale-membership-invites', { schedule: cronTime.everyDayAt(12), image: backendImage, - secret: fyhSecret, - cmd: ["python", "manage.py", "import_paideia_events"], + secret: clubsSecret, + cmd: ["python", "manage.py", "expire_membership_invites"], }); - new CronJob(this, 'expire-stale-membership-invites', { - schedule: cronTime.everyDayAt(12), + new CronJob(this, 'graduate-users', { + schedule: cronTime.everyYearIn(1, 1, 12, 0), image: backendImage, - secret: fyhSecret, - cmd: ["python", "manage.py", "expire_membership_invites"], + secret: clubsSecret, + cmd: ["python", "manage.py", "graduate_users"], }); } }