diff --git a/.gitignore b/.gitignore index 72ea923c3..dcff588e4 100644 --- a/.gitignore +++ b/.gitignore @@ -263,3 +263,6 @@ web.code-workspace # Apollo VS Code extension apollo.config.js + +# Local Github AI skills +.github/skills/**/* \ No newline at end of file diff --git a/backend/apps/events/admin.py b/backend/apps/events/admin.py index 5e2650879..b114fc716 100644 --- a/backend/apps/events/admin.py +++ b/backend/apps/events/admin.py @@ -8,5 +8,5 @@ @admin.register(Event) class EventAdmin(admin.ModelAdmin): search_fields = ["title"] - list_display = ("title", "organization", "start_time", "is_attendable") - list_filter = ("organization__name", "is_attendable") + list_display = ("title", "organization", "start_time", "is_attendable", "is_hidden") + list_filter = ("organization__name", "is_attendable", "is_hidden") diff --git a/backend/apps/events/migrations/0020_event_is_hidden.py b/backend/apps/events/migrations/0020_event_is_hidden.py new file mode 100644 index 000000000..03acd25c0 --- /dev/null +++ b/backend/apps/events/migrations/0020_event_is_hidden.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-08-25 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0019_alter_event_allowed_grade_years'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='is_hidden', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/apps/events/models.py b/backend/apps/events/models.py index b3420a918..e10a2b5a5 100644 --- a/backend/apps/events/models.py +++ b/backend/apps/events/models.py @@ -47,6 +47,8 @@ class Event(models.Model, Sellable): image = models.URLField(blank=True, null=True) short_description = models.CharField(max_length=100, blank=True, null=True) has_extra_information = models.BooleanField(default=False) + + is_hidden = models.BooleanField(default=False) contact_email = models.EmailField(blank=True, default="") GRADE_CHOICES = ((1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")) allowed_grade_years = MultiSelectField( diff --git a/backend/apps/events/mutations.py b/backend/apps/events/mutations.py index 644514460..f8371d56b 100644 --- a/backend/apps/events/mutations.py +++ b/backend/apps/events/mutations.py @@ -33,6 +33,7 @@ class BaseEventInput: contact_email = graphene.String(required=False) binding_signup = graphene.Boolean(required=False) allowed_grade_years = graphene.List(NonNull(graphene.Int)) + is_hidden = graphene.Boolean(required=False) class CreateEventInput(BaseEventInput, graphene.InputObjectType): diff --git a/backend/apps/events/resolvers.py b/backend/apps/events/resolvers.py index f2884e866..e99f6f7f5 100644 --- a/backend/apps/events/resolvers.py +++ b/backend/apps/events/resolvers.py @@ -51,7 +51,7 @@ def resolve_all_events( Get all events that fit the given filters """ if category or organization or start_time or end_time: - filteredEvents = Event.objects + filteredEvents = Event.objects.filter(is_hidden=False) if start_time and end_time: filteredEvents = filteredEvents.filter( @@ -88,15 +88,18 @@ def resolve_all_events( ) # Only show events that have yet to pass .order_by("start_time") ) - return Event.objects.filter(start_time__gte=date.today()).order_by("start_time") + return ( + Event.objects.filter(is_hidden=False, start_time__gte=date.today()) + .order_by("start_time") + ) def resolve_default_events(self, info): """ For each organization, get the most recent (future) event """ - return Event.objects.filter(start_time__gte=date.today()).distinct( - "organization" - ) + return Event.objects.filter( + is_hidden=False, start_time__gte=date.today() + ).distinct("organization") def resolve_event(self, info, id): try: diff --git a/backend/apps/events/tests.py b/backend/apps/events/tests.py index 5afef903d..2160329ba 100644 --- a/backend/apps/events/tests.py +++ b/backend/apps/events/tests.py @@ -120,6 +120,78 @@ def test_send_mail_on_available_slots_expanded(self, send_mail_mock: MagicMock): self.assertEqual(send_mail_mock.call_args_list[1].args[1], self.event) +class HiddenEventTestCase(EventsBaseTestCase): + def setUp(self) -> None: + super().setUp() + self.visible_event = EventFactory(is_attendable=False, is_hidden=False) + self.hidden_event = EventFactory(is_attendable=False, is_hidden=True) + + def test_hidden_event_excluded_from_all_events(self): + query = """ + query { + allEvents { + id + } + } + """ + response = self.query(query) + event_ids = {event["id"] for event in response.json()["data"]["allEvents"]} + self.assertIn(str(self.visible_event.id), event_ids) + self.assertNotIn(str(self.hidden_event.id), event_ids) + + def test_hidden_event_excluded_from_all_events_with_filters(self): + query = f""" + query {{ + allEvents(organization: "{self.hidden_event.organization.name}") {{ + id + }} + }} + """ + response = self.query(query) + event_ids = {event["id"] for event in response.json()["data"]["allEvents"]} + self.assertNotIn(str(self.hidden_event.id), event_ids) + + def test_hidden_event_excluded_from_default_events(self): + query = """ + query { + defaultEvents { + id + } + } + """ + response = self.query(query) + event_ids = {event["id"] for event in response.json()["data"]["defaultEvents"]} + self.assertNotIn(str(self.hidden_event.id), event_ids) + + def test_hidden_event_still_accessible_by_id(self): + query = f""" + query {{ + event(id: {self.hidden_event.id}) {{ + id + }} + }} + """ + response = self.query(query) + self.assertEqual(response.json()["data"]["event"]["id"], str(self.hidden_event.id)) + + def test_update_event_can_set_is_hidden(self): + org_user = IndokUserFactory() + MembershipFactory(user=org_user, organization=self.visible_event.organization) + mutation = f""" + mutation {{ + updateEvent(id: {self.visible_event.id}, eventData: {{isHidden: true}}) {{ + ok + event {{ + isHidden + }} + }} + }} + """ + response = self.query(mutation, user=org_user) + self.assertTrue(response.json()["data"]["updateEvent"]["ok"]) + self.assertTrue(response.json()["data"]["updateEvent"]["event"]["isHidden"]) + + class AttendeeReportExportTestCase(EventsBaseTestCase): def test_wrap_attendee_report_as_json_supports_xlsx(self): dataframe = pd.DataFrame( diff --git a/backend/apps/events/types.py b/backend/apps/events/types.py index de0d93f34..368e4895f 100644 --- a/backend/apps/events/types.py +++ b/backend/apps/events/types.py @@ -109,6 +109,7 @@ class Meta: "has_extra_information", "binding_signup", "contact_email", + "is_hidden", ] class PermissionDecorators: diff --git a/backend/schema.json b/backend/schema.json index 0f5d8612b..c4c26af6e 100644 --- a/backend/schema.json +++ b/backend/schema.json @@ -3848,6 +3848,22 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isHidden", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -16062,6 +16078,16 @@ } } }, + { + "defaultValue": null, + "description": null, + "name": "isHidden", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, { "defaultValue": null, "description": null, @@ -16300,6 +16326,16 @@ } } }, + { + "defaultValue": null, + "description": null, + "name": "isHidden", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, { "defaultValue": null, "description": null, diff --git a/frontend/src/components/pages/events/EventDetail/index.tsx b/frontend/src/components/pages/events/EventDetail/index.tsx index 9295a5c5b..c58662729 100644 --- a/frontend/src/components/pages/events/EventDetail/index.tsx +++ b/frontend/src/components/pages/events/EventDetail/index.tsx @@ -115,7 +115,7 @@ export const EventDetail: React.FC = ({ event }) => { - + )} {event.category && ( diff --git a/frontend/src/components/pages/organization/OrgMembers.tsx b/frontend/src/components/pages/organization/OrgMembers.tsx index 261b2aee7..2a9b92753 100644 --- a/frontend/src/components/pages/organization/OrgMembers.tsx +++ b/frontend/src/components/pages/organization/OrgMembers.tsx @@ -63,9 +63,9 @@ export const OrgMembers: React.FC = ({ organization }) => { const currentUserId = currentUserData?.user?.id; const isHrMember = Boolean( currentUserId && - memberships.some( - (membership) => membership.user.id === currentUserId && membership?.group?.uuid === organization.hrGroup?.uuid - ) + memberships.some( + (membership) => membership.user.id === currentUserId && membership?.group?.uuid === organization.hrGroup?.uuid + ) ); const canEditMemberships = isHrMember || Boolean(manageOrganizationPermissionData?.hasPermission); @@ -193,7 +193,7 @@ export const OrgMembers: React.FC = ({ organization }) => { }, }); - setFeedbackSuccess(isAdmin ? "Bruker demotert til medlem." : "Bruker promotert til administrator."); + setFeedbackSuccess(isAdmin ? "Bruker degradert til medlem." : "Bruker forfremmet til administrator."); await refetch(); } catch (err) { setFeedbackError(toErrorMessage(err, "Klarte ikke å oppdatere rolle.")); @@ -251,7 +251,7 @@ export const OrgMembers: React.FC = ({ organization }) => { {membership.user.firstName} {membership.user.lastName} - {membership?.group?.name === "HR" ? "Administrator" : (membership?.group?.name ?? "--")} + {membership?.group?.name === "HR" ? "Administrator" : membership?.group?.name ?? "--"} {canEditMemberships ? ( @@ -265,7 +265,7 @@ export const OrgMembers: React.FC = ({ organization }) => { void promoteOrDemote(membership.user.id, membership?.group?.uuid === organization.hrGroup?.uuid) } > - {membership?.group?.uuid === organization.hrGroup?.uuid ? "Demoter" : "Promoter"} + {membership?.group?.uuid === organization.hrGroup?.uuid ? "Degrader" : "Forfrem"}