diff --git a/backend/api/organizations.py b/backend/api/organizations.py index 11bcbfdb9..fd129a801 100644 --- a/backend/api/organizations.py +++ b/backend/api/organizations.py @@ -2,16 +2,31 @@ Organization routes are used to create, retrieve, and update Organizations.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Body +from typing import Annotated from ..services import OrganizationService, RoleService +from ..services.academics import TermService from ..models.organization import Organization +from ..models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipRegistration, + OrganizationPermissionLevel, + OrganizationMembershipStatus, +) from ..models.organization_details import OrganizationDetails from ..api.authentication import registered_user from ..models.user import User - -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] -__copyright__ = "Copyright 2023" +from datetime import datetime + +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Alanna Zhang", +] +__copyright__ = "Copyright 2025" __license__ = "MIT" api = APIRouter(prefix="/api/organizations") @@ -141,3 +156,147 @@ def delete_organization( """ organization_service.delete(subject, slug) + + +@api.post( + "/{slug}/roster", + responses={404: {"model": None}}, + response_model=OrganizationMembership, + tags=["Organizations"], +) +def add_membership( + slug: str, + membership_registration: Annotated[ + OrganizationMembershipRegistration, + Body( + description="Details to create a new organization membership", + openapi_examples={ + "default": { + "summary": "Default", + "value": {"user_id": 0, "organization_id": 0}, + }, + "custom": { + "summary": "Custom", + "value": { + "user_id": 0, + "organization_id": 0, + "title": "Member", + "permission_level": OrganizationPermissionLevel.MEMBER, + "status": OrganizationMembershipStatus.ACTIVE, + "term_id": "25S", + }, + }, + }, + ), + ], + organization_service: OrganizationService = Depends(), + term_service: TermService = Depends(), + subject: User = Depends(registered_user), +) -> OrganizationMembership: + """ + Add membership to organization with matching slug + + Parameters: + slug: a string representing a unique identifier for an Organization + membership_registration: an OrganizationMembershipRegistration object with info for a new OrganizationMembership + organization_service: a valid OrganizationService + subject: a valid User model representing the currently logged in User + + Returns: + OrganizationMember: Created organization member + + Raises: + HTTPException 404 if add_member() raises an Exception + """ + if membership_registration.term_id is None: + membership_registration.term_id = term_service.get_by_date(datetime.today()).id + return organization_service.add_membership(subject, slug, membership_registration) + + +@api.get( + "/{slug}/roster", + responses={404: {"model": None}}, + response_model=list[OrganizationMembership], + tags=["Organizations"], +) +def get_roster_by_slug( + slug: str, + organization_service: OrganizationService = Depends(), +) -> list[OrganizationMembership]: + """ + Get organization roster with matching slug + + Parameters: + slug: a string representing a unique identifier for an Organization + organization_service: a valid OrganizationService + + Returns: + list[OrganizationMembership]: List of OrganizationMemberships of the organization with matching slug + + Raises: + HTTPException 404 if get_roster() raises an Exception + """ + return organization_service.get_roster(slug) + + +@api.put( + "/{slug}/roster", + response_model=OrganizationMembership, + tags=["Organizations"], +) +def update_membership( + slug: str, + membership: Annotated[ + OrganizationMembershipRegistration, + Body( + description="Details to modify an organization membership", + openapi_examples={ + "model": { + "summary": "Default", + "value": { + "id": 0, + "user_id": 0, + "organization_id": 0, + "title": "Member", + "permission_level": OrganizationPermissionLevel.MEMBER, + "status": OrganizationMembershipStatus.ACTIVE, + "term_id": "25S", + }, + }, + }, + ), + ], + subject: User = Depends(registered_user), + organization_service: OrganizationService = Depends(), +) -> OrganizationMembership: + """ + Update membership details + + Parameters: + slug: a string representing a unique identifier for an Organization + membership: the OrganizationMembership to update + subject: a valid User model representing the currently logged in User + organization_service: a valid OrganizationService + """ + return organization_service.update_membership(subject, slug, membership) + + +@api.delete( + "/{slug}/roster/{membership_id}", response_model=None, tags=["Organizations"] +) +def delete_membership( + slug: str, + membership_id: int, + subject: User = Depends(registered_user), + organization_service: OrganizationService = Depends(), +): + """ + Delete membership based on membership_id + + Parameters: + slug: a string representing a unique identifier for an Organization + membership_id: a unique OrganizationMembership id + subject: a valid User model representing the currently logged in User + organization_service: a valid OrganizationService + """ + organization_service.delete_membership(subject, slug, membership_id) diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index 5ab2a911d..5ec7a00aa 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -22,6 +22,7 @@ from .room_entity import RoomEntity from .organization_entity import OrganizationEntity +from .organization_membership_entity import OrganizationMembershipEntity from .event_entity import EventEntity from .event_registration_entity import EventRegistrationEntity diff --git a/backend/entities/academics/term_entity.py b/backend/entities/academics/term_entity.py index 3706bf351..72a4a0a3f 100644 --- a/backend/entities/academics/term_entity.py +++ b/backend/entities/academics/term_entity.py @@ -59,6 +59,11 @@ class TermEntity(EntityBase): back_populates="term", cascade="all, delete" ) + # NOTE: This field establishes a one-to-many relationship between the term and organization membership tables. + organization_memberships: Mapped[list["OrganizationMembershipEntity"]] = ( + relationship(back_populates="term", cascade="all, delete") + ) + @classmethod def from_model(cls, model: Term) -> Self: """ diff --git a/backend/entities/organization_entity.py b/backend/entities/organization_entity.py index bf7d8bd20..7010c0590 100644 --- a/backend/entities/organization_entity.py +++ b/backend/entities/organization_entity.py @@ -1,14 +1,20 @@ """Definition of SQLAlchemy table-backed object mapping entity for Organizations.""" -from sqlalchemy import Integer, String, Boolean +from sqlalchemy import Integer, String, Boolean, Enum as SQLAlchemyEnum from sqlalchemy.orm import Mapped, mapped_column, relationship from .entity_base import EntityBase from typing import Self -from ..models.organization import Organization +from ..models.organization import Organization, OrganizationJoinType from ..models.organization_details import OrganizationDetails -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] -__copyright__ = "Copyright 2023" +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Alanna Zhang", +] +__copyright__ = "Copyright 2025" __license__ = "MIT" @@ -48,6 +54,14 @@ class OrganizationEntity(EntityBase): heel_life: Mapped[str] = mapped_column(String) # Whether the organization can be joined by anyone or not public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Whether the organization is open, application-based, or closed + join_type: Mapped[OrganizationJoinType] = mapped_column( + SQLAlchemyEnum(OrganizationJoinType) + ) + # Application link for an organization with APPLY join_type + application_url: Mapped[str | None] = mapped_column( + String, nullable=True, default=None + ) # NOTE: This field establishes a one-to-many relationship between the organizations and events table. events: Mapped[list["EventEntity"]] = relationship( @@ -59,6 +73,16 @@ class OrganizationEntity(EntityBase): back_populates="organization", cascade="all,delete" ) + # NOTE: This field establishes a one-to-many relationship between the organizations and organization_member table. + members: Mapped[list["OrganizationMembershipEntity"]] = relationship( + back_populates="organization", cascade="all,delete" + ) + users: Mapped[list["UserEntity"]] = relationship( + secondary="organization_membership", + back_populates="organizations", + viewonly=True, + ) + @classmethod def from_model(cls, model: Organization) -> Self: """ @@ -84,6 +108,8 @@ def from_model(cls, model: Organization) -> Self: youtube=model.youtube, heel_life=model.heel_life, public=model.public, + join_type=model.join_type, + application_url=model.application_url, ) def to_model(self) -> Organization: @@ -108,6 +134,8 @@ def to_model(self) -> Organization: youtube=self.youtube, heel_life=self.heel_life, public=self.public, + join_type=self.join_type, + application_url=self.application_url, ) def to_details_model(self) -> OrganizationDetails: @@ -132,5 +160,7 @@ def to_details_model(self) -> OrganizationDetails: youtube=self.youtube, heel_life=self.heel_life, public=self.public, + join_type=self.join_type, + application_url=self.application_url, events=[event.to_overview_model() for event in self.events], ) diff --git a/backend/entities/organization_membership_entity.py b/backend/entities/organization_membership_entity.py new file mode 100644 index 000000000..0116640a1 --- /dev/null +++ b/backend/entities/organization_membership_entity.py @@ -0,0 +1,96 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for Organization Members.""" + +from sqlalchemy import Integer, Boolean, String, ForeignKey, Enum as SQLAlchemyEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .entity_base import EntityBase +from typing import Self +from ..models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipRegistration, + OrganizationPermissionLevel, + OrganizationMembershipStatus, +) + +__authors__ = [ + "Alanna Zhang", +] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class OrganizationMembershipEntity(EntityBase): + + __tablename__ = "organization_membership" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # The member's user information + # NOTE: This defines a one-to-many relationship between the user and organization membership tables. + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) + user: Mapped["UserEntity"] = relationship(back_populates="memberships") + + # Organization that the member is a part of + # NOTE: This defines a one-to-many relationship between the organization and organization membership tables. + organization_id: Mapped[int] = mapped_column( + ForeignKey("organization.id"), primary_key=True + ) + organization: Mapped["OrganizationEntity"] = relationship(back_populates="members") + + # Membership title, default value of "Member" + title: Mapped[str] = mapped_column(String, default="Member") + + # Level of administrative privileges in organization + permission_level: Mapped["OrganizationPermissionLevel"] = mapped_column( + SQLAlchemyEnum(OrganizationPermissionLevel), + default=OrganizationPermissionLevel.MEMBER, + ) + + # Membership status + status: Mapped["OrganizationMembershipStatus"] = mapped_column( + SQLAlchemyEnum(OrganizationMembershipStatus) + ) + + # Membership term, default value of the current academic term + # NOTE: This defines a one-to-many relationship between the term and organization membership tables. + term_id: Mapped[str] = mapped_column(ForeignKey("academics__term.id")) + term: Mapped["TermEntity"] = relationship(back_populates="organization_memberships") + + @classmethod + def from_model( + cls, model: OrganizationMembership | OrganizationMembershipRegistration + ) -> Self: + """Create an OrganizationMembershipEntity from an OrganizationMembership model or Registration model.""" + if isinstance(model, OrganizationMembership): + return cls( + id=model.id, + user_id=model.user.id, + organization_id=model.organization_id, + title=model.title, + permission_level=model.permission_level, + status=model.status, + term_id=model.term.id, + ) + else: + return cls( + id=model.id, + user_id=model.user_id, + organization_id=model.organization_id, + title=model.title, + permission_level=model.permission_level, + status=model.status, + term_id=model.term_id, + ) + + def to_model(self) -> OrganizationMembership: + """Create an OrganizationMembership model from an OrganizationMembershipEntity.""" + return OrganizationMembership( + id=self.id, + user=self.user.to_public_model(), + organization_id=self.organization_id, + organization_name=self.organization.name, + organization_slug=self.organization.slug, + title=self.title, + permission_level=self.permission_level, + status=self.status, + term=self.term.to_model(), + ) diff --git a/backend/entities/user_entity.py b/backend/entities/user_entity.py index daeb4ae5d..2f0b76fa0 100644 --- a/backend/entities/user_entity.py +++ b/backend/entities/user_entity.py @@ -89,6 +89,15 @@ class UserEntity(EntityBase): back_populates="user" ) + # All of the organization membership the user is under + # NOTE: This field establishes a one-to-many relationship between the users and organization_member table. + memberships: Mapped[list["OrganizationMembershipEntity"]] = relationship( + back_populates="user" + ) + organizations: Mapped[list["OrganizationEntity"]] = relationship( + secondary="organization_membership", back_populates="users", viewonly=True + ) + def full_name(self) -> str: """ Returns the full name of the user. @@ -148,6 +157,11 @@ def to_model(self) -> User: bio=self.bio, linkedin=self.linkedin, website=self.website, + organizations=( + [org.name for org in self.organizations if org.name] + if self.organizations + else [] + ), ) def update(self, model: User) -> None: @@ -185,4 +199,9 @@ def to_public_model(self) -> PublicUser: bio=self.bio, linkedin=self.linkedin, website=self.website, + organizations=( + [org.name for org in self.organizations if org.name] + if self.organizations + else [] + ), ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 90aaaf7c6..bbf9fdff6 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -9,6 +9,7 @@ from .role import Role from .role_details import RoleDetails from .organization import Organization +from .organization_membership import OrganizationMembership from .event import EventOverview, EventDraft from .public_user import PublicUser from .room import Room diff --git a/backend/models/organization.py b/backend/models/organization.py index 1946910bc..3cbc0ad4e 100644 --- a/backend/models/organization.py +++ b/backend/models/organization.py @@ -1,10 +1,25 @@ from pydantic import BaseModel +from enum import Enum -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] -__copyright__ = "Copyright 2023" +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Alanna Zhang", +] +__copyright__ = "Copyright 2025" __license__ = "MIT" +class OrganizationJoinType(Enum): + """Enum to represent the join type of an organization.""" + + OPEN = "Open" + APPLY = "Apply" + CLOSED = "Closed" + + class Organization(BaseModel): """ Pydantic model to represent an `Organization`. @@ -27,3 +42,5 @@ class Organization(BaseModel): youtube: str heel_life: str public: bool + join_type: OrganizationJoinType + application_url: str | None = None diff --git a/backend/models/organization_membership.py b/backend/models/organization_membership.py new file mode 100644 index 000000000..50b57c77a --- /dev/null +++ b/backend/models/organization_membership.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel +from enum import Enum +from .public_user import PublicUser +from .academics import Term + +__authors__ = ["Alanna Zhang, Alexander Feng"] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class OrganizationMembershipStatus(Enum): + """Enum to represent the status of an organization membership.""" + + ACTIVE = "Active" + PENDING = "Membership pending" + + +class OrganizationPermissionLevel(Enum): + """Enum to represent the level of administrative permissions in an organization.""" + + ADMIN = "Admin" + MEMBER = "Member" + + +class OrganizationMembership(BaseModel): + """Pydantic model to represent an organization membership in the roster.""" + + id: int | None = None + user: PublicUser + organization_id: int + organization_name: str + organization_slug: str + title: str + permission_level: OrganizationPermissionLevel + status: OrganizationMembershipStatus + term: Term + + +class OrganizationMembershipRegistration(BaseModel): + """Pydantic model for creating a new membership""" + + id: int | None = None + user_id: int + organization_id: int + title: str = "Member" + permission_level: OrganizationPermissionLevel = OrganizationPermissionLevel.MEMBER + status: OrganizationMembershipStatus = OrganizationMembershipStatus.ACTIVE + term_id: str | None = None diff --git a/backend/models/user.py b/backend/models/user.py index cee9c2f4d..e5a6c9dda 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -39,6 +39,7 @@ class User(UserIdentity, BaseModel): bio: str | None = None linkedin: str | None = None website: str | None = None + organizations: list[str] | None = None # new class NewUser(User, BaseModel): diff --git a/backend/models/user_details.py b/backend/models/user_details.py index 23ad5fe9a..31bca9d45 100644 --- a/backend/models/user_details.py +++ b/backend/models/user_details.py @@ -1,4 +1,5 @@ from backend.models.academics.section import Section +from backend.models.organization import Organization from .application import Application from .permission import Permission from .user import User @@ -17,6 +18,7 @@ class UserDetails(User): of the `Event` database in the PostgreSQL database. """ + organizations: list[str] = [] permissions: list["Permission"] = [] applications: list[Application] = [] sections: list[Section] = [] diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py index 9c19b6d8d..358077e56 100644 --- a/backend/script/reset_demo.py +++ b/backend/script/reset_demo.py @@ -19,7 +19,10 @@ from .. import entities from ..test.services import role_data, user_data, permission_data, room_data -from ..test.services.organization import organization_demo_data +from ..test.services.organization import ( + organization_demo_data, + organization_membership_test_data, +) from ..test.services.event import event_demo_data from ..test.services.coworking import seat_data, operating_hours_data, time from ..test.services.coworking.reservation import reservation_data diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py index b54d0e4b6..a3bb3eb27 100644 --- a/backend/services/exceptions.py +++ b/backend/services/exceptions.py @@ -1,7 +1,7 @@ """ This file contains exceptions found in the service layer. -These custom exceptions can then be handled peoperly +These custom exceptions can then be handled properly at the API level. """ @@ -12,6 +12,20 @@ class ResourceNotFoundException(Exception): ... +class ResourceExistsException(Exception): + """ResourceExistsException is raised when a user attempts to create an already existing resource.""" + + def __init__(self, reason: str): + super().__init__(f"{reason}") + + +class OrganizationPermissionException(Exception): + """OrganizationPermissionException is raised when a user attempts to perform an action on organization memberships they are not authorized to perform.""" + + def __init__(self, reason: str): + super().__init__(f"{reason}") + + class UserPermissionException(Exception): """UserPermissionException is raised when a user attempts to perform an action they are not authorized to perform.""" @@ -39,8 +53,9 @@ class CourseDataScrapingException(Exception): def __init__(self, reason: str): super().__init__(f"{reason}") + class RecurringOfficeHourEventException(Exception): """RecurringOfficeHourEventException is raised when an unexpected error occurs when managing recurring offiec hours events.""" def __init__(self, reason: str): - super().__init__(f"{reason}") \ No newline at end of file + super().__init__(f"{reason}") diff --git a/backend/services/organization.py b/backend/services/organization.py index e965bb535..1d534bf53 100644 --- a/backend/services/organization.py +++ b/backend/services/organization.py @@ -7,17 +7,35 @@ from sqlalchemy.orm import Session from ..database import db_session -from ..models.organization import Organization +from ..models import User +from ..models.organization import Organization, OrganizationJoinType from ..models.organization_details import OrganizationDetails +from ..models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipRegistration, + OrganizationPermissionLevel, + OrganizationMembershipStatus, +) from ..entities.organization_entity import OrganizationEntity -from ..models import User +from ..entities.organization_membership_entity import OrganizationMembershipEntity +from ..entities.user_entity import UserEntity from .permission import PermissionService -from .exceptions import ResourceNotFoundException - - -__authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] -__copyright__ = "Copyright 2023" +from .exceptions import ( + ResourceNotFoundException, + ResourceExistsException, + OrganizationPermissionException, +) + + +__authors__ = [ + "Ajay Gandecha", + "Jade Keegan", + "Brianna Ta", + "Audrey Toney", + "Alanna Zhang", +] +__copyright__ = "Copyright 2025" __license__ = "MIT" @@ -157,6 +175,8 @@ def update(self, subject: User, organization: Organization) -> Organization: obj.youtube = organization.youtube obj.heel_life = organization.heel_life obj.public = organization.public + obj.join_type = organization.join_type + obj.application_url = organization.application_url # Save changes self._session.commit() @@ -196,3 +216,279 @@ def delete(self, subject: User, slug: str) -> None: self._session.delete(obj) # Save changes self._session.commit() + + def add_membership( + self, + subject: User, + slug: str, + membership_registration: OrganizationMembershipRegistration, + ) -> OrganizationMembership | None: + """ + Add a new organization membership + If user or organization don't exist or a membership already exists, a debug message is displayed + + Parameters: + subject: a valid User model representing the currently logged in User + slug: a string representing a unique organization slug + membership_registration: an OrganizationMembershipRegistration used to create a new OrganizationMembership + + Returns: + OrganizationMembership: Object added to table + + Raises: + ResourceNotFoundException if no organization is found with the corresponding slug + ResourceExistsException if user is already in the organization + """ + # Query the organization with matching slug and check if null + organization = self.get_by_slug(slug) + + # Query the user with matching id and check if null + user = ( + self._session.query(UserEntity) + .filter(UserEntity.id == membership_registration.user_id) + .one_or_none() + ) + + if user is None: + raise ResourceNotFoundException( + f"No user found with matching id: {membership_registration.user_id}" + ) + + # Check if subject has permission to create membership for given user + subject_user_membership = ( + self._session.query(OrganizationMembershipEntity).filter( + OrganizationMembershipEntity.user_id == subject.id, + OrganizationMembershipEntity.organization_id == organization.id, + ) + ).one_or_none() + + # Flag denoting if subject is an organization or CSXL admin + subject_has_permission = ( + subject_user_membership is not None + and subject_user_membership.permission_level + == OrganizationPermissionLevel.ADMIN + ) or self._permission.check( + subject, "organization.update", f"organization/{slug}" + ) + + if organization.join_type.name == OrganizationJoinType.CLOSED.name: + # Raise exception if organization isn't allowing new memberships + if not subject_has_permission: + raise Exception( + f"Organization with slug {slug} is not currently open to new memberships" + ) + + if not (subject_has_permission or subject.id == user.id): + raise OrganizationPermissionException( + f"Not authorized to create a membership for another user in organization: {slug}" + ) + + # Check if membership already exists for this organization + check_existing_membership = ( + self._session.query(OrganizationMembershipEntity) + .filter( + OrganizationMembershipEntity.user_id == membership_registration.user_id, + OrganizationMembershipEntity.organization_id == organization.id, + ) + .one_or_none() + ) + + if check_existing_membership: + raise ResourceExistsException( + f"User with id {membership_registration.user_id} already in the organization with slug: {slug}" + ) + + # Create new OrganizationMembershipEntity object + organization_membership_entity = OrganizationMembershipEntity.from_model( + membership_registration + ) + organization_membership_entity.id = None + organization_membership_entity.organization_id = organization.id + + # Set default membership values if subject is lacking admin permissions + if not subject_has_permission: + organization_membership_entity.permission_level = ( + OrganizationPermissionLevel.MEMBER + ) + organization_membership_entity.title = "Member" + if organization.join_type.name == OrganizationJoinType.APPLY.name: + organization_membership_entity.status = ( + OrganizationMembershipStatus.PENDING + ) + else: + organization_membership_entity.status = ( + OrganizationMembershipStatus.ACTIVE + ) + + # Add new object to table and commit changes + self._session.add(organization_membership_entity) + self._session.commit() + + # Return added object + return organization_membership_entity.to_model() + + def get_roster(self, slug: str) -> list[OrganizationMembership]: + """ + Get an organization roster + If the organization doesn't exist, a debug message is displayed + + Parameters: + slug: a string representing a unique organization slug + + Returns: + list[OrganizationMembership]: list of'OrganizationMembership' objects + + Raises: + ResourceNotFoundException if no organization is found with the corresponding slug + """ + # Query the organization with matching slug and check if null + organization = self.get_by_slug(slug) + + # Select all entries in `Organization` table + query = select(OrganizationMembershipEntity).filter( + OrganizationMembershipEntity.organization_id == organization.id + ) + entities = self._session.scalars(query).all() + + # Convert entries to a model and return + return [entity.to_model() for entity in entities] + + def update_membership( + self, + subject: User, + slug: str, + membership: OrganizationMembershipRegistration, + ) -> OrganizationMembership: + """ + Update an organization membership + If the membership doesn't exist, a debug message is displayed + + Parameters: + subject: a valid User model representing the currently logged in User + slug: a string representing a unique organization slug + membership: an OrganizationMembershipRegistration representing a membership + + Returns: + OrganizationMembership: updated OrganizationMembership object + + Raises: + ResourceNotFoundException if no membership is found with the corresponding id + """ + # Query the organization with matching slug and check if null + organization = self.get_by_slug(slug) + + subject_membership = ( + self._session.query(OrganizationMembershipEntity).filter( + OrganizationMembershipEntity.user_id == subject.id, + OrganizationMembershipEntity.organization_id == organization.id, + ) + ).one_or_none() + + # Query the membership to update + query = select(OrganizationMembershipEntity).where( + OrganizationMembershipEntity.id == membership.id + ) + entity = self._session.scalars(query).one_or_none() + + if entity is None: + raise ResourceNotFoundException( + f"No organization membership found with id: {membership.id}" + ) + + # Check if subject has permission to edit the membership + if self.subject_has_organization_permission( + subject, subject_membership, entity + ): + entity.title = membership.title + entity.permission_level = membership.permission_level + entity.status = membership.status + entity.term_id = membership.term_id + else: + raise OrganizationPermissionException( + f"User is not authorized to edit the given membership in organization: {slug}" + ) + + self._session.commit() + return entity.to_model() + + def delete_membership(self, subject: User, slug: str, membership_id: int) -> None: + """ + Remove an existing organization membership + If the user isn't a part of the organization, a debug message is displayed + + Parameters: + subject: a valid User model representing the currently logged in User + slug: a string representing a unique organization slug + membership_id: an int representing a unique membership id + + Raises: + ResourceNotFoundException if no organization membership is found with given membership_id + """ + # Query the organization with matching slug and check if null + organization = self.get_by_slug(slug) + + # Check if user has permissions to remove memberships (organization or CSXL admin, user is subject) + subject_membership = ( + self._session.query(OrganizationMembershipEntity).filter( + OrganizationMembershipEntity.user_id == subject.id, + OrganizationMembershipEntity.organization_id == organization.id, + ) + ).one_or_none() + + former_membership = ( + self._session.query(OrganizationMembershipEntity) + .filter(OrganizationMembershipEntity.id == membership_id) + .one_or_none() + ) + + # Check if result is null + if former_membership is None: + raise ResourceNotFoundException( + f"No organization membership found with id {membership_id}" + ) + + # Check if subject has permission to delete the membership + if ( + self.subject_has_organization_permission( + subject, subject_membership, former_membership + ) + or subject.id == former_membership.user_id + ): + self._session.delete(former_membership) + self._session.commit() + else: + raise OrganizationPermissionException( + f"User is not authorized to delete the given membership in organization: {slug}" + ) + + def subject_has_organization_permission( + self, + subject: User, + subject_membership: OrganizationMembershipEntity, + membership: OrganizationMembershipEntity, + ) -> bool: + """ + Helper method that determines if the subject can perform actions on the specified membership + + Parameters: + subject: the currently logged in User + subject_membership: the currently logged in User's membership + membership: the membership targeted for edit or delete + + Returns: + bool: flag for the statement "subject has permission to act on the membership" + """ + + subject_is_organization_admin = ( + subject_membership is not None + and subject_membership.permission_level == OrganizationPermissionLevel.ADMIN + ) + subject_is_csxl_admin = self._permission.check( + subject, "organization.update", "organization/*" + ) + + # CSXL admin > organization admin > general user + return subject_is_csxl_admin or ( + not membership.permission_level == OrganizationPermissionLevel.ADMIN + and subject_is_organization_admin + ) diff --git a/backend/services/user.py b/backend/services/user.py index ae7a390e0..fe4f6ef7d 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -47,6 +47,7 @@ def get(self, pid: int) -> UserDetails | None: user = user_entity.to_model() user_fields = user.model_dump() user_fields["permissions"] = self._permission.get_permissions(user) + user_fields["organizations"] = user.organizations user_details = UserDetails(**user_fields) return user_details diff --git a/backend/test/services/academics/course_site_test.py b/backend/test/services/academics/course_site_test.py index 7832b3566..08e5fc220 100644 --- a/backend/test/services/academics/course_site_test.py +++ b/backend/test/services/academics/course_site_test.py @@ -18,11 +18,10 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from .term_data import fake_data_fixture as insert_order_1 -from .course_data import fake_data_fixture as insert_order_2 -from .section_data import fake_data_fixture as insert_order_3 -from ..room_data import fake_data_fixture as insert_order_4 -from ..office_hours.office_hours_data import fake_data_fixture as insert_order_5 +from .course_data import fake_data_fixture as insert_order_1 +from .section_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_3 +from ..office_hours.office_hours_data import fake_data_fixture as insert_order_4 # Import the fake model data in a namespace for test assertions from .. import user_data diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index ff8f45e4a..a8b9ef92d 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -26,12 +26,11 @@ # Import the setup_teardown fixture explicitly to load entities in database from ...core_data import setup_insert_data_fixture as insert_order_0 -from ...academics.term_data import fake_data_fixture as insert_order_1 -from ...academics.course_data import fake_data_fixture as insert_order_2 -from ...academics.section_data import fake_data_fixture as insert_order_3 -from ...room_data import fake_data_fixture as insert_order_4 -from ...office_hours.office_hours_data import fake_data_fixture as insert_order_5 -from .hiring_data import fake_data_fixture as insert_order_6 +from ...academics.course_data import fake_data_fixture as insert_order_1 +from ...academics.section_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_3 +from ...office_hours.office_hours_data import fake_data_fixture as insert_order_4 +from .hiring_data import fake_data_fixture as insert_order_5 # Test data diff --git a/backend/test/services/academics/section_member_test.py b/backend/test/services/academics/section_member_test.py index 54152d275..bfd226f4f 100644 --- a/backend/test/services/academics/section_member_test.py +++ b/backend/test/services/academics/section_member_test.py @@ -17,11 +17,10 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from .term_data import fake_data_fixture as insert_order_1 -from .course_data import fake_data_fixture as insert_order_2 -from .section_data import fake_data_fixture as insert_order_3 -from ..room_data import fake_data_fixture as insert_order_4 -from ..office_hours.office_hours_data import fake_data_fixture as insert_order_5 +from .course_data import fake_data_fixture as insert_order_1 +from .section_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_3 +from ..office_hours.office_hours_data import fake_data_fixture as insert_order_4 # Import the fake model data in a namespace for test assertions from . import section_data diff --git a/backend/test/services/academics/section_test.py b/backend/test/services/academics/section_test.py index ab6d7bc5f..4715fb17f 100644 --- a/backend/test/services/academics/section_test.py +++ b/backend/test/services/academics/section_test.py @@ -16,9 +16,8 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from .term_data import fake_data_fixture as insert_order_1 -from .course_data import fake_data_fixture as insert_order_2 -from .section_data import fake_data_fixture as insert_order_3 +from .course_data import fake_data_fixture as insert_order_1 +from .section_data import fake_data_fixture as insert_order_2 # Import the fake model data in a namespace for test assertions from . import term_data diff --git a/backend/test/services/application_test.py b/backend/test/services/application_test.py index a652fc929..1194d3644 100644 --- a/backend/test/services/application_test.py +++ b/backend/test/services/application_test.py @@ -18,12 +18,11 @@ # Import the setup_teardown fixture explicitly to load entities in database from .core_data import setup_insert_data_fixture as insert_order_0 -from .academics.term_data import fake_data_fixture as insert_order_1 -from .academics.course_data import fake_data_fixture as insert_order_2 -from .academics.section_data import fake_data_fixture as insert_order_3 -from .room_data import fake_data_fixture as insert_order_4 -from .office_hours.office_hours_data import fake_data_fixture as insert_order_5 -from .academics.hiring.hiring_data import fake_data_fixture as insert_order_6 +from .academics.course_data import fake_data_fixture as insert_order_1 +from .academics.section_data import fake_data_fixture as insert_order_2 +from .room_data import fake_data_fixture as insert_order_3 +from .office_hours.office_hours_data import fake_data_fixture as insert_order_4 +from .academics.hiring.hiring_data import fake_data_fixture as insert_order_5 # Data Models for Fake Data Inserted in Setup from . import user_data diff --git a/backend/test/services/core_data.py b/backend/test/services/core_data.py index 18765b14d..1d8932dc9 100644 --- a/backend/test/services/core_data.py +++ b/backend/test/services/core_data.py @@ -6,9 +6,10 @@ import pytest from sqlalchemy.orm import Session -from .organization import organization_test_data +from .organization import organization_test_data, organization_membership_test_data from .event import event_test_data from . import permission_data, role_data, user_data +from .academics import term_data __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" @@ -19,8 +20,10 @@ def setup_insert_data_fixture(session: Session): role_data.insert_fake_data(session) user_data.insert_fake_data(session) + term_data.insert_fake_data(session) permission_data.insert_fake_data(session) organization_test_data.insert_fake_data(session) + organization_membership_test_data.insert_fake_data(session) event_test_data.insert_fake_data(session) session.commit() diff --git a/backend/test/services/office_hours/office_hours_recurrence_test.py b/backend/test/services/office_hours/office_hours_recurrence_test.py index b621be12a..e1a062d30 100644 --- a/backend/test/services/office_hours/office_hours_recurrence_test.py +++ b/backend/test/services/office_hours/office_hours_recurrence_test.py @@ -15,11 +15,10 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from ..academics.term_data import fake_data_fixture as insert_order_1 -from ..academics.course_data import fake_data_fixture as insert_order_2 -from ..academics.section_data import fake_data_fixture as insert_order_3 -from ..room_data import fake_data_fixture as insert_order_4 -from ..office_hours.office_hours_data import fake_data_fixture as insert_order_5 +from ..academics.course_data import fake_data_fixture as insert_order_1 +from ..academics.section_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_3 +from ..office_hours.office_hours_data import fake_data_fixture as insert_order_4 # Important fake model data in namespace for test assertions from .. import user_data diff --git a/backend/test/services/office_hours/office_hours_test.py b/backend/test/services/office_hours/office_hours_test.py index 469f26c16..ec30fe7ac 100644 --- a/backend/test/services/office_hours/office_hours_test.py +++ b/backend/test/services/office_hours/office_hours_test.py @@ -16,11 +16,10 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from ..academics.term_data import fake_data_fixture as insert_order_1 -from ..academics.course_data import fake_data_fixture as insert_order_2 -from ..academics.section_data import fake_data_fixture as insert_order_3 -from ..room_data import fake_data_fixture as insert_order_4 -from ..office_hours.office_hours_data import fake_data_fixture as insert_order_5 +from ..academics.course_data import fake_data_fixture as insert_order_1 +from ..academics.section_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_3 +from ..office_hours.office_hours_data import fake_data_fixture as insert_order_4 # Import the fake model data in a namespace for test assertions from .. import user_data diff --git a/backend/test/services/office_hours/ticket_test.py b/backend/test/services/office_hours/ticket_test.py index 609d34f97..318047cef 100644 --- a/backend/test/services/office_hours/ticket_test.py +++ b/backend/test/services/office_hours/ticket_test.py @@ -14,11 +14,10 @@ # Import the setup_teardown fixture explicitly to load entities in database from ..core_data import setup_insert_data_fixture as insert_order_0 -from ..academics.term_data import fake_data_fixture as insert_order_1 -from ..academics.course_data import fake_data_fixture as insert_order_2 -from ..academics.section_data import fake_data_fixture as insert_order_3 -from ..room_data import fake_data_fixture as insert_order_4 -from ..office_hours.office_hours_data import fake_data_fixture as insert_order_5 +from ..academics.course_data import fake_data_fixture as insert_order_1 +from ..academics.section_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_3 +from ..office_hours.office_hours_data import fake_data_fixture as insert_order_4 # Import the fake model data in a namespace for test assertions from .. import user_data diff --git a/backend/test/services/organization/organization_demo_data.py b/backend/test/services/organization/organization_demo_data.py index 3213c81b5..999454f2d 100644 --- a/backend/test/services/organization/organization_demo_data.py +++ b/backend/test/services/organization/organization_demo_data.py @@ -2,7 +2,7 @@ import pytest from sqlalchemy.orm import Session -from ....models.organization import Organization +from ....models.organization import Organization, OrganizationJoinType from ....entities.organization_entity import OrganizationEntity from ..reset_table_id_seq import reset_table_id_seq @@ -28,6 +28,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/appteamcarolina", public=False, + join_type=OrganizationJoinType.APPLY, ) acm = OrganizationEntity( @@ -45,6 +46,7 @@ youtube="https://www.youtube.com/channel/UCkgDDL-DKsFJKpld2SosbxA", heel_life="https://heellife.unc.edu/organization/acm-at-carolina", public=False, + join_type=OrganizationJoinType.OPEN, ) bit = OrganizationEntity( @@ -62,6 +64,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/bit", public=False, + join_type=OrganizationJoinType.OPEN, ) cads = Organization( @@ -79,6 +82,7 @@ youtube="https://www.youtube.com/channel/UCO44Yjhjuo5-TLUCAaP0-cQ", heel_life="https://heellife.unc.edu/organization/carolinadatascience", public=True, + join_type=OrganizationJoinType.OPEN, ) carvr = OrganizationEntity( @@ -96,6 +100,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/carvr", public=False, + join_type=OrganizationJoinType.OPEN, ) cssg = Organization( @@ -113,6 +118,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/cssg", public=False, + join_type=OrganizationJoinType.APPLY, ) ctf = OrganizationEntity( @@ -130,6 +136,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/ntropy-unc", public=False, + join_type=OrganizationJoinType.OPEN, ) enablingtech = OrganizationEntity( @@ -147,6 +154,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/enablingtechnologyclub", public=False, + join_type=OrganizationJoinType.OPEN, ) esports = OrganizationEntity( @@ -164,6 +172,7 @@ youtube="https://www.youtube.com/carolinaesports", heel_life="https://heellife.unc.edu/organization/esports", public=False, + join_type=OrganizationJoinType.OPEN, ) gamedev = OrganizationEntity( @@ -181,6 +190,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/uncgamedev", public=False, + join_type=OrganizationJoinType.OPEN, ) gwc = OrganizationEntity( @@ -198,6 +208,7 @@ youtube="", heel_life="", public=False, + join_type=OrganizationJoinType.OPEN, ) hacknc = OrganizationEntity( @@ -215,6 +226,7 @@ youtube="https://www.youtube.com/channel/UCDRN6TMC27uSDsZosIwUrZg", heel_life="https://heellife.unc.edu/organization/hacknc", public=False, + join_type=OrganizationJoinType.APPLY, ) ktp = OrganizationEntity( @@ -232,6 +244,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/uncktp", public=False, + join_type=OrganizationJoinType.APPLY, ) pearlhacks = OrganizationEntity( @@ -249,6 +262,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/pearlhacks", public=False, + join_type=OrganizationJoinType.OPEN, ) pm = OrganizationEntity( @@ -266,6 +280,7 @@ youtube="", heel_life="", public=False, + join_type=OrganizationJoinType.APPLY, ) queerhack = OrganizationEntity( @@ -283,6 +298,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/queer_hack", public=False, + join_type=OrganizationJoinType.CLOSED, ) wics = OrganizationEntity( @@ -300,6 +316,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/wins", public=False, + join_type=OrganizationJoinType.OPEN, ) organizations = [ diff --git a/backend/test/services/organization/organization_membership_test_data.py b/backend/test/services/organization/organization_membership_test_data.py new file mode 100644 index 000000000..6124a9bdb --- /dev/null +++ b/backend/test/services/organization/organization_membership_test_data.py @@ -0,0 +1,139 @@ +import pytest +from sqlalchemy.orm import Session +from ....models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipRegistration, + OrganizationPermissionLevel, + OrganizationMembershipStatus, +) +from ....entities.organization_membership_entity import OrganizationMembershipEntity +from ....models.public_user import PublicUser +from .organization_test_data import cads, appteam +from ..academics import term_data + +from ..reset_table_id_seq import reset_table_id_seq + +# Sample objects + +# Sample Users +root = PublicUser( + id=1, + onyen="root", + email="root@unc.edu", + first_name="Rhonda", + last_name="Root", + pronouns="She / Her / Hers", +) + +ambassador = PublicUser( + id=2, + onyen="xlstan", + email="amam@unc.edu", + first_name="Amy", + last_name="Ambassador", + pronouns="They / Them / Theirs", +) + +user = PublicUser( + id=3, + onyen="user", + email="user@unc.edu", + first_name="Sally", + last_name="Student", + pronouns="She / They", +) + +# Sample Memberships +member_1 = OrganizationMembershipRegistration( + id=1, + user_id=root.id, + organization_id=cads.id, + title="President", + permission_level=OrganizationPermissionLevel.ADMIN, + status=OrganizationMembershipStatus.ACTIVE, + term_id=term_data.current_term.id, +) + +member_2 = OrganizationMembershipRegistration( + id=2, + user_id=ambassador.id, + organization_id=cads.id, + title="Ambassador", + permission_level=OrganizationPermissionLevel.MEMBER, + status=OrganizationMembershipStatus.ACTIVE, + term_id=term_data.current_term.id, +) + +member_to_add = OrganizationMembershipRegistration( + user_id=user.id, + organization_id=cads.id, + title="Non-default title", + permission_level=OrganizationPermissionLevel.ADMIN, + status=OrganizationMembershipStatus.ACTIVE, + term_id=term_data.current_term.id, +) + +non_member = OrganizationMembershipRegistration( + user_id=user.id, + organization_id=appteam.id, + term_id=term_data.current_term.id, +) + +edit_member_2 = OrganizationMembershipRegistration( + id=2, + user_id=ambassador.id, + organization_id=cads.id, + title="Treasurer", + permission_level=OrganizationPermissionLevel.ADMIN, + status=OrganizationMembershipStatus.ACTIVE, + term_id=term_data.current_term.id, +) + +bad_membership = OrganizationMembership( + id=100, + user=ambassador, + organization_id=cads.id, + organization_name=cads.name, + organization_slug=cads.slug, + title="Treasurer", + permission_level=OrganizationPermissionLevel.ADMIN, + status=OrganizationMembershipStatus.ACTIVE, + term=term_data.current_term, +) + +roster = [member_1, member_2] + + +def insert_fake_data(session: Session): + """Inserts fake organization data into the test session.""" + + global roster + + # Create entities for test organization data + entities = [] + for membership in roster: + entity = OrganizationMembershipEntity.from_model(membership) + session.add(entity) + entities.append(entity) + + # Reset table IDs to prevent ID conflicts + reset_table_id_seq( + session, + OrganizationMembershipEntity, + OrganizationMembershipEntity.id, + len(roster) + 1, + ) + + # Commit all changes + session.commit() + + +@pytest.fixture(autouse=True) +def fake_data_fixture(session: Session): + """Insert fake data the session automatically when test is run. + Note: + This function runs automatically due to the fixture property `autouse=True`. + """ + insert_fake_data(session) + session.commit() + yield diff --git a/backend/test/services/organization/organization_test.py b/backend/test/services/organization/organization_test.py index 9d69f36a1..7283611ec 100644 --- a/backend/test/services/organization/organization_test.py +++ b/backend/test/services/organization/organization_test.py @@ -7,10 +7,18 @@ from backend.services.exceptions import ( UserPermissionException, ResourceNotFoundException, + ResourceExistsException, + OrganizationPermissionException, ) # Tested Dependencies -from ....models import Organization +from ....models import Organization, User +from ....models.organization_membership import ( + OrganizationMembership, + OrganizationMembershipRegistration, + OrganizationPermissionLevel, + OrganizationMembershipStatus, +) from ....services import OrganizationService # Injected Service Fixtures @@ -24,10 +32,22 @@ organizations, to_add, cads, + appteam, + queerhack, new_cads, to_add_conflicting_id, ) +from .organization_membership_test_data import ( + member_to_add, + non_member, + member_1, + member_2, + roster, + edit_member_2, + bad_membership, +) from ..user_data import root, user +from ..academics.term_data import current_term __authors__ = ["Ajay Gandecha"] __copyright__ = "Copyright 2023" @@ -100,6 +120,183 @@ def test_create_organization_as_user(organization_svc_integration: OrganizationS pytest.fail() # Fail test if no error was thrown above +# Test Organization Management (roster) begin + + +def test_add_default_membership_to_open_org( + organization_svc_integration: OrganizationService, +): + """Test that a user can add themselves to an open organization only with default values (title/is_admin)""" + added_member = organization_svc_integration.add_membership( + user, cads.slug, member_to_add + ) + assert added_member is not None + assert added_member.id is not None + assert added_member.title == "Member" + assert added_member.permission_level == OrganizationPermissionLevel.MEMBER + assert added_member.status == OrganizationMembershipStatus.ACTIVE + + +def test_add_custom_membership_to_open_org( + organization_svc_integration: OrganizationService, +): + """Test that an organization admin can add custom memberships to an open organization""" + added_member = organization_svc_integration.add_membership( + member_1, cads.slug, member_to_add + ) + assert added_member is not None + assert added_member.id is not None + assert added_member.title == "Non-default title" + assert added_member.permission_level == OrganizationPermissionLevel.ADMIN + assert added_member.status == OrganizationMembershipStatus.ACTIVE + + +def test_add_default_membership_to_apply_org( + organization_svc_integration: OrganizationService, +): + """Test that a user can add themselves to an apply organization only with default values (title/is_admin)""" + member_to_add.organization_id = appteam.id + added_member = organization_svc_integration.add_membership( + user, appteam.slug, member_to_add + ) + assert added_member is not None + assert added_member.id is not None + assert added_member.title == "Member" + assert added_member.permission_level == OrganizationPermissionLevel.MEMBER + assert added_member.status == OrganizationMembershipStatus.PENDING + + +def test_add_membership_to_closed_org( + organization_svc_integration: OrganizationService, +): + """Test that a non-admin user cannot join a closed organization""" + member_to_add.organization_id = queerhack.id + with pytest.raises(Exception): + organization_svc_integration.add_membership(user, queerhack.slug, member_to_add) + + +def test_add_member_to_nonexistent_organization( + organization_svc_integration: OrganizationService, +): + """Test that member cannot be added to nonexistent organization""" + with pytest.raises(ResourceNotFoundException): + organization_svc_integration.add_membership(root, "fakeslug", non_member) + + +def test_add_existing_member_to_organization( + organization_svc_integration: OrganizationService, +): + """Test that member cannot be added to an organization multiple times""" + with pytest.raises(ResourceExistsException): + organization_svc_integration.add_membership(root, cads.slug, member_1) + + +def test_add_different_user_to_organization( + organization_svc_integration: OrganizationService, +): + """Test that member cannot be added to an organization by non-admin member""" + with pytest.raises(OrganizationPermissionException): + organization_svc_integration.add_membership(member_2, cads.slug, non_member) + + +def test_add_nonexistent_user_to_organization( + organization_svc_integration: OrganizationService, +): + """Test that nonexistent user cannot be added to an organization""" + fake_user = User(id=100) + member_to_add = OrganizationMembershipRegistration( + user_id=fake_user.id, organization_id=cads.id + ) + with pytest.raises(ResourceNotFoundException): + organization_svc_integration.add_membership(root, cads.slug, member_to_add) + + +def test_get_roster_by_slug(organization_svc_integration: OrganizationService): + """Test retrieve roster for an organization by slug""" + fetched_members = organization_svc_integration.get_roster(cads.slug) + assert fetched_members is not None + assert len(fetched_members) == len(roster) + assert isinstance(fetched_members[0], OrganizationMembership) + + +def test_get_nonexistent_roster(organization_svc_integration: OrganizationService): + """Test retrieving roster for a nonexistent organization""" + with pytest.raises(ResourceNotFoundException): + organization_svc_integration.get_roster("fakeslug") + + +def test_delete_membership(organization_svc_integration: OrganizationService): + """Test that member can be removed from database""" + organization_svc_integration.delete_membership(root, cads.slug, member_1.id) + + updated_roster = organization_svc_integration.get_roster(cads.slug) + + assert len(updated_roster) == len(roster) - 1 + + +def test_delete_nonexistent_membership( + organization_svc_integration: OrganizationService, +): + """Test that a nonexistent member cannot be removed from database""" + with pytest.raises(ResourceNotFoundException): + organization_svc_integration.delete_membership(root, cads.slug, non_member.id) + + +def test_delete_membership_as_subject( + organization_svc_integration: OrganizationService, +): + """Test that a user without admin permissions can remove their own membership""" + organization_svc_integration.delete_membership(member_2, cads.slug, member_2.id) + + updated_roster = organization_svc_integration.get_roster(cads.slug) + + assert len(updated_roster) == len(roster) - 1 + + +def test_delete_membership_as_user( + organization_svc_integration: OrganizationService, +): + """Test that a user without admin permissions cannot remove other members""" + with pytest.raises(OrganizationPermissionException): + organization_svc_integration.delete_membership(member_2, cads.slug, member_1.id) + + +def test_update_existing_membership(organization_svc_integration: OrganizationService): + """Test an existing member can have their role updated in database""" + membership = organization_svc_integration.update_membership( + root, cads.slug, edit_member_2 + ) + assert membership.title == "Treasurer" + assert membership.permission_level == OrganizationPermissionLevel.ADMIN + + +def test_update_nonexistent_membership( + organization_svc_integration: OrganizationService, +): + """Test that a nonexistent membership cannot be updated""" + with pytest.raises(ResourceNotFoundException): + organization_svc_integration.update_membership( + root, + cads.slug, + bad_membership, + ) + + +def test_update_membership_as_user( + organization_svc_integration: OrganizationService, +): + """Test that a user without adminstrative permissions cannot update memberships""" + with pytest.raises(OrganizationPermissionException): + organization_svc_integration.update_membership( + user, + cads.slug, + edit_member_2, + ) + + +# Test Organization Management (roster) end + + # Test `OrganizationService.update()` def test_update_organization_as_root( organization_svc_integration: OrganizationService, diff --git a/backend/test/services/organization/organization_test_data.py b/backend/test/services/organization/organization_test_data.py index 33719cfaa..90ed0ef63 100644 --- a/backend/test/services/organization/organization_test_data.py +++ b/backend/test/services/organization/organization_test_data.py @@ -2,7 +2,7 @@ import pytest from sqlalchemy.orm import Session -from ....models.organization import Organization +from ....models.organization import Organization, OrganizationJoinType from ....entities.organization_entity import OrganizationEntity from ..reset_table_id_seq import reset_table_id_seq @@ -28,6 +28,7 @@ youtube="https://www.youtube.com/channel/UCO44Yjhjuo5-TLUCAaP0-cQ", heel_life="https://heellife.unc.edu/organization/carolinadatascience", public=True, + join_type=OrganizationJoinType.OPEN, ) cssg = Organization( @@ -45,6 +46,7 @@ youtube="", heel_life="https://heellife.unc.edu/organization/cssg", public=False, + join_type=OrganizationJoinType.APPLY, ) appteam = Organization( @@ -62,9 +64,28 @@ youtube="", heel_life="https://heellife.unc.edu/organization/appteamcarolina", public=False, + join_type=OrganizationJoinType.APPLY, ) -organizations = [cads, cssg, appteam] +queerhack = Organization( + id=16, + name="queer_hack", + shorthand="queer_hack", + slug="queer-hack", + logo="https://raw.githubusercontent.com/briannata/comp423_a3_starter/main/logos/queerhack.jpg", + short_description="A community for LGBTQ+ students in tech.", + long_description="Vision: We envision a future with a tech culture that is inclusive and accessible for LGBTQ+ people. \nMission: We aim to empower LGBTQ+ students in tech by fostering peer connections and curating opportunities to grow as a programmer. Our event programming includes skill-building workshops, weekly study groups, social events, career networking opportunities, and an annual hackathon.\nWhether you're already a Computer Science major or just interested in exploring coding, we'd love for you to join the community.", + website="http://queerhack.com/", + email="uncqueerhack@gmail.com", + instagram="", + linked_in="", + youtube="", + heel_life="https://heellife.unc.edu/organization/queer_hack", + public=False, + join_type=OrganizationJoinType.CLOSED, +) + +organizations = [cads, cssg, appteam, queerhack] organization_names = [cads.name, cssg.name, appteam.name] to_add = Organization( @@ -81,6 +102,7 @@ youtube="", heel_life="", public=True, + join_type=OrganizationJoinType.APPLY, ) to_add_conflicting_id = Organization( @@ -98,6 +120,7 @@ youtube="", heel_life="", public=True, + join_type=OrganizationJoinType.APPLY, ) new_cads = Organization( @@ -115,6 +138,7 @@ youtube="https://www.youtube.com/channel/UCO44Yjhjuo5-TLUCAaP0-cQ", heel_life="https://heellife.unc.edu/organization/carolinadatascience", public=True, + join_type=OrganizationJoinType.OPEN, ) # Data Functions diff --git a/backend/test/services/user_test.py b/backend/test/services/user_test.py index b5263d33b..1dac86ab6 100644 --- a/backend/test/services/user_test.py +++ b/backend/test/services/user_test.py @@ -129,7 +129,7 @@ def test_search_by_pid_rhonda(user_svc: UserService): """Test searching for a partial PID that does exist.""" users = user_svc.search(ambassador, "999") assert len(users) == 1 - assert users[0] == root + assert users[0].id == root.id def test_list(user_svc: UserService): diff --git a/docs/org_roster-spec.md b/docs/org_roster-spec.md new file mode 100644 index 000000000..638425983 --- /dev/null +++ b/docs/org_roster-spec.md @@ -0,0 +1,159 @@ +# Technical Specifications Document + +This document list the relevant technical aspects of the organization roster management feature. + +## New and Modified Models: + +### [NEW] OrganizationMembership: + +id: int membership primary key +user: User useful for populating member’s data +organization_id: int identifies organization +organization_slug: str useful for queries filtering by organization slug +organization_role: OrganizationRole (enum) + +### [EDIT] Organization: + +join_type: OrganizationJoinType (enum) + +### [NEW] OrganizationRole: enum with {PRESIDENT, OFFICER, MEMBER, ADMIN, PENDING} + +### [NEW] OrganizationJoinType: enum with {OPEN, APPLY, CLOSED} + +### Sample data representation for Sally Student as a member of CADS: + +``` +{ +id = 1, +user: Profile = +{ +id: 3, +pid: 222222222, +onyen: "user", +... +} +organization_id = 1, +organization_slug = "cads" +organization_role = OrganizationRole.MEMBER +} +``` + +We opted to call it a “membership” because the membership encapsulates a student, and a specific role per org. It might have been confusing to call it a “member” because the “member” seems synonymous with the “student”, but they are not actually the same, the membership encapsulates the student that the membership applies to. + +## New and Modified API routes: + +### Frontend API: + +**GET organizations/{slug}/roster** <- gets the roster, which is a list of memberships +returns OrganizationMembership[] +name: getOrganizationRoster(slug) + +**DELETE organizations/{slug}/roster/{member_id}** <- deletes one membership from the roster list +returns void +name: deleteOrganizationMembership(member_id) + +**POST organizations/{slug}/roster** <- adds membership +returns OrganizationMembership +addOrganizationMembership(slug, user_id) + +**PUT organizations/{slug}/roster/{member_id}** <- updates role of one membership from the roster list +returns void +updateOrganizationMembership(slug, membership_id, new_role) + +### Backend API: + +**GET /{slug}/roster** <- gets the roster, which is a list of memberships +returns OrganizationMembership[] +name: get_roster_by_slug + +**DELETE/{slug}/roster/{membership_id}** <- deletes one membership from the roster list +name: delete_member + +**POST {slug}/roster** <- adds membership +returns OrganizationMembership +name: add_member_to_organization + +**PUT {slug}/roster/{membership_id}** <- updates role of one membership from the roster list +returns void +name: update_membership_role + +## New and modified database/entity-level representations + +**[NEW] OrganizationMembershipEntity (organization_member_entity.py):** \ +This allows the many-to-many relationship between organization and user tables. +id +organization_role: takes a value from the OrganizationRole enum (PRESIDENT, OFFICER, MEMBER, ADMIN, PENDING) +User relationship: + +- user_id: foreign key pointing to user.id +- user: back populates memberships + +Organization relationship: + +- organization_id: foreign key pointing to organization.id +- organization: back populates members + +**[EDIT] OrganizationEntity (organization_entity.py):** \ +The edit involves adding the secondary relationship with the user table through a one-to-many relationship with organization_membership. A new field for organization join type was added to control adding new memberships to an organization. +members: back populates organization from organization_membership +users: back populates organizations from user +join_type: takes a value from the OrganizationJoinType enum (OPEN, APPLY, CLOSED) + +**[EDIT] UserEntity (user_entity.py):** +The edit involves adding the secondary relationship with the organization table through a one-to-many relationship with organization_membership. +memberships: back populates user from organization_membership +organizations: back populates users from organization + +## Implementation and Design Considerations + +An important technical decision for the organization management’s roster is how to represent and store data about organization members. Entities for an organization and general CSXL user already exist, so we initially considered adding a new “roster” field to the organization entity to group all the members of an organization and make it easier for officers to manage members together. However, because this is actually a many-to-many relationship (where users can join multiple organizations under different roles and organizations will have many members), we found it necessary to create a new entity called OrganizationMembership that describes the relationship between users and organizations as “user has membership in organization.” While creating a new entity makes it more complex to track and organize different representations of data, we hope that the OrganizationMembership entity will make it easier in future development to access fields specific to a membership such as membership term, organization role, and special privileges for event creation. + +Our UX design tradeoff was how to introduce the edit interface of the roster - whether it should be enabled at the same time that “edit organization details” is usually activated, or if it should have its own toggle. We ultimately chose its own toggle because conceptually to us, editing the roster is a more common and frequent action, while editing an organization’s details is very rarely done, and they are two different things, as a roster is just a transient component of an organization. Therefore, they should be represented by different UX actions. + +We were mainly concerned with giving administrative permissions to members of a specific organization without letting that permission overlap and allow them to edit other organizations. We decided that each organization should have their own unique role that admin would create and subsequently assign users to. The added users should be the presidents/co-presidents or media managers of that organization. Our backend service to update a member’s role in an organization enforce that this permission must be assigned in order to complete the action. We also enforce that this permission must be given to those who are trying to remove a member that is not themselves. + +## Getting Started + +#### To work on the widget UI and service injections: + +/frontend/organization/widgets/organization-roster.html, ts, css +Define what gets passed (services, roster data) into the roster widget in the .ts file. + +/frontend/organization/organization.module.ts +Add the widget here to the module. + +/frontend/organization/widgets/organization-details-info-card.html, ts, css +The join method from the parent's service is injected into the ts file. + +/frontend/organization/organization-details-component.ts +The service is passed into the organization-details component, which inject it into the roster and detail widgets. Helper functions are defined here to bridge between service calls and widget events. + +#### To work on the FastAPI service in frontend: + +/frontend/organization/organization.model.ts +The shape of the membership model is defined here under organizations. If there is a change in what gets passed in from the backend, update it here. + +/frontend/organization/organization-roster.service.ts +The service is separately defined here within the widget folder. Any API methods involving roster manipulation should be defined here. + +#### To work on the python API in backend: + +backend/api/organization.py +API methods to access data concerning organizations. For the organization management system, this is where to add methods involving membership data on an organization's detail page. + +backend/models/organization_membership.py +Represents the model used by API for organization membership. + +/backend/services/organization.py +API service connected to the organization and organization membership tables in the Postgres database. This service acts on and returns data to the organization API backend. + +#### To work on the database operations and representations: + +/backend/entities/organization_membership_entity.py +Database entity containing all relevant information about an organization membership. In addition to details from the organization and user tables, an OrganizationMembershipEntity can have specific attributes for organization role, membership term, and special privileges within the organization. + +/backend/entities/organization.py +Database entity representing information about an organization. + +/backend/entities/user.py +Database entity representing information about a CSXL user. diff --git a/docs/org_roster_design_doc.md b/docs/org_roster_design_doc.md new file mode 100644 index 000000000..3afc4b054 --- /dev/null +++ b/docs/org_roster_design_doc.md @@ -0,0 +1,86 @@ +# Student Organization Management Experience Thing (SOMEThing) +--- +## Overview + +Currently, organizations have no way to control their membership or interact with the CSXL in varying capacities based on their leadership privileges. It would be useful to perform actions like control the roster, create events, and reserve spaces that are tightly integrated with the CSXL website, but this would require that organizations store information about membership and leadership first. + +--- +## Key Personas + +**Nolan No-Club:** Students who are not part of any organization. They are able to view every clubs’ leadership hierarchy and their recruitment processes. + +**Pepper President:** Students who are in the highest level leadership position in a given organization. They should be able to customize the organization settings and have admin privileges to all main actions, such as creating events, managing the roster and more. + +**Orestes Officer:** Students who are elevated above general membership and have certain privileges depending on their role. Every organization may control their hierarchy differently, so specific position privileges can be created customized by admins using checkboxes. + +**Alennikamy Admin:** Individuals who have administrative access to the CSXL website. They are able to delegate permissions of different organizations to students based on their roles and have the power to override or modify all settings and entities. + +**Margarine Member:** Students who are members of one or more clubs. They should be able to see and/or participate in club events and news related to the clubs they are in. + +--- +## User Stories + +**Nolan No-Club:** As a student who is not in an organization, I want to distinguish the availability of all the student organizations, so that I know how to join the ones I am interested in. + +**Pepper President:** As a student who is an organization leader, I want to be able to accept prospective club members, customize organization settings, and create events and announcements so that I can have the highest privileges to manage my organization. + +**Orestes Officer:** As a student who is on the executive team, I want to have access to role-specific privileges so that I can effectively help manage my organization. + +**Alennikamy Admin:** As a CSXL website administrator, I want to give and restrict access according to the needs of each organization, so that I can effectively manage the organizations hosted by the site. + +**Margarine Member:** As a student who is a member of an organization, I want to be able to see my club’s information, so that I can stay up to date on important information that is relevant to my club. + +--- +## Figma: + +https://www.figma.com/design/AfYkqBBSxwhc10iTicUWOt/423-Sprint-00?node-id=11-1833&t=XfGCmMX9tCguB3wJ-1 + +--- +## Wireframes and Mockups: + +**Organization Page:** +![Organization Page](https://github.com/user-attachments/assets/ba946081-455c-42c6-b4de-dfb9b95d7685) + +**View Organization (General View):** +![View Organization (General Member)](https://github.com/user-attachments/assets/7e87e2ee-81e4-41ff-adce-9f834cb019d6) + +**Edit Organization (Admin):** +![Officer)](https://github.com/user-attachments/assets/e2367763-18f9-4fc7-9de2-1be1362ce8d6) + +**Edit Roster (Admin):** +![Component 2](https://github.com/user-attachments/assets/e8b743b0-7716-456a-8793-3b901963a96c) + +--- +## Technical Implementation Opportunities + Planning + + +### What specific areas of the existing code base will you directly depend upon, extend, or integrate with? + +Organizations - search, details, join + +### What planned page components and widgets do you anticipate needing in your feature’s frontend? + +Buttons, Chips, Lists, Side bar panels, Tiles, Checkboxes + +### What additional models, or changes to existing models, do you foresee needing (if any)? +
    +
  1. Change to organization model to include leadership and roster and application type
  2. +
  3. Add leadership role model - with name, privileges, and current users
  4. +
  5. Add roster model - list of users
  6. +
+ +### Considering your most-frequently used and critical user stories, what API / Routes do you foresee modifying or needing to add? +
    +
  1. roster - create it and setget info
  2. +
  3. organization - setget more info
  4. +
  5. leadership roles - setget more info
  6. +
  7. admin behind the scenes setget privileges
  8. +
+ +### What concerns exist for security and privacy of data? Should the capabilities you are implementing be specific to only certain users or roles? (For example: When Sally Student makes a reservation, only Sally Student or Amy Ambassador should be able to cancel the reservation. Another student, such as Sam Student, should not be able to cancel Sally’s reservation.) +
    +
  1. Roster should not be editable except for by admin or executive members
  2. +
  3. Events should not be editable by lower roles
  4. +
  5. Should not be able to see private student information unless they opt in somehow
  6. +
  7. Club application state should be hidden except to approvers
  8. +
diff --git a/frontend/src/app/models.module.ts b/frontend/src/app/models.module.ts index 2452d89a7..89dfb9645 100644 --- a/frontend/src/app/models.module.ts +++ b/frontend/src/app/models.module.ts @@ -1,3 +1,5 @@ +import { Organization } from './organization/organization.model'; + /** Interface for Permission Type */ export interface Permission { id?: number; @@ -24,6 +26,7 @@ export interface Profile { bio: string | null; linkedin: string | null; website: string | null; + organizations: string[]; } /** Interface for UserSummary Type (used on frontend for user requests) */ diff --git a/frontend/src/app/organization/organization-details/organization-details.component.css b/frontend/src/app/organization/organization-details/organization-details.component.css index 97f73ed80..a64ceaa9f 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.css +++ b/frontend/src/app/organization/organization-details/organization-details.component.css @@ -6,7 +6,56 @@ * */ -.event-listing-container { +.event-listing-container { padding-left: 16px; padding-right: 16px; +} + +.container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; /* Adjust alignment as needed */ + width: 100%; /* Full width */ + height: 100%; /* Ensure full height for proper positioning */ +} + + +.right-column { + display: flex; + flex-direction: column; + width: 70%; + padding-right: 16px; + height: 91.5vh; +} + +.left-column { + display: flex; + flex-direction: column; + padding: 0; + height: 91.5vh; + max-width: 670px; +} + +.left-column.no-events { + height: auto; /* Set height to auto to prevent white space */ +} + +.events-pane { + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + max-width: 100%; + border-color: transparent !important; + margin-top: 0; +} + +.events-pane-content { + overflow-y: auto; + height: 100%; +} + +mat-divider { + margin-top: 12px; + padding-bottom: 8px; } \ No newline at end of file diff --git a/frontend/src/app/organization/organization-details/organization-details.component.html b/frontend/src/app/organization/organization-details/organization-details.component.html index 06211b606..ec53ff917 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.html +++ b/frontend/src/app/organization/organization-details/organization-details.component.html @@ -3,9 +3,52 @@ @if (organization === undefined) { } @else { - +
+
+ + + + + Events + + + + + + @if (eventsByDate().length === 0) { +

This organization has no upcoming events.

+ } @for (eventGroup of eventsByDate(); track eventGroup) { + + {{ eventGroup[0] }} + + @for (event of eventGroup[1]; track event.id) { + + } } +
+
+
+ @if (organizationRoster && organizationRoster.length > 0) { + +
+ +
+ } +
} diff --git a/frontend/src/app/organization/organization-details/organization-details.component.ts b/frontend/src/app/organization/organization-details/organization-details.component.ts index c05f69d67..e10e1be2e 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.ts +++ b/frontend/src/app/organization/organization-details/organization-details.component.ts @@ -2,8 +2,8 @@ * The Organization Detail Component displays more information and options regarding * UNC CS organizations. * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2024 + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney, Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang + * @copyright 2025 * @license MIT */ @@ -15,7 +15,8 @@ import { Route } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Organization } from '../organization.model'; +import { Organization, OrganizationMembership } from '../organization.model'; +import { OrganizationRosterService } from '../organization-roster.service'; import { Profile, ProfileService } from '../../profile/profile.service'; import { organizationResolver } from '../organization.resolver'; import { EventService } from '../../event/event.service'; @@ -23,6 +24,13 @@ import { Observable } from 'rxjs'; import { PermissionService } from '../../permission.service'; import { GroupEventsPipe } from '../../event/pipes/group-events.pipe'; import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; +import { EventOverview, EventStatusOverview } from 'src/app/event/event.model'; +import { + TimeRangePaginationParams, + DEFAULT_TIME_RANGE_PARAMS, + Paginated +} from 'src/app/pagination'; +import { signal, WritableSignal, computed } from '@angular/core'; /** Injects the organization's name to adjust the title. */ let titleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { @@ -53,12 +61,33 @@ export class OrganizationDetailsComponent implements OnInit { ] }; + public eventStatus: WritableSignal = + signal(undefined); + public page: WritableSignal< + Paginated | undefined + > = signal(undefined); + private previousParams: TimeRangePaginationParams = DEFAULT_TIME_RANGE_PARAMS; + + protected eventsByDate = computed(() => { + const items = this.page()?.items ?? []; + // 👇 Replace 'event.organization.slug' with the actual key from your model + const filtered = items.filter( + (event) => event.organization_slug === this.organization?.slug + ); + return this.groupEventsPipe.transform(filtered); + }); /** Store the currently-logged-in user's profile. */ public profile: Profile; /** The organization to show */ public organization: Organization | undefined; + /** The organization's roster to show */ + public organizationRoster: OrganizationMembership[] | undefined; + + /** The current user's membership details if they are in the club */ + public organizationMembership?: OrganizationMembership; + // TODO: Refactor once the event feature is refactored. /** Whether or not the user has permission to update events. */ public eventCreationPermission$: Observable; @@ -68,6 +97,7 @@ export class OrganizationDetailsComponent implements OnInit { private route: ActivatedRoute, protected snackBar: MatSnackBar, private profileService: ProfileService, + protected organizationRosterService: OrganizationRosterService, protected eventService: EventService, protected groupEventsPipe: GroupEventsPipe, private permission: PermissionService, @@ -81,10 +111,29 @@ export class OrganizationDetailsComponent implements OnInit { }; this.organization = data.organization; + + if (this.organization) { + this.getRoster(this.organization.slug); + } + this.eventCreationPermission$ = this.permission.check( 'organization.*', `organization/${this.organization?.slug ?? '*'}` ); + + // TEST START + this.eventService + .getEvents(this.previousParams, this.profile !== undefined) + .subscribe((events) => { + this.page.set(events); + }); + + this.eventService + .getEventStatus(this.profile !== undefined) + .subscribe((status) => { + this.eventStatus.set(status); + }); + // TEST END } ngOnInit(): void { @@ -95,4 +144,46 @@ export class OrganizationDetailsComponent implements OnInit { `/organizations/${this.organization?.slug}/edit` ); } + + private getRoster(slug: string): void { + this.organizationRosterService.getOrganizationRoster(slug).subscribe({ + next: (roster) => { + this.organizationRoster = roster; + this.organizationMembership = this.getMembershipForOrg( + this.organization?.id + ); + } + }); + } + + private getMembershipForOrg( + org_id: number | null | undefined + ): OrganizationMembership | undefined { + if (!this.profile) return undefined; + + return this.organizationRoster?.find( + (membership) => + membership.organization_id === org_id && + membership.user.id === this.profile!.id + ); + } + + reloadPage() { + this.eventService + .getEvents(this.previousParams, this.profile !== undefined) + .subscribe((events) => { + this.page.set(events); + }); + this.eventService + .getEventStatus(this.profile !== undefined) + .subscribe((status) => { + this.eventStatus.set(status); + }); + } + + onMembershipChanged() { + if (this.organization) { + this.getRoster(this.organization.slug); + } + } } diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.css b/frontend/src/app/organization/organization-editor/organization-editor.component.css index 06375cf55..95a284a81 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.css +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.css @@ -19,4 +19,21 @@ mat-form-field { padding-bottom: 16px; justify-content: end; gap: 12px; +} + +mat-button-toggle-group { + display: flex; + justify-content: space-between; + margin-top: -25px; + width: 100%; +} + +mat-button-toggle { + flex: 1; + text-align: center; +} + +.reset-save-button { + margin-top: 10px; + padding: 10px; } \ No newline at end of file diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.html b/frontend/src/app/organization/organization-editor/organization-editor.component.html index 513e6eaaf..dc83a9b55 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.html +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.html @@ -125,8 +125,15 @@ formControlName="heel_life" name="heel_life" /> +

Join Type

+ + Open + Apply + Closed + +
+
diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.ts b/frontend/src/app/organization/organization-editor/organization-editor.component.ts index a779ec2ae..86b5a98ca 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.ts +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.ts @@ -2,8 +2,8 @@ * The Organization Editor Component allows organization managers to edit information * about their organization which is publically displayed on the organizations page. * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2024 + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney, Anika Ahmed, Alanna Zhang + * @copyright 2025 * @license MIT */ @@ -60,7 +60,8 @@ export class OrganizationEditorComponent { linked_in: '', youtube: '', heel_life: '', - public: false + public: false, + join_type: 'Open' }); /** Constructs the organization editor component */ diff --git a/frontend/src/app/organization/organization-page/organization-page.component.ts b/frontend/src/app/organization/organization-page/organization-page.component.ts index f78c128c3..2b975b861 100644 --- a/frontend/src/app/organization/organization-page/organization-page.component.ts +++ b/frontend/src/app/organization/organization-page/organization-page.component.ts @@ -3,8 +3,8 @@ * organizations at UNC. Students are also able to join public organizations, filter * based on interests, and access social media pages of organizations to stay up-to-date. * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2024 + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney, Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang + * @copyright 2025 * @license MIT */ diff --git a/frontend/src/app/organization/organization-roster.service.ts b/frontend/src/app/organization/organization-roster.service.ts new file mode 100644 index 000000000..3de5e8055 --- /dev/null +++ b/frontend/src/app/organization/organization-roster.service.ts @@ -0,0 +1,104 @@ +/** + * The Organization Roster Service performs direct HTTP CRUD operations on an organization's memberships. + * The service is used by the detail card (joining/leaving, displaying exec board) as well as the roster (managing memberships). + * + * @author Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang, Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang + * @copyright 2025 + * @license MIT + */ + +import { Observable } from 'rxjs'; +import { + OrganizationMembership, + OrganizationMembershipPermissionLevel, + OrganizationMembershipStatus +} from './organization.model'; +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class OrganizationRosterService { + /** Constructor */ + constructor(protected http: HttpClient) {} + + /** Gets an organization's roster (represented as an array of OrganizationMemberships) based on its slug. + * @param slug: String representing the organization slug + * @returns {Observable} + */ + getOrganizationRoster( + slug: string + ): Observable { + return this.http.get( + '/api/organizations/' + slug + '/roster' + ); + } + + /** Adds a student represented by a user_id to an organization represented by a slug. + * @param slug: String representing the organization slug + * @param user_id: String representing the user's ID + * @returns {Observable} + */ + addOrganizationMembership( + slug: string, + user_id: number, + organization_id: number + ): Observable { + return this.http.post( + '/api/organizations/' + slug + '/roster', + { user_id, organization_id } + ); + } + + /** Removes a membership represented by a membership_id from an organization represented by a slug. + * @param slug: String representing the organization slug + * @param membership_id: String representing the membership's ID + * @returns { Observable } + */ + deleteOrganizationMembership( + slug: string, + membership_id: number + ): Observable { + return this.http.delete( + '/api/organizations/' + slug + '/roster/' + membership_id + ); + } + + /** Updates a student represented by a member_id's role (enum) in an organization represented by a slug. + * @param slug: String representing the organization slug + * @param membership_id: String representing the membership's ID + * @param user_id: String representing the user's ID + * @param organization_id: String representing the organization's ID + * @param term_id: String representing the term ID + * @param new_title: String representing the new title + * @param new_permission_level: OrganizationMembershipPermissionLevel representing the new permission level + * @param new_status: OrganizationMembershipStatus representing the new status + * @returns { Observable } + */ + updateOrganizationMembership( + slug: string, + membership_id: number, + user_id: number | null, + organization_id: number, + term_id: string, + new_title?: string, + new_permission_level?: OrganizationMembershipPermissionLevel, + new_status?: OrganizationMembershipStatus + ): Observable { + const updatePayload: any = {}; + updatePayload.id = membership_id; + updatePayload.user_id = user_id; + updatePayload.organization_id = organization_id; + updatePayload.term_id = term_id; + if (new_title !== undefined) updatePayload.title = new_title; + if (new_permission_level !== undefined) + updatePayload.permission_level = new_permission_level; + if (new_status !== undefined) updatePayload.status = new_status; + + return this.http.put( + '/api/organizations/' + slug + '/roster', + updatePayload + ); + } +} diff --git a/frontend/src/app/organization/organization.model.ts b/frontend/src/app/organization/organization.model.ts index 62e2a6b42..cc6c981da 100644 --- a/frontend/src/app/organization/organization.model.ts +++ b/frontend/src/app/organization/organization.model.ts @@ -7,6 +7,9 @@ * @license MIT */ +import { Term } from '../academics/academics.models'; +import { Profile } from '../profile/profile.service'; + /** Interface for Organization Type (used on frontend for organization detail) */ export interface Organization { id: number | null; @@ -21,7 +24,41 @@ export interface Organization { youtube: string; heel_life: string; public: boolean; + join_type: OrganizationJoinType | null; slug: string; shorthand: string; events: Event[] | null; + members: OrganizationMembership[] | null; +} + +/** Interface for Organization Membership (used in roster widget) */ +export interface OrganizationMembership { + id: number; + user: Profile; + organization_id: number; + organization_slug: string; + + title: string; + permission_level: OrganizationMembershipPermissionLevel; + status: OrganizationMembershipStatus; + term: Term; + selected_title?: string; // For editing purposes + selected_permission_level?: OrganizationMembershipPermissionLevel; // For editing purposes + checked?: boolean; // For editing purposes +} + +export enum OrganizationMembershipStatus { + ACTIVE = 'Active', + PENDING = 'Membership pending' +} + +export enum OrganizationMembershipPermissionLevel { + MEMBER = 'Member', + ADMIN = 'Admin' +} + +export enum OrganizationJoinType { + OPEN = 'Open', + APPLY = 'Apply', + CLOSED = 'Closed' } diff --git a/frontend/src/app/organization/organization.module.ts b/frontend/src/app/organization/organization.module.ts index c0eda4082..abef9651b 100644 --- a/frontend/src/app/organization/organization.module.ts +++ b/frontend/src/app/organization/organization.module.ts @@ -28,13 +28,17 @@ import { FormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; - +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { OrganizationPageComponent } from './organization-page/organization-page.component'; import { OrganizationRoutingModule } from './organization-routing.module'; import { OrganizationDetailsComponent } from './organization-details/organization-details.component'; import { OrganizationAdminComponent } from './organization-admin/organization-admin.component'; import { OrganizationFilterPipe } from './pipes/organization-filter.pipe'; +import { RosterFilterPipe } from './pipes/roster-filter.pipe'; /* UI Widgets */ import { OrganizationCard } from './widgets/organization-card/organization-card.widget'; @@ -43,6 +47,10 @@ import { SharedModule } from '../shared/shared.module'; import { OrganizationDetailsInfoCard } from './widgets/organization-details-info-card/organization-details-info-card.widget'; import { OrganizationEditorComponent } from '/workspace/frontend/src/app/organization/organization-editor/organization-editor.component'; import { OrganizationNotFoundCard } from './widgets/organization-not-found-card/organization-not-found-card.widget'; +import { OrganizationRoster } from './widgets/organization-roster-widget/organization-roster.widget'; +import { OrganizationRosterEditDialogComponent } from './widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component'; +import { EventModule } from '../event/event.module'; +import { OrganizationEventCardWidget } from './widgets/organization-event-card/organization-event-card.widget'; @NgModule({ declarations: [ @@ -53,24 +61,33 @@ import { OrganizationNotFoundCard } from './widgets/organization-not-found-card/ // Pipes OrganizationFilterPipe, + RosterFilterPipe, // UI Widgets OrganizationCard, OrganizationDetailsInfoCard, - OrganizationNotFoundCard + OrganizationNotFoundCard, + OrganizationRoster, + OrganizationRosterEditDialogComponent, + OrganizationEventCardWidget ], imports: [ CommonModule, MatTabsModule, MatTableModule, + MatButtonToggleModule, MatCardModule, MatDialogModule, MatButtonModule, MatSelectModule, + MatCheckboxModule, MatFormFieldModule, MatInputModule, MatPaginatorModule, + MatCheckboxModule, MatListModule, + MatChipsModule, + MatMenuModule, MatAutocompleteModule, FormsModule, ReactiveFormsModule, @@ -78,7 +95,8 @@ import { OrganizationNotFoundCard } from './widgets/organization-not-found-card/ MatTooltipModule, OrganizationRoutingModule, RouterModule, - SharedModule + SharedModule, + EventModule ] }) export class OrganizationModule {} diff --git a/frontend/src/app/organization/organization.resolver.ts b/frontend/src/app/organization/organization.resolver.ts index 63ab2c911..d0255df60 100644 --- a/frontend/src/app/organization/organization.resolver.ts +++ b/frontend/src/app/organization/organization.resolver.ts @@ -10,7 +10,7 @@ import { inject } from '@angular/core'; import { ResolveFn } from '@angular/router'; import { Organization } from './organization.model'; -import { catchError, map, of } from 'rxjs'; +import { catchError, of } from 'rxjs'; import { OrganizationService } from './organization.service'; // TODO: Explore if this can be replaced by a signal. @@ -36,7 +36,9 @@ export const organizationResolver: ResolveFn = ( youtube: '', heel_life: '', public: false, - events: null + join_type: null, + events: null, + members: null }; } diff --git a/frontend/src/app/organization/pipes/roster-filter.pipe.ts b/frontend/src/app/organization/pipes/roster-filter.pipe.ts new file mode 100644 index 000000000..984590cf3 --- /dev/null +++ b/frontend/src/app/organization/pipes/roster-filter.pipe.ts @@ -0,0 +1,48 @@ +/** + * This is the pipe used to filter organizations on the organizations page. + * + * @author Alex Feng + * @copyright 2024 + * @license MIT + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { OrganizationMembership } from '../organization.model'; + +@Pipe({ + name: 'rosterFilter' +}) +export class RosterFilterPipe implements PipeTransform { + /** Returns a mapped array of organizations that start with the input string (if search query provided). + * @param {Observable} roster: observable list of valid Organization models + * @param {String} searchQuery: input string to filter by + * @returns {Observable} + */ + transform( + roster: OrganizationMembership[], + searchQuery: String + ): OrganizationMembership[] { + // Sort the organizations list alphabetically by name + roster = roster.sort( + (a: OrganizationMembership, b: OrganizationMembership) => { + return (a.user.first_name + ' ' + a.user.last_name || '') + .toLowerCase() + .localeCompare( + (b.user.first_name + ' ' + b.user.last_name || '').toLowerCase() + ); + } + ); + + // If a search query is provided, return the organizations that start with the search query. + if (searchQuery) { + return roster.filter((roster) => + (roster.user.first_name + ' ' + roster.user.last_name || '') + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + } else { + // Otherwise, return the original list. + return roster; + } + } +} diff --git a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.css b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.css index 471458fa6..ce4f691b9 100644 --- a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.css +++ b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.css @@ -1,44 +1,42 @@ - /* TODO(mdc-migration): The following rule targets internal classes of card that may no longer apply for the MDC version. */ .card { - ::ng-deep .mat-mdc-card { - width: 360px !important; - height: 186px !important; - margin: 0 !important; - row-gap: 0.6rem !important; - } + ::ng-deep .mat-mdc-card { + width: 360px !important; + height: 186px !important; + margin: 0 !important; + row-gap: 0.6rem !important; + } } - .mdc-card__media:first-child { - border-radius: 100%; + border-radius: 100%; } .left-container { - display: flex; - flex-direction: row; + display: flex; + flex-direction: row; } .logo { - height: auto; - max-width: 2.5rem; - max-height: 2.5rem; - margin-right: 1rem; - margin-bottom: 0; - border-radius: 2.5rem; + height: auto; + max-width: 2.5rem; + max-height: 2.5rem; + margin-right: 1rem; + margin-bottom: 0; + border-radius: 2.5rem; } .name { - height: 2.5rem; - margin: 0; - font-size: 1.15rem; - font-weight: 500; - line-height: 2.5rem; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 1; - line-clamp: 1; - -webkit-box-orient: vertical; + height: 2.5rem; + margin: 0; + font-size: 1.15rem; + font-weight: 500; + line-height: 2.5rem; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; } /* .description { @@ -49,44 +47,51 @@ } */ .description p { - width: 100%; - height: 2.5rem; - line-height: 1.25rem; - margin: 0; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; + width: 100%; + height: 2.5rem; + line-height: 1.25rem; + margin: 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; } #website-button { - margin-right: auto; + margin-right: auto; } #events-button { - margin-left: auto; - margin-right: 8px; + margin-left: auto; + margin-right: 8px; } .link-icons { - width: 144px; - height: 48px; - overflow: hidden; - display: -webkit-box; + width: 144px; + height: 48px; + overflow: hidden; + display: -webkit-box; } .mat-mdc-card-actions { - display: flex; - flex-direction: row-reverse; + display: flex; + flex-direction: row-reverse; } /* Override Mat UI's preset z-index for button */ .mat-mdc-outlined-button { - z-index: 0 !important; + z-index: 0 !important; } .details-link { - text-decoration: none; - margin-right: 0px; -} \ No newline at end of file + text-decoration: none; + margin-right: 0px; +} + +.registration-type-label { + display: block; + font-size: 0.8em; + margin-bottom: 4px; + margin-right: auto; +} diff --git a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html index d2cff3a41..3ca7fa30b 100644 --- a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html +++ b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html @@ -18,30 +18,28 @@ - -

- {{ organization.short_description }} -

+

{{ organization.short_description }}

- - - - - - @if (organization.website !== '') { - - } - - + class="details-link" + [routerLink]="['/organizations', organization.slug]"> + + + @if (organization.website !== '') { + + } + + + {{ organization.join_type === 'Open' ? '' : organization.join_type === + 'Apply' ? 'Application-based' : 'Closed registration' }} + diff --git a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.css b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.css index 494906bc2..efb80946f 100644 --- a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.css +++ b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.css @@ -1,55 +1,71 @@ - mat-card-header { - flex-direction: column; - width: 100%; + flex-direction: column; + width: 100%; } p { - margin-bottom: 0; + margin-bottom: 0; } .mat-mdc-card-avatar { - margin-bottom: 0 !important; + margin-bottom: 0 !important; } .header-row { - display: flex; - flex-direction: row; - align-items: center; - gap: 12px; + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; } .row { - display: flex; - flex-direction: row; - gap: 4px; - align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; } .links-row { - display: flex; - flex-direction: row; - flex-wrap: wrap; - margin-top: 4px; - align-items: center; - margin-bottom: 12px; - margin-left: 48px; - margin-right: 32px; - gap: 12px; - } - - .link-icons { - width: 22px; - height: 22px; - } + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-top: 4px; + align-items: center; + margin-bottom: 12px; + margin-left: 48px; + margin-right: 32px; + gap: 12px; +} + +.link-icons { + width: 22px; + height: 22px; +} mat-divider { - margin-top: 12px; - padding-bottom: 4px; + margin-top: 12px; + padding-bottom: 4px; } mat-card-actions { - padding-bottom: 12px; - justify-content: flex-end; - gap: 8px; -} \ No newline at end of file + padding-bottom: 12px; + justify-content: flex-end; + gap: 8px; +} + +.exec-board-section { + margin: 16px 0; +} +.exec-chip { + height: 3em; +} + +.exec-chip-content { + display: flex; + flex-direction: column; + align-items: flex-start; + line-height: 1.2; +} +.exec-title { + font-weight: bold; +} diff --git a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html index 053a2e976..278615bd3 100644 --- a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html +++ b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html @@ -93,7 +93,25 @@ [routerLink]="'/organizations/' + this.organization.slug + '/edit'"> Edit - } + } @if(profile && profile.id != null) { @if (membership && + checkActiveStatus()) { + + } @else if (membership && checkPendingStatus()) { + + } @else { + + } } } diff --git a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts index 9bef86595..dd8403c0b 100644 --- a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts +++ b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts @@ -2,15 +2,22 @@ * The Organization Details Info Card widget abstracts the implementation of each * individual organization detail card from the whole organization detail page. * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2024 + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney, Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang + * @copyright 2025 * @license MIT */ -import { Component, Input } from '@angular/core'; -import { Organization } from '../../organization.model'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { + Organization, + OrganizationJoinType, + OrganizationMembership, + OrganizationMembershipStatus +} from '../../organization.model'; import { Profile } from '../../../profile/profile.service'; import { SocialMediaIconWidgetService } from 'src/app/shared/social-media-icon/social-media-icon.widget.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { OrganizationRosterService } from '../../organization-roster.service'; @Component({ selector: 'organization-details-info-card', @@ -25,6 +32,123 @@ export class OrganizationDetailsInfoCard { /** Whether or not the user has permission to create events */ @Input() eventCreationPermissions!: boolean | null; + /** The snackbar from parent component, used to display join/leave status */ + @Input() snackBar: MatSnackBar | undefined; + + /** The user's membership, passed in from roster */ + @Input() membership?: OrganizationMembership; + + @Input() organizationRoster?: OrganizationMembership[] | undefined; + + @Input() organizationRosterService: OrganizationRosterService | undefined; + + /** Emits when the user joins or leaves the organization */ + @Output() membershipChanged = new EventEmitter(); + /** Constructs the organization detail info card widget */ constructor(private icons: SocialMediaIconWidgetService) {} + + isinOrganization() { + if ( + this.profile && + this.profile.id != null && + this.organization != undefined + ) { + for (let organization of this.profile.organizations) { + if (organization == this.organization.name) { + return true; + } + } + } + return false; + } + + checkActiveStatus(): boolean { + return this.membership?.status === OrganizationMembershipStatus.ACTIVE; + } + + checkPendingStatus(): boolean { + return this.membership?.status === OrganizationMembershipStatus.PENDING; + } + + getJoinButtonText(joinType: OrganizationJoinType | null): string { + return joinType === 'Open' + ? 'Join' + : joinType === 'Apply' + ? 'Apply' + : 'Closed'; + } + + handleJoinOrganization(slug: string, profile_id: number) { + this.organizationRosterService + ?.addOrganizationMembership(slug, profile_id, this.organization?.id ?? 0) + .subscribe({ + complete: () => { + if (this.organization) { + this.profile?.organizations.push(this.organization.name); + } + this.membershipChanged.emit(); + }, + error: () => { + this.snackBar?.open('Unable to join organization', 'Close', { + duration: 5000 + }); + } + }); + } + + handleLeaveOrganization(slug: string) { + if (this.membership) { + this.organizationRosterService + ?.deleteOrganizationMembership(slug, this.membership.id) + .subscribe({ + complete: () => { + if (this.organization) { + const index = this.profile?.organizations.findIndex( + (org) => org === this.organization?.name + ); + if (index !== -1 && index != null) { + this.profile?.organizations.splice(index, 1); + } + } + this.membershipChanged.emit(); + }, + error: () => { + this.snackBar?.open('Unable to leave organization', 'Close', { + duration: 5000 + }); + } + }); + } + } + + /** Returns an ordered list of memberships to display as exec board */ + getExecBoardMembers(): OrganizationMembership[] { + if (!this.organization || !this.organizationRoster) return []; + // Filter out members with no special title + const execs = this.organizationRoster.filter( + (m) => m.title && m.title.trim().toLowerCase() !== 'member' + ); + // Sort according to priority + const priority = [ + 'president', + 'co-president', + 'vice president', + 'treasurer', + 'secretary' + ]; + return execs.sort((a, b) => { + const aTitle = (a.title || '').toLowerCase(); + const bTitle = (b.title || '').toLowerCase(); + const aIndex = priority.findIndex((p) => aTitle.includes(p)); + const bIndex = priority.findIndex((p) => bTitle.includes(p)); + if (aIndex !== bIndex) { + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + } + // If no priority specified, sort alphabetically + return aTitle.localeCompare(bTitle); + }); + } } diff --git a/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.css b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.css new file mode 100644 index 000000000..f3f463cf7 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.css @@ -0,0 +1,83 @@ + +.mat-mdc-card { + margin-left: 0px; + max-width: 100%; + max-height: fit-content; +} + +.header-row { + display: flex; + flex-direction: row; + width: 100%; +} + +.header-column { + display: flex; + flex-direction: column; + justify-content: center; + + p { + margin-bottom: 0; + } +} + +#chip-column { + align-items: end; + margin-left: auto; +} + +.mat-mdc-chip { + height: 26px; +} + +.description { + padding-top: 8px; + padding-bottom: 8px; +} + +mat-card-actions { + gap: 8px; + margin-bottom: 8px; + justify-content: flex-end; +} + +.vertical-divider { + margin-left: 8px; + margin-top: 6px; + margin-bottom: 6px; +} + +#organization-chip:hover { + cursor: pointer; +} + +::ng-deep .profile-icon { + border-radius: 9px !important; +} + +.no-hover-chipset { + --mdc-chip-focus-state-layer-color: transparent !important; + --mdc-chip-hover-state-layer-color: transparent !important; +} + + +.links-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-top: 4px; + align-items: center; + margin-bottom: 4px; + column-gap: 16px; + } + + .link-icons { + margin-left: 6px; + } + + .row { + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + } \ No newline at end of file diff --git a/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.html b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.html new file mode 100644 index 000000000..d087e19b8 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.html @@ -0,0 +1,84 @@ + + +
+
+ {{ event.name }} + + +
+
+
+ + + + + @for (organizer of event.organizers; track organizer.id) { + + @if (organizer.github_avatar && organizer.github_avatar !== '') { + + } + + {{ organizer.first_name }} {{ organizer.last_name }} + + } + +

+ {{ event.description }} +

+
+ + @if(profile) { + + @if(event.user_registration_type === registrationType.ATTENDEE && + event.start > now) { + + } @else if(event.user_registration_type === registrationType.ORGANIZER) { + + } @else if(event.registration_limit === event.number_registered && + event.start > now) { + + } @else if(event.start > now) { @if(event.override_registration_url) { + + } @else { + + } } } + +
diff --git a/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.ts b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.ts new file mode 100644 index 000000000..dac9937d1 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-event-card/organization-event-card.widget.ts @@ -0,0 +1,80 @@ +/** + * The Event Card displays details for events in the paginated list. + * + * @author Ajay Gandecha + * @author Alex Feng + * @copyright 2025 + * @license MIT + */ + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { EventOverview, RegistrationType } from '../../../event/event.model'; +import { EventService } from '../../../event/event.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Profile } from 'src/app/profile/profile.service'; + +@Component({ + selector: 'new-event-card', + templateUrl: './organization-event-card.widget.html', + styleUrl: './organization-event-card.widget.css' +}) +export class OrganizationEventCardWidget { + @Input() profile: Profile | undefined; + registrationType = RegistrationType; + @Input() event!: EventOverview; + @Output() registrationChange = new EventEmitter(); + + now = new Date(); + + constructor( + protected eventService: EventService, + protected snackBar: MatSnackBar + ) {} + + /** Registers a user for an event. */ + registerForEvent() { + if (this.event.override_registration_url) { + window.location.href = this.event.override_registration_url!; + return; + } + + this.eventService.registerForEvent(this.event.id!).subscribe({ + next: () => { + this.registrationChange.emit(true); + this.snackBar.open( + `Successfully registered for ${this.event.name}!`, + 'Close', + { duration: 15000 } + ); + }, + error: () => { + this.snackBar.open( + `Error: Could not register. Please try again.`, + 'Close', + { duration: 15000 } + ); + } + }); + } + + /** Unregisters a user from an evenet. */ + unregisterForEvent() { + this.eventService.unregisterForEvent(this.event.id!).subscribe({ + next: () => { + this.registrationChange.emit(true); + this.snackBar.open( + `Successfully unregistered for ${this.event.name}!`, + 'Close', + { duration: 15000 } + ); + }, + error: () => { + this.snackBar.open( + `Error: Could not unregister. Please try again.`, + 'Close', + { duration: 15000 } + ); + } + }); + } +} diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.css b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.css new file mode 100644 index 000000000..ee427f67a --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.css @@ -0,0 +1,26 @@ +.title-form-field { + width: 100%; + margin-top: 16px; +} + +mat-dialog-content { + display: flex; + flex-direction: column; + gap: 1em; + padding: 24px; + min-height: fit-content; + max-height: none; + overflow: visible; +} + +.admin-permissions-section { + display: flex; + align-items: center; + gap: 12px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.html b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.html new file mode 100644 index 000000000..0c49ca689 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.html @@ -0,0 +1,29 @@ +

Edit Membership - {{ membership.user.first_name }} {{ membership.user.last_name }}

+ + + Position title + + +
+

Term: {{ membership.term.name || 'Current Term' }}

+
+
+ Admin: + + +
+
+ + + + \ No newline at end of file diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.ts b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.ts new file mode 100644 index 000000000..9b583d154 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component.ts @@ -0,0 +1,64 @@ +/** + * The Organization Roster Widget Edit Dialog + * is responsible for tracking the edited state of a single membership + * before either staging or cancelling the edit. + * + * @author Amy Xu + * @copyright 2025 + * @license MIT + */ + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + OrganizationMembership, + OrganizationMembershipPermissionLevel +} from 'src/app/organization/organization.model'; + +@Component({ + selector: 'organization-roster-edit-dialog', + templateUrl: './organization-roster-widget-edit-dialog.component.html', + styleUrls: ['./organization-roster-widget-edit-dialog.component.css'] +}) +export class OrganizationRosterEditDialogComponent { + isAdmin: boolean; + localTitle: string; + localPermissionLevel: OrganizationMembershipPermissionLevel; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public membership: OrganizationMembership + ) { + // Initialize local state from selected state + this.isAdmin = + membership.selected_permission_level === + OrganizationMembershipPermissionLevel.ADMIN; + this.localTitle = membership.selected_title || ''; + this.localPermissionLevel = + membership.selected_permission_level || + OrganizationMembershipPermissionLevel.MEMBER; + } + onAdminChange(isAdmin: boolean) { + this.localPermissionLevel = isAdmin + ? OrganizationMembershipPermissionLevel.ADMIN + : OrganizationMembershipPermissionLevel.MEMBER; + } + + onCancel() { + this.dialogRef.close(null); + } + + /** Stages local updates to be saved by user */ + onConfirm() { + this.membership.selected_title = this.localTitle; + this.membership.selected_permission_level = this.localPermissionLevel; + + if ( + !this.membership.selected_title || + this.membership.selected_title.trim() === '' + ) { + this.membership.selected_title = 'Member'; + } + this.dialogRef.close(this.membership); + } +} diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.css b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.css new file mode 100644 index 000000000..a1b65d4c5 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.css @@ -0,0 +1,160 @@ +mat-card-header { + flex-direction: column; + width: 100%; +} + +[matListItemIcon] { + display: flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + color: white; + font-weight: bold; + font-size: 16px; + background-color: #4786c6; + flex-shrink: 0; +} + +.item-content { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + width: 100%; + flex: 1; +} + +.text-content { + width: 200px; + flex: 1; +} + +.edit-btn-far-right { + margin-left: auto; +} + +p { + margin-bottom: 0; +} + +.mat-mdc-card-avatar { + margin-bottom: 0 !important; +} + +.header-row { + display: flex; + justify-content: space-between; + flex-direction: row; + align-items: center; + width: 100%; +} + +.links-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-top: 4px; + align-items: center; + margin-bottom: 12px; + margin-left: 48px; + margin-right: 32px; + gap: 12px; +} + +.link-icons { + width: 22px; + height: 22px; +} + +mat-divider { + margin-top: 12px; + padding-bottom: 4px; +} + +mat-card-actions { + padding-bottom: 12px; + justify-content: flex-end; + gap: 8px; +} + +.organization-search-bar { + ::ng-deep .mat-mdc-card { + max-width: 100% !important; + max-height: 90% !important; + margin-right: 10% !important; + } +} +mat-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 48px !important; + height: auto !important; + padding-top: 4px !important; + padding-bottom: 4px !important; + margin-top: 0px !important; +} + +mat-checkbox { + padding: 0; +} + +.text-content { + display: flex; + flex-direction: column; +} + +.checkbox-right { + margin-left: auto; + display: flex; + align-items: center; +} + +.title-form-field { + width: 150px; +} + +.header-row { + display: flex; + align-items: center; + justify-content: space-between; + width: fit-content; + gap: 8px; +} + +.remove-content { + display: flex; + margin-top: 8px; + align-items: center; + gap: 8px; + width: fit-content; +} + +.remove-content button { + color: salmon; +} + +.accept-reject-buttons { + display: flex; + gap: 8px; +} + +.accept-button, +.reject-button { + flex-shrink: 0; +} + +.reject-button { + background-color: #b3261e; + color: white; +} + +.changes-count { + margin: 4px 0 0 8px; + font-size: 0.9em; + color: #666; + font-style: italic; +} diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.html b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.html new file mode 100644 index 000000000..65e4ba7cc --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.html @@ -0,0 +1,170 @@ + + + +
+ Roster + @if (adminPermissions(this.organization?.slug) | async) { @if + (inEditing()) { + + + @if(hasUnsavedChanges) { +

{{ getTotalChangesCount() }} change{{ getTotalChangesCount() === 1 ? '' : 's' }} pending

+ } + } @else { + + } } +
+ + +
+ + + + @if(adminPermissions(this.organization?.slug)){ @if(inEditing()){ + + @for(membership of organizationRoster | rosterFilter: searchBarQuery; + track membership.user){ @if(!checkPendingStatus(membership.status)){ + +
+ +
+ {{ membership.user.first_name | slice:0:1 }} +
+ +
+

+ {{ membership.user.first_name + ' ' + membership.user.last_name }} +

+

+ {{ membership.selected_title ?? membership.title }} + {{ + (membership.selected_permission_level ?? membership.permission_level) === 'Admin' + ? ' (Admin)' + : '' + }} +

+
+ + + + @if(editingMembershipId === null){ + + } +
+
+ } } +
+ +
+ +

{{ checkCount() }} selected

+
+ } @else{ + + + + @for(membership of organizationRoster | rosterFilter: searchBarQuery; + track membership.user){ @if(!checkPendingStatus(membership.status)){ + +
+ {{ membership.user.first_name | slice:0:1 }} +
+ {{ membership.user.first_name + ' ' + + membership.user.last_name}} +

{{ membership.title }}

+
+ } } +
+
+ @if (adminPermissions(this.organization?.slug) | async) { + + + @for(membership of organizationRoster | rosterFilter: searchBarQuery; + track membership.user){ @if(checkPendingStatus(membership.status)){ + +
A
+
+
+ {{ membership.user.first_name + ' ' + + membership.user.last_name}} +
+
+ + +
+
+
+ } } +
+
+ } +
+ } } @else{ + + @for(membership of organizationRoster | rosterFilter: searchBarQuery; + track membership.user){ @if(!checkPendingStatus(membership.status)){ + +
A
+ {{ membership.user.first_name + ' ' + + membership.user.last_name}} +

{{ membership.status }}

+
+ }} +
+ } +
+
diff --git a/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.ts b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.ts new file mode 100644 index 000000000..17f121ae2 --- /dev/null +++ b/frontend/src/app/organization/widgets/organization-roster-widget/organization-roster.widget.ts @@ -0,0 +1,328 @@ +/** + * The Organization Roster widget is responsible for displaying a club's roster for general users + * as well as an interface for privileged users to accept/reject and edit memberships. + * + * @author Anika Ahmed, Alex Feng, Amy Xu, Alanna Zhang + * @copyright 2025 + * @license MIT + */ + +import { Component, Input, SimpleChanges } from '@angular/core'; +import { + Organization, + OrganizationMembership, + OrganizationJoinType, + OrganizationMembershipStatus +} from '../../organization.model'; +import { Profile } from 'src/app/models.module'; +import { OrganizationRosterService } from '../../organization-roster.service'; +import { PermissionService } from '../../../permission.service'; +import { Observable, of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { OrganizationRosterEditDialogComponent } from './organization-roster-widget-edit-dialog/organization-roster-widget-edit-dialog.component'; + +@Component({ + selector: 'organization-roster', + templateUrl: './organization-roster.widget.html', + styleUrls: ['./organization-roster.widget.css'] +}) +export class OrganizationRoster { + // Organization to perform operations on + @Input() organization!: Organization | undefined; + // Service to perform operations with + @Input() organizationRosterService!: OrganizationRosterService; + // Roster that has been pre-fetched + @Input() organizationRoster!: OrganizationMembership[]; + // User if they are logged in + @Input() profile?: Profile; + // Displays in organization-editing interface + public editing?: boolean; + + /** Resposible for showing editable roster view in HTML code when admin signed in. + * @returns {Observable} + */ + adminPermissions(slug: String | undefined): Observable { + if (slug) { + return this.permissionService.check( + 'organization.update', + `organization/${slug}` + ); + } + return of(false); + } + + // Responsible for auto-updating the roster when join type changes + ngOnChanges(changes: SimpleChanges) { + if (changes['organization'] && changes['organization'].currentValue) { + const previousJoinType = changes['organization'].previousValue?.join_type; + const currentJoinType = changes['organization'].currentValue.join_type; + + if (previousJoinType !== currentJoinType) { + console.log('Join type changed:', { + previousJoinType, + currentJoinType + }); + this.handleJoinTypeChange(currentJoinType); + } + this.organization = changes['organization'].currentValue; + } + } + + private handleJoinTypeChange(newJoinType: OrganizationJoinType | null) { + switch (newJoinType) { + case 'Open': + this.acceptRequests(this.getPendingRequests()); + break; + case 'Closed': + this.rejectRequests(this.getPendingRequests()); + break; + case 'Apply': + break; + } + } + + private getPendingRequests(): OrganizationMembership[] { + return this.organizationRoster.filter((membership) => + this.checkPendingStatus(membership.status) + ); + } + + /** Contents of the search bar */ + public searchBarQuery = ''; + + /** Array of checked memberships for bulk deletion */ + protected selectedMemberships: OrganizationMembership[] = []; + /** Deletes which are no longer visible but not yet finalized */ + protected stagedDeletes: OrganizationMembership[] = []; + /** Updates which are locally applied but not yet committed */ + protected stagedUpdates: OrganizationMembership[] = []; + /** Array of checked requests for bulk accept/reject */ + protected selectedRequests: OrganizationMembership[] = []; + /** ID of the membership being edited for title/admin level */ + protected editingMembershipId: number | null = null; + /** Original roster for reverting on cancel */ + originalRoster: OrganizationMembership[] = []; + hasUnsavedChanges = false; + + constructor( + private permissionService: PermissionService, + private dialog: MatDialog + ) {} + + inEditing() { + return this.editing; + } + + startEditing() { + this.editing = true; + this.originalRoster = JSON.parse(JSON.stringify(this.organizationRoster)); + return this.editing; + } + + // ** Cancels editing and clears editing state. Also called after saving changes to clear state. */ + cancelEditing() { + this.editing = false; + this.selectedMemberships = []; + // Restore dirty membership fields + for (const changed of this.stagedUpdates) { + changed.selected_title = undefined; + changed.selected_permission_level = undefined; + } + // Restore deleted memberships if not already deleted + for (const deleted of this.stagedDeletes) { + const original = this.originalRoster.find((m) => m.id === deleted.id); + if ( + original && + !this.organizationRoster.some((m) => m.id === original.id) + ) { + this.organizationRoster.push(original); + } + } + this.stagedUpdates = []; + this.stagedDeletes = []; + this.hasUnsavedChanges = false; + return this.editing; + } + + confirmUpdate() { + // Delete staged deletions + this.removeMemberships(this.stagedDeletes); + // Finalize dirty roster into official one + this.originalRoster = this.organizationRoster; + for (const membership of this.stagedUpdates) { + if ( + membership.id && + membership.organization_slug && + (membership.selected_title !== undefined || + membership.selected_permission_level !== undefined) + ) { + this.organizationRosterService + .updateOrganizationMembership( + membership.organization_slug, + membership.id, + membership.user.id, + membership.organization_id, + membership.term.id, + membership.selected_title ?? membership.title, + membership.selected_permission_level ?? membership.permission_level + ) + .subscribe((updatedMembership) => { + const rosterItem = this.organizationRoster.find( + (m) => m.id === membership.id + ); + if (rosterItem) { + // Finalizes value + rosterItem.title = updatedMembership.title; + rosterItem.permission_level = updatedMembership.permission_level; + } + }); + } + } + // Clear state + this.cancelEditing(); + } + + protected toggleSelectedMembership( + membership: OrganizationMembership, + selected: boolean + ) { + if (selected) { + this.selectedMemberships.push(membership); + } else { + this.selectedMemberships.splice( + this.selectedMemberships.indexOf(membership), + 1 + ); + } + } + + openEditDialog(membership: OrganizationMembership) { + // Initialize value to current selected values from session if any, otherwise use original values + membership.selected_title = membership.selected_title ?? membership.title; + membership.selected_permission_level = + membership.selected_permission_level ?? membership.permission_level; + + const dialogRef = this.dialog.open(OrganizationRosterEditDialogComponent, { + width: '500px', + height: 'auto', + maxHeight: '90vh', + data: { ...membership } + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + membership.selected_title = result.selected_title; + membership.selected_permission_level = result.selected_permission_level; + if (!this.stagedUpdates.includes(membership)) { + this.stagedUpdates.push(membership); + } + this.hasUnsavedChanges = this.hasActualChanges(); + } + }); + } + checkCount() { + return this.selectedMemberships.length; + } + getTotalChangesCount() { + // Staged deletes are always actual changes + let changeCount = this.stagedDeletes.length; + + for (const membership of this.stagedUpdates) { + const titleChanged = membership.selected_title !== membership.title; + const permissionChanged = + membership.selected_permission_level !== membership.permission_level; + if (titleChanged || permissionChanged) { + changeCount++; + } + } + return changeCount; + } + private hasActualChanges(): boolean { + return this.getTotalChangesCount() > 0; + } + checkPendingStatus(status: OrganizationMembershipStatus) { + if (status === OrganizationMembershipStatus.PENDING) { + return true; + } + return false; + } + protected removeMemberships(memberships: OrganizationMembership[]) { + for (const membership of memberships) { + if (membership.id && membership.organization_slug) { + this.organizationRosterService + .deleteOrganizationMembership( + membership.organization_slug, + membership.id + ) + .subscribe(); + } + } + this.selectedMemberships = []; + } + + protected acceptRequest = (membership: OrganizationMembership) => { + if ( + membership.id !== null && + membership.status === OrganizationMembershipStatus.PENDING + ) + this.organizationRosterService + .updateOrganizationMembership( + membership.organization_slug, + membership.id, + membership.user.id, + membership.organization_id, + membership.term.id, + membership.title, + membership.permission_level, + OrganizationMembershipStatus.ACTIVE + ) + .subscribe((updatedMembership) => { + this.organizationRoster = this.organizationRoster.filter( + (m) => m.id !== membership.id + ); + this.organizationRoster.push(updatedMembership); + }); + }; + + protected rejectRequest = (membership: OrganizationMembership) => { + if ( + membership.id !== null && + membership.status === OrganizationMembershipStatus.PENDING + ) + this.organizationRosterService + .deleteOrganizationMembership( + membership.organization_slug, + membership.id + ) + .subscribe(() => { + this.organizationRoster = this.organizationRoster.filter( + (m) => m.id !== membership.id + ); + }); + }; + + protected stageDeletes = (memberships: OrganizationMembership[]) => { + this.stagedDeletes = memberships; + for (const membership of memberships) { + this.organizationRoster = this.organizationRoster.filter( + (member) => member.id !== membership.id + ); + } + this.selectedMemberships = []; + this.hasUnsavedChanges = true; + }; + + // Configurable for a selection of requests + // Currently used to select all requests for the purpose of opening/closing a club + protected acceptRequests = (memberships: OrganizationMembership[]) => { + for (const membership of memberships) { + this.acceptRequest(membership); + } + }; + + protected rejectRequests = (memberships: OrganizationMembership[]) => { + for (const membership of memberships) { + this.rejectRequest(membership); + } + }; +} diff --git a/frontend/src/app/profile/profile.service.ts b/frontend/src/app/profile/profile.service.ts index ad2fdc46c..db05feb70 100644 --- a/frontend/src/app/profile/profile.service.ts +++ b/frontend/src/app/profile/profile.service.ts @@ -27,6 +27,7 @@ export interface Profile { bio: string | null; linkedin: string | null; website: string | null; + organizations: string[]; } export interface PublicProfile {