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/views.py b/backend/clubs/views.py index 86d21926a..f8e8ab3e7 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -6758,6 +6758,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): """ @@ -6766,7 +6812,10 @@ def applications(self, *args, **kwargs): requestBody: {} responses: "200": - content: {} + content: + text/csv: + schema: + type: string --- """ cycle = self.get_object() diff --git a/backend/pennclubs/settings/base.py b/backend/pennclubs/settings/base.py index 4c645ea94..d74fa029a 100644 --- a/backend/pennclubs/settings/base.py +++ b/backend/pennclubs/settings/base.py @@ -265,3 +265,5 @@ # Cybersource settings CYBERSOURCE_CLIENT_VERSION = "0.15" + +OSA_KEYS = None 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 bea022416..ed445fe2b 100644 --- a/backend/pennclubs/settings/production.py +++ b/backend/pennclubs/settings/production.py @@ -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/tests/clubs/test_commands.py b/backend/tests/clubs/test_commands.py index 48e3799b3..90ceefda1 100644 --- a/backend/tests/clubs/test_commands.py +++ b/backend/tests/clubs/test_commands.py @@ -777,3 +777,31 @@ def test_graduate_users_output(self): "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/frontend/components/ClubCard.tsx b/frontend/components/ClubCard.tsx index 80254bd87..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 ( diff --git a/frontend/components/ClubEditPage/ClubEditCard.tsx b/frontend/components/ClubEditPage/ClubEditCard.tsx index 9e4509592..3f1c4d7e9 100644 --- a/frontend/components/ClubEditPage/ClubEditCard.tsx +++ b/frontend/components/ClubEditPage/ClubEditCard.tsx @@ -40,8 +40,9 @@ import { SITE_ID, SITE_NAME, } from '../../utils/branding' +import { ModalContent } from '../ClubPage/Actions' import { LiveBanner, LiveSub, LiveTitle } from '../ClubPage/LiveEventsDialog' -import { Checkbox, CheckboxLabel, Contact, Text } from '../common' +import { Checkbox, CheckboxLabel, Contact, Modal, Text } from '../common' import { CheckboxField, CheckboxTextField, @@ -150,6 +151,48 @@ const Card = ({ ) } +interface EmailModalProps { + closeModal: () => void + email: string + setEmail: (inp: string) => void + confirmSubmission: () => void +} + +const EmailModal = ({ + closeModal, + email, + setEmail, + confirmSubmission, +}: EmailModalProps): ReactElement => { + return ( + +
+ + Warning: This email will be shown to the public. We highly recommend + you don't use a personal email, and instead use a club email. Feel + free to ignore this warning if the email is not a personal email. + + setEmail(e.target.value)} + className="input mb-4" + style={{ maxWidth: '350px' }} + > +
+ +
+
+
+ ) +} /** * Remove fields in an object that are not part of a whitelist. @@ -189,6 +232,7 @@ export default function ClubEditCard({ isEdit, onSubmit = () => Promise.resolve(undefined), }: ClubEditCardProps): ReactElement { + const [showRankModal, setShowRankModal] = useState(false) const [showTargetFields, setShowTargetFields] = useState( !!( club.target_majors?.length || @@ -227,6 +271,8 @@ export default function ClubEditCard({ ), ) + const [emailModal, showEmailModal] = useState(false) + const submit = (data, { setSubmitting, setStatus }): Promise => { const photo = data.image if (photo !== null) { @@ -397,6 +443,58 @@ export default function ClubEditCard({ { name: 'General', type: 'group', + description: ( +
+ setShowRankModal(true)}> + How does filling out this information affect your club? + + setShowRankModal(false)} + marginBottom={false} + width="80%" + > + +

How we calculate club rankings

+
+
+ The following positively affects your club's ranking in homepage + search results: +
+
    +
  • + Upcoming events with filled out name, description, and image +
  • +
  • Upcoming, open applications for membership
  • +
  • + Having at least 3 active officers, plus a bonus for any + additional non-officer member on the platform +
  • +
  • + Having between 3 and 7 useful tags (please email {' '} + if none apply) +
  • +
  • + Posting a public (non-personal) contact email and 2 or more + social links +
  • +
  • + Having a club logo image uploaded and subtitle filled out +
  • +
  • + Filling out a club mission with images and detail (rewarded up + to 1000 words) +
  • +
  • Displaying 3 or more student testimonials (experiences)
  • +
  • Filling out the {FIELD_PARTICIPATION_LABEL} section
  • +
  • + Updating the club listing recently (within the last 8 months) +
  • +
+
+
+
+ ), fields: [ { name: 'name', @@ -446,8 +544,9 @@ export default function ClubEditCard({ }, { name: 'description', + label: 'Club Mission', required: true, - placeholder: `Type your ${OBJECT_NAME_SINGULAR} description here!`, + placeholder: `Type your ${OBJECT_NAME_SINGULAR} mission here!`, type: 'html', hidden: !REAPPROVAL_QUEUE_ENABLED, }, @@ -795,6 +894,7 @@ export default function ClubEditCard({ const creationDefaults = { subtitle: '', + email: '', email_public: true, accepting_members: false, size: CLUB_SIZES[0].value, @@ -816,9 +916,36 @@ export default function ClubEditCard({ : creationDefaults return ( - - {({ dirty, isSubmitting }) => ( + + 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 }) => (
+ {emailModal && ( + showEmailModal(false)} + email={values.email} + setEmail={(newEmail) => setFieldValue('email', newEmail)} + confirmSubmission={() => { + showEmailModal(false) + submitForm() + }} + /> + )} {!REAPPROVAL_QUEUE_ENABLED && ( Queue Closed for Summer Break 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 => (
- Description + Club Mission
{

Embed Content

You can use this tool to embed multimedia content into your club - description. If you run into any issues using the tool, please - contact . Here are examples of some of the things you can - embed. + mission. If you run into any issues using the tool, please contact{' '} + . Here are examples of some of the things you can embed.

    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 = ( <> - - - - + + + + ) 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({ name: '', id: null, + endDate: new Date(), }) const [editExtensions, setEditExtensions] = useState(false) const [extensionsCycle, setExtensionsCycle] = useState({ 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 => { + {clubsExtensions.some( + (x) => + !x.exception && + !x.originalException && + x.endDate.getTime() !== extensionsCycle.endDate.getTime(), + ) && ( +

    + To change the end date for a club, you must also check its + exception box. +

    + )} )} diff --git a/frontend/pages/club/[club]/renew.tsx b/frontend/pages/club/[club]/renew.tsx index c5304f519..4390583e5 100644 --- a/frontend/pages/club/[club]/renew.tsx +++ b/frontend/pages/club/[club]/renew.tsx @@ -295,7 +295,7 @@ const RenewPage = (props: RenewPageProps): ReactElement => { name: 'University Affiliation', content: (
    - The club description must clearly state that the group is a student + The club mission must clearly state that the group is a student organization at the University.
    ), diff --git a/frontend/pages/rank.tsx b/frontend/pages/rank.tsx deleted file mode 100644 index c62277a9d..000000000 --- a/frontend/pages/rank.tsx +++ /dev/null @@ -1,365 +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 ( -
    - {items.map(({ name, description, points }) => ( - - - - -
    - {name} - {description} - {points && ( -
      - {points.map(([num, desc], i) => ( -
    • - {num > 0 ? `+${num}` : num}: {desc} -
    • - ))} -
    - )} -
    -
    - ))} -
    - ) -} - -const Rank = (): ReactElement => ( - - - - {OBJECT_NAME_TITLE_SINGULAR} Recommendation Algorithm - - {SHOW_RANK_ALGORITHM || ( -
    - 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. -
    - )} - How are {OBJECT_NAME_PLURAL} ordered? - - 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. - - - How does the {OBJECT_NAME_SINGULAR} recommendation algorithm work? - - - 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: - - - 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 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: [ - [ - 25, - 'Standard exponential random number scaled to average this number, updated daily', - ], - ], - }, - ]} - /> - - The algorithm also attempts to personalize search results for logged in - users, based on the following criteria: - - - 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. - - ), - }, - ]} - /> -
    -) - -export default renderPage(Rank) 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 = - 'This club has not added a description yet.' + 'This club has not added a club mission yet.' export const LOGIN_URL = `${API_BASE_URL}/accounts/login/` export const LOGOUT_URL = `${API_BASE_URL}/accounts/logout/` diff --git a/k8s/main.ts b/k8s/main.ts index dac1f2dd8..fc7198317 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -75,6 +75,13 @@ export class MyChart extends PennLabsChart { cmd: ['python', 'manage.py', 'rank'], }); + new CronJob(this, 'osa-perms-updates', { + schedule: cronTime.every(5).minutes(), + image: backendImage, + secret: clubsSecret, + cmd: ['python', 'manage.py', 'osa_perms_updates'], + }); + new CronJob(this, 'daily-notifications', { schedule: cronTime.onSpecificDaysAt(['monday', 'wednesday', 'friday'], 10, 0), image: backendImage,