diff --git a/backend/Makefile b/backend/Makefile index 7731affb5a..3d901574ba 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -22,6 +22,7 @@ enrich-data: \ github-enrich-issues \ owasp-enrich-chapters \ owasp-enrich-committees \ + owasp-enrich-events \ owasp-enrich-projects generate-sitemap: @@ -70,6 +71,10 @@ owasp-enrich-committees: @echo "Enriching OWASP committees" @CMD="python manage.py owasp_enrich_committees" $(MAKE) exec-backend-command +owasp-enrich-events: + @echo "Enriching OWASP events" + @CMD="python manage.py owasp_enrich_events" $(MAKE) exec-backend-command + owasp-enrich-projects: @echo "Enriching OWASP projects" @CMD="python manage.py owasp_enrich_projects" $(MAKE) exec-backend-command diff --git a/backend/apps/core/models/prompt.py b/backend/apps/core/models/prompt.py index 6616e9e31f..66b6fe7b7f 100644 --- a/backend/apps/core/models/prompt.py +++ b/backend/apps/core/models/prompt.py @@ -71,6 +71,16 @@ def get_owasp_committee_summary(): """Return OWASP committee summary prompt.""" return Prompt.get_text("owasp-committee-summary") + @staticmethod + def get_owasp_event_suggested_location(): + """Return OWASP event suggested location prompt.""" + return Prompt.get_text("owasp-event-suggested-location") + + @staticmethod + def get_owasp_event_summary(): + """Return OWASP event summary prompt.""" + return Prompt.get_text("owasp-event-summary") + @staticmethod def get_owasp_project_summary(): """Return OWASP project summary prompt.""" diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index c6aabd1c20..244c9c9967 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -66,7 +66,10 @@ class CommitteeAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin): - list_display = ("name",) + list_display = ( + "name", + "suggested_location", + ) search_fields = ("name",) diff --git a/backend/apps/owasp/graphql/nodes/event.py b/backend/apps/owasp/graphql/nodes/event.py index e20023521e..db6b9e6c94 100644 --- a/backend/apps/owasp/graphql/nodes/event.py +++ b/backend/apps/owasp/graphql/nodes/event.py @@ -16,5 +16,7 @@ class Meta: "key", "name", "start_date", + "suggested_location", + "summary", "url", ) diff --git a/backend/apps/owasp/management/commands/owasp_enrich_events.py b/backend/apps/owasp/management/commands/owasp_enrich_events.py new file mode 100644 index 0000000000..4c61bf10ff --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_enrich_events.py @@ -0,0 +1,53 @@ +"""A command to enrich events with extra data.""" + +import logging +import time + +from django.core.management.base import BaseCommand + +from apps.core.models.prompt import Prompt +from apps.owasp.models.event import Event + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Enrich events with extra data." + + def add_arguments(self, parser): + parser.add_argument("--offset", default=0, required=False, type=int) + + def handle(self, *args, **options): + events = Event.objects.order_by("id") + all_events = [] + offset = options["offset"] + + for idx, event in enumerate(events[offset:]): + prefix = f"{idx + offset + 1} of {events.count()}" + print(f"{prefix:<10} {event.url}") + # Summary. + if not event.summary and (prompt := Prompt.get_owasp_event_summary()): + event.generate_summary(prompt) + + # Suggested location. + if not event.suggested_location and ( + prompt := Prompt.get_owasp_event_suggested_location() + ): + event.generate_suggested_location(prompt) + + # Geo location. + if not event.latitude or not event.longitude: + try: + event.generate_geo_location() + time.sleep(5) + except Exception: + logger.exception( + "Could not get geo data for event", + extra={"url": event.url}, + ) + all_events.append(event) + + Event.bulk_save( + all_events, + fields=("latitude", "longitude", "suggested_location", "summary"), + ) diff --git a/backend/apps/owasp/migrations/0023_event_latitude_event_longitude_and_more.py b/backend/apps/owasp/migrations/0023_event_latitude_event_longitude_and_more.py new file mode 100644 index 0000000000..ec2e31c4fd --- /dev/null +++ b/backend/apps/owasp/migrations/0023_event_latitude_event_longitude_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.7 on 2025-03-08 20:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0022_sponsor"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="latitude", + field=models.FloatField(blank=True, null=True, verbose_name="Latitude"), + ), + migrations.AddField( + model_name="event", + name="longitude", + field=models.FloatField(blank=True, null=True, verbose_name="Longitude"), + ), + migrations.AddField( + model_name="event", + name="suggested_location", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Suggested Location" + ), + ), + migrations.AddField( + model_name="event", + name="summary", + field=models.TextField(blank=True, default="", verbose_name="Summary"), + ), + ] diff --git a/backend/apps/owasp/migrations/0024_event_country_event_postal_code_event_region.py b/backend/apps/owasp/migrations/0024_event_country_event_postal_code_event_region.py new file mode 100644 index 0000000000..50ce349cbc --- /dev/null +++ b/backend/apps/owasp/migrations/0024_event_country_event_postal_code_event_region.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.7 on 2025-03-09 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0023_event_latitude_event_longitude_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="country", + field=models.CharField(default="", max_length=50, verbose_name="Country"), + ), + migrations.AddField( + model_name="event", + name="postal_code", + field=models.CharField( + blank=True, default="", max_length=15, verbose_name="Postal code" + ), + ), + migrations.AddField( + model_name="event", + name="region", + field=models.CharField(default="", max_length=50, verbose_name="Region"), + ), + ] diff --git a/backend/apps/owasp/migrations/0025_remove_event_country_remove_event_postal_code_and_more.py b/backend/apps/owasp/migrations/0025_remove_event_country_remove_event_postal_code_and_more.py new file mode 100644 index 0000000000..8d6dd06442 --- /dev/null +++ b/backend/apps/owasp/migrations/0025_remove_event_country_remove_event_postal_code_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.7 on 2025-03-10 02:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0024_event_country_event_postal_code_event_region"), + ] + + operations = [ + migrations.RemoveField( + model_name="event", + name="country", + ), + migrations.RemoveField( + model_name="event", + name="postal_code", + ), + migrations.RemoveField( + model_name="event", + name="region", + ), + ] diff --git a/backend/apps/owasp/models/event.py b/backend/apps/owasp/models/event.py index e5876de66f..35aa6353b8 100644 --- a/backend/apps/owasp/models/event.py +++ b/backend/apps/owasp/models/event.py @@ -4,8 +4,11 @@ from django.db import models from django.utils import timezone +from apps.common.constants import NL +from apps.common.geocoding import get_location_coordinates from apps.common.models import BulkSaveModel, TimestampedModel -from apps.common.utils import slugify +from apps.common.open_ai import OpenAi +from apps.common.utils import join_values, slugify from apps.github.utils import normalize_url @@ -30,13 +33,18 @@ class Category(models.TextChoices): choices=Category.choices, default=Category.OTHER, ) - - end_date = models.DateField(verbose_name="End Date", null=True, blank=True) - key = models.CharField(verbose_name="Key", max_length=100, unique=True) name = models.CharField(verbose_name="Name", max_length=100) - description = models.TextField(verbose_name="Description", default="", blank=True) start_date = models.DateField(verbose_name="Start Date") + end_date = models.DateField(verbose_name="End Date", null=True, blank=True) + description = models.TextField(verbose_name="Description", default="", blank=True) + key = models.CharField(verbose_name="Key", max_length=100, unique=True) + summary = models.TextField(verbose_name="Summary", blank=True, default="") + suggested_location = models.CharField( + verbose_name="Suggested Location", max_length=255, blank=True, default="" + ) url = models.URLField(verbose_name="URL", default="", blank=True) + latitude = models.FloatField(verbose_name="Latitude", null=True, blank=True) + longitude = models.FloatField(verbose_name="Longitude", null=True, blank=True) def __str__(self): """Event human readable representation.""" @@ -130,3 +138,53 @@ def from_dict(self, category, data): for key, value in fields.items(): setattr(self, key, value) + + def generate_geo_location(self): + """Add latitude and longitude data.""" + location = None + if self.suggested_location and self.suggested_location != "None": + location = get_location_coordinates(self.suggested_location) + if location is None: + location = get_location_coordinates(self.get_context()) + if location: + self.latitude = location.latitude + self.longitude = location.longitude + + def generate_suggested_location(self, prompt): + """Generate a suggested location for the event.""" + open_ai = OpenAi() + open_ai.set_input(self.get_context()) + open_ai.set_max_tokens(100).set_prompt(prompt) + try: + suggested_location = open_ai.complete() + self.suggested_location = ( + suggested_location if suggested_location and suggested_location != "None" else "" + ) + except (ValueError, TypeError): + self.suggested_location = "" + + def generate_summary(self, prompt): + """Generate a summary for the event.""" + open_ai = OpenAi() + open_ai.set_input(self.get_context(include_dates=True)) + open_ai.set_max_tokens(100).set_prompt(prompt) + try: + summary = open_ai.complete() + self.summary = summary if summary and summary != "None" else "" + except (ValueError, TypeError): + self.summary = "" + + def get_context(self, include_dates=False): + """Return geo string.""" + context = [ + f"Name: {self.name}", + f"Description: {self.description}", + f"Summary: {self.summary}", + ] + if include_dates: + context.append(f"Dates: {self.start_date} - {self.end_date}") + + return join_values( + context, + delimiter=NL, + ) diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts index d4c70e9102..eed053ad4b 100644 --- a/frontend/__tests__/e2e/pages/Home.spec.ts +++ b/frontend/__tests__/e2e/pages/Home.spec.ts @@ -61,17 +61,17 @@ test.describe('Home Page', () => { test('should be able to join OWASP', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Ready to Make a Difference?' })).toBeVisible() await expect(page.getByText('Join OWASP and be part of the')).toBeVisible() - await expect(page.getByRole('link', { name: 'Join OWASP Now' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Join OWASP' })).toBeVisible() const page1Promise = page.waitForEvent('popup') - await page.getByRole('link', { name: 'Join OWASP Now' }).click() + await page.getByRole('link', { name: 'Join OWASP' }).click() const page1 = await page1Promise expect(page1.url()).toBe('https://owasp.glueup.com/organization/6727/memberships/') }) test('should have upcoming events', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Upcoming Events' })).toBeVisible() - await expect(page.getByRole('link', { name: 'Event 1' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Event 1' })).toBeVisible() await expect(page.getByText('Feb 27 — 28, 2025')).toBeVisible() - await page.getByRole('link', { name: 'Event 1' }).click() + await page.getByRole('button', { name: 'Event 1' }).click() }) }) diff --git a/frontend/__tests__/unit/pages/Modal.test.tsx b/frontend/__tests__/unit/pages/Modal.test.tsx index a41411feb4..e6118736a6 100644 --- a/frontend/__tests__/unit/pages/Modal.test.tsx +++ b/frontend/__tests__/unit/pages/Modal.test.tsx @@ -38,6 +38,8 @@ describe('Dialog Component', () => { url: 'https://example.com/issue/123', }, children: undefined as React.ReactNode | undefined, + description: + 'The issue summary and the recommended steps to address it have been generated by AI', } const renderModal = (props = defaultProps) => { diff --git a/frontend/src/api/queries/homeQueries.ts b/frontend/src/api/queries/homeQueries.ts index 0cba38152e..78dcfba190 100644 --- a/frontend/src/api/queries/homeQueries.ts +++ b/frontend/src/api/queries/homeQueries.ts @@ -66,8 +66,11 @@ export const GET_MAIN_PAGE_DATA = gql` upcomingEvents(limit: 6) { category endDate + key name startDate + summary + suggestedLocation url } } diff --git a/frontend/src/components/ItemCardList.tsx b/frontend/src/components/ItemCardList.tsx index 3107108565..09ea9264f1 100644 --- a/frontend/src/components/ItemCardList.tsx +++ b/frontend/src/components/ItemCardList.tsx @@ -18,7 +18,7 @@ const ItemCardList = ({ }) => ( {data && data.length > 0 ? ( -
+
{data.map((item, index) => (
diff --git a/frontend/src/components/LogoCarousel.tsx b/frontend/src/components/LogoCarousel.tsx index 20d9a1adf2..13ae4eae11 100644 --- a/frontend/src/components/LogoCarousel.tsx +++ b/frontend/src/components/LogoCarousel.tsx @@ -53,8 +53,8 @@ export default function MovingLogos({ sponsors }: MovingLogosProps) {

- These logos represent the corporate supporters of the OWASP Foundation, whose - contributions fuel OWASP's security initiatives. Visit{' '} + These logos represent the corporate supporters, whose contributions fuel OWASP Foundation + security initiatives. Visit{' '} this page {' '} - to become an OWASP Foundation corporate supporter. If you're interested in sponsoring the - OWASP Nest project ❤️{' '} + to become a corporate supporter. +

+

+ If you're interested in sponsoring the OWASP Nest project ❤️{' '} = ({ onClose, button, children, + description, }: ModalProps) => { return ( = ({ {title} - - The issue summary and the recommended steps to address it have been generated by AI - + {description} - Issue Summary + Summary {hint && ( diff --git a/frontend/src/pages/Contribute.tsx b/frontend/src/pages/Contribute.tsx index 4c053610a6..11230ceed1 100644 --- a/frontend/src/pages/Contribute.tsx +++ b/frontend/src/pages/Contribute.tsx @@ -64,6 +64,7 @@ const ContributePage = () => { summary={issue.summary} hint={issue.hint} button={viewIssueButton} + description="The issue summary and the recommended steps to address it have been generated by AI" > ) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 78b06da9cd..69aa1bbb39 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -23,6 +23,7 @@ import ChapterMap from 'components/ChapterMap' import ItemCardList from 'components/ItemCardList' import LoadingSpinner from 'components/LoadingSpinner' import MovingLogos from 'components/LogoCarousel' +import Modal from 'components/Modal' import MultiSearchBar from 'components/MultiSearch' import SecondaryCard from 'components/SecondaryCard' import TopContributors from 'components/ToggleContributors' @@ -32,6 +33,7 @@ export default function Home() { const [data, setData] = useState(null) const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_MAIN_PAGE_DATA) const [geoLocData, setGeoLocData] = useState([]) + const [modalOpenIndex, setModalOpenIndex] = useState(null) useEffect(() => { if (graphQLData) { @@ -130,28 +132,38 @@ export default function Home() {

-
- {data.upcomingEvents.map((event: EventType) => ( -
-

- + {data.upcomingEvents.map((event: EventType, index: number) => ( +

-
-
- - {formatDateRange(event.startDate, event.endDate)} +

{event.name}

+ +
+
+ + {formatDateRange(event.startDate, event.endDate)} +
+ {event.suggestedLocation && ( +
+ + {event.suggestedLocation} +
+ )}
+ setModalOpenIndex(null)} + title={event.name} + summary={event.summary} + button={{ label: 'View Event', url: event.url }} + description="The event summary has been generated by AI" + >
))}
@@ -210,7 +222,7 @@ export default function Home() {
-

OWASP Chapters Worldwide

+

OWASP Chapters Worldwide

- Join OWASP Now + Join OWASP diff --git a/frontend/src/types/event.ts b/frontend/src/types/event.ts index c540720290..028e87e312 100644 --- a/frontend/src/types/event.ts +++ b/frontend/src/types/event.ts @@ -5,4 +5,6 @@ export type EventType = { name: string startDate: string url: string + summary?: string + suggestedLocation?: string } diff --git a/frontend/src/types/modal.ts b/frontend/src/types/modal.ts index 089e479d22..5c6fb1e08b 100644 --- a/frontend/src/types/modal.ts +++ b/frontend/src/types/modal.ts @@ -9,4 +9,5 @@ export interface ModalProps { onClose: () => void button: ButtonType children?: React.ReactNode + description: string }