diff --git a/api/const.py b/api/const.py index f533e69..a6fdf99 100644 --- a/api/const.py +++ b/api/const.py @@ -36,3 +36,35 @@ class Permissions(int, Enum): CREATE_FEEDBACK = 8388608 UPDATE_FEEDBACK = 16777216 DELETE_FEEDBACK = 33554432 + + +class CorePermissions(int, Enum): + UPDATE_USER = 1 + DELETE_USER = 2 + CREATE_DIVISION = 4 + UPDATE_DIVISION = 8 + DELETE_DIVISION = 16 + CREATE_ROLE = 32 ## make it a feature permission + UPDATE_ROLE = 64 ## make it a feature permission + DELETE_ROLE = 128 ## make it a feature permission + + +class FeaturePermissions(int, Enum): + CREATE_ASSIGNMENT = 1 + UPDATE_ASSIGNMENT = 2 + DELETE_ASSIGNMENT = 4 + CREATE_ANNOUNCEMENT = 8 + UPDATE_ANNOUNCEMENT = 16 + DELETE_ANNOUNCEMENT = 32 + CREATE_MEETING = 64 + UPDATE_MEETING = 128 + DELETE_MEETING = 256 + CREATE_SUBMISSION = 512 + UPDATE_SUBMISSION = 1024 + DELETE_SUBMISSION = 2048 + CREATE_EXCUSE = 4096 + UPDATE_EXCUSE = 8192 + DELETE_EXCUSE = 16384 + CREATE_FEEDBACK = 32768 + UPDATE_FEEDBACK = 65536 + DELETE_FEEDBACK = 131072 diff --git a/api/crud/core/core_base.py b/api/crud/core/core_base.py index 23b3fc6..6f40e5e 100644 --- a/api/crud/core/core_base.py +++ b/api/crud/core/core_base.py @@ -2,9 +2,11 @@ from typing import TypeVar, List, Callable from fastapi import HTTPException +from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Session, Query from starlette import status +from ...const import CorePermissions, FeaturePermissions from ...db.models import UserModel T = TypeVar("T") @@ -30,6 +32,34 @@ def get_db_all(cls, db: Session, attribute: str, value: int | str) -> List[T]: def get_db_dump(cls, db: Session) -> List[T]: return db.query(cls.db_model).all() + # Function to filter the table based on keyword arguments + @classmethod + def filter_table(cls, db: Session, **kwargs) -> T | None: + + query = db.query(cls.db_model) + + for attr, value in kwargs.items(): + query = query.filter(getattr(cls.db_model, attr) == value) + + try: + results = query.first() + return results + except NoResultFound: + return [] + + @classmethod + def filter_all(cls, db: Session, **kwargs) -> List[T]: + query = db.query(cls.db_model) + + for attr, value in kwargs.items(): + query = query.filter(getattr(cls.db_model, attr) == value) + + try: + results = query.all() + return results + except NoResultFound: + return [] + @classmethod def create(cls, db: Session, **kwargs) -> T: new_model = cls.db_model(**kwargs) @@ -62,3 +92,19 @@ def db_update(cls, model_instance, **kwargs) -> None: """this method is used to update a model instance without committing to the database""" for key, value in kwargs.items(): setattr(model_instance, key, value) + + @classmethod + def _calculate_core_permission(cls, request_permissions: dict) -> int: + total_request: int = 0 + for permission in CorePermissions: + if request_permissions[permission.name]: + total_request = total_request | permission.value + return total_request + + @classmethod + def _calculate_feature_permission(cls, request_permissions: dict) -> int: + total_request: int = 0 + for permission in FeaturePermissions: + if request_permissions[permission.name]: + total_request = total_request | permission.value + return total_request diff --git a/api/crud/core/role.py b/api/crud/core/role.py index ad445cd..c9c227e 100644 --- a/api/crud/core/role.py +++ b/api/crud/core/role.py @@ -3,8 +3,9 @@ from starlette import status from .core_base import CoreBase -from ...const import Permissions +from .division import Division from ...db.models import RoleModel +from ...db.models.division_model import DivisionModel class Role(CoreBase): @@ -12,9 +13,14 @@ class Role(CoreBase): @classmethod def create(cls, db: Session, **kwargs) -> RoleModel: - cls.check_role_exists(db, kwargs.get("name")) + cls.check_role_exists(db, kwargs.get("name"), kwargs.get("division_id")) + division_exists = Division.get_db_first(db, "id", kwargs.get("division_id")) + if not division_exists: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="division does not exist") + return super().create(db, name=kwargs["name"], - permissions=cls._calculate_total_permission(kwargs["permissions"])) + division_id=kwargs["division_id"], + permissions=cls._calculate_feature_permission(kwargs["permissions"])) @classmethod def update(cls, model_id: int, db: Session, **kwargs) -> RoleModel: @@ -24,22 +30,28 @@ def update(cls, model_id: int, db: Session, **kwargs) -> RoleModel: if role_found and role_found.id != model_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role already exists") model.name = kwargs["name"] - model.permissions = cls._calculate_total_permission(kwargs["permissions"]) + model.permissions = cls._calculate_feature_permission(kwargs["permissions"]) db.commit() db.refresh(model) return model raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{cls.__name__.lower()} not found") @classmethod - def check_role_exists(cls, db: Session, name: str) -> None: + def check_role_exists(cls, db: Session, name: str, division_id: int) -> None: role = cls.get_db_first(db, "name", name) - if role: + if role and role.division_id == division_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role already exists") + # @classmethod + # def _calculate_total_permission(cls, request_permissions: dict) -> int: + # total_request: int = 0 + # for permission in Permissions: + # if request_permissions[permission.name]: + # total_request = total_request | permission.value + # return total_request + @classmethod - def _calculate_total_permission(cls, request_permissions: dict) -> int: - total_request: int = 0 - for permission in Permissions: - if request_permissions[permission.name]: - total_request = total_request | permission.value - return total_request + def check_division_exists(cls, db, division_id: int) -> None: + checkDivision = db.query(DivisionModel).filter_by(id=division_id).first() is not None + if not checkDivision: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="division not found") diff --git a/api/crud/core/user.py b/api/crud/core/user.py index d486d86..7e1e27e 100644 --- a/api/crud/core/user.py +++ b/api/crud/core/user.py @@ -3,6 +3,8 @@ from starlette import status from .core_base import CoreBase +from .role import Role +from .userrole import UserRole from ...db.models import UserModel @@ -18,4 +20,26 @@ def get_db_username_or_email(cls, db: Session, username: str) -> UserModel | Non def validate_username(cls, db: Session, username: str) -> None: existing_user = cls.get_db_first(db, "username", username) if existing_user: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username is taken") \ No newline at end of file + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username is taken") + + @classmethod + def edit_user_permissions(cls, db: Session, user_id: int, **kwargs) -> None: + user = cls.get_db_first(db, "id", user_id) + if user: + user.permissions = cls._calculate_core_permission(kwargs) + db.commit() + db.refresh(user) + return user + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") + + @classmethod + def assign_user_role(cls, db: Session, user_id: int, role_id: int) -> UserRole: + user = cls.get_db_first(db, "id", user_id) + if user: + role = Role.get_db_first(db, "id", role_id) + if role: + if UserRole.filter_table(db, user_id=user_id, role_id=role_id): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="user already has this role") + return UserRole.create(db=db, user_id=user_id, role_id=role_id) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") diff --git a/api/crud/core/userrole.py b/api/crud/core/userrole.py new file mode 100644 index 0000000..9c93d85 --- /dev/null +++ b/api/crud/core/userrole.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session + +from api.crud.core.core_base import CoreBase + +from ...db.models import UserRoleModel + + +class UserRole(CoreBase): + db_model = UserRoleModel + + @classmethod + def delete(cls, db: Session, user_id: int, role_id: int) -> dict: + # Use UserRoleModel for database operations + db.query(cls.db_model).filter_by(user_id=user_id, role_id=role_id).delete() + db.commit() + return {"message": "record deleted successfully"} diff --git a/api/crud/core/userroledivision.py b/api/crud/core/userroledivision.py deleted file mode 100644 index ff63ca6..0000000 --- a/api/crud/core/userroledivision.py +++ /dev/null @@ -1,70 +0,0 @@ -from fastapi import HTTPException -from sqlalchemy.orm import Session -from starlette import status - -from .core_base import CoreBase -from .division import Division -from .role import Role -from .user import User -from ...db.models import UserModel, DivisionModel, UserDivisionPermissionModel, RoleModel -from ...db.models import UserRoleDivisionModel - - -class UserRoleDivision(CoreBase): - db_model = UserRoleDivisionModel - - @classmethod - def create(cls, db: Session, user_id: int, role_id: int, division_id: int) -> UserRoleDivisionModel: - user = User.get_db_first(db, "id", user_id) - if user: - role = Role.get_db_first(db, "id", role_id) - if role: - division = Division.get_db_first(db, "id", division_id) - if division: - cls._add_user_division_permission_record(db, user, division, role) - return super().create(db, user=user, role=role, division=division) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="division not found") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role not found") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") - - @classmethod - def delete(cls, db: Session, user_id: int, role_id: int, division_id: int) -> dict: - record = db.query(cls.db_model).filter( - (cls.db_model.user_id == user_id) & - (cls.db_model.role_id == role_id) & - (cls.db_model.division_id == division_id) - ).first() - if record: - db.delete(record) - db.delete(cls._delete_user_division_permission_record(db, record.user, record.division, record.role)) - db.commit() - - return {"message": "record deleted successfully"} - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") - - @classmethod - def _add_user_division_permission_record(cls, db: Session, user: UserModel, division: DivisionModel, - role: RoleModel) -> UserDivisionPermissionModel: - record = db.query(UserDivisionPermissionModel).filter( - (UserDivisionPermissionModel.user == user) & - (UserDivisionPermissionModel.division == division) - ).first() - if record: - record.permissions |= role.permissions - else: - record = UserDivisionPermissionModel(user=user, division=division, permissions=role.permissions) - db.add(record) - return record - - @classmethod - def _delete_user_division_permission_record(cls, db: Session, user: UserModel, division: DivisionModel, - role: RoleModel) -> UserDivisionPermissionModel: - record = db.query(UserDivisionPermissionModel).filter( - (UserDivisionPermissionModel.user == user) & - (UserDivisionPermissionModel.division == division) - ).first() - if record: - record.permissions &= ~role.permissions - if record.permissions == 0: - db.delete(record) - return record diff --git a/api/crud/core/userroledivisionpermission.py b/api/crud/core/userroledivisionpermission.py new file mode 100644 index 0000000..7d4ba68 --- /dev/null +++ b/api/crud/core/userroledivisionpermission.py @@ -0,0 +1,49 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session +from starlette import status + +from .core_base import CoreBase +from .division import Division +from .role import Role +from .user import User +from ...db.models import UserModel, DivisionModel, RoleModel, \ + UserRoleDivisionPermissionModel + + +class UserRoleDivisionPermission(CoreBase): + db_model = UserRoleDivisionPermissionModel + + @classmethod + def create(cls, db: Session, user_id: int, division_id: int, role_id: int, + **kwargs) -> UserRoleDivisionPermissionModel: + user = User.get_db_first(db, "id", user_id) + division = Division.get_db_first(db, "id", division_id) + + if cls.filter_table(db, user_id=user_id, division_id=division_id, role_id=role_id): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, + detail="User already in this and have the same role") + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if not division: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Division not found") + + return super().create( + db, user_id=user_id, division_id=division_id, role_id=role_id, + permissions=cls._calculate_feature_permission(kwargs) + ) + + @classmethod + def delete(cls, db: Session, user_id: int, division_id: int, role_id: int | None, ) -> dict: + record = db.query(cls.db_model).filter( + (cls.db_model.user_id == user_id) & + (cls.db_model.division_id == division_id) + & (cls.db_model.role_id == role_id) + ).first() + if record: + db.delete(record) + db.commit() + + return {"message": "record deleted successfully"} + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found") diff --git a/api/crud/feature/feature_base.py b/api/crud/feature/feature_base.py index 5da3831..ff5c996 100644 --- a/api/crud/feature/feature_base.py +++ b/api/crud/feature/feature_base.py @@ -4,60 +4,28 @@ from starlette import status from ..core.core_base import CoreBase -from ...const import Permissions -from ...db.models import UserModel, DivisionModel, UserDivisionPermissionModel +from ...db.models import UserModel, DivisionModel class FeatureBase(CoreBase): """Base class for all feature entities, contains the basic CRUD inherited from CoreBase""" @classmethod - def create(cls, request: BaseModel, db: Session, user: UserModel) -> Mapper | None: - request.division = db.query(DivisionModel).filter_by(name=request.division).first() - if request.division: - cls._compare_permissions(user, cls._fetch_permission_value("CREATE"), request.division, db) - return super().create(db, creator=user, **request.model_dump()) + def create(cls, request: BaseModel, db: Session, user: UserModel, division_id: int) -> Mapper | None: + division = db.query(DivisionModel).filter_by(id=division_id).first() + if division: + return super().create(db, creator=user, division_id=division.id, **request.model_dump()) raise HTTPException(status.HTTP_404_NOT_FOUND, "division not found") @classmethod - def update(cls, model_id: int, request: BaseModel, db: Session, user: UserModel | None = None, **kwargs) -> dict[ - str: str]: + def update(cls, model_id: int, request: BaseModel, db: Session, division_id: int, user: UserModel | None = None, + **kwargs) -> dict[str: str]: model = db.query(cls.db_model).filter_by(id=model_id).first() if model: - request.division = db.query(DivisionModel).filter_by(name=request.division).first() - if request.division: - cls._compare_permissions(user, cls._fetch_permission_value("UPDATE"), request.division, db) - cls.db_update(model, **request.model_dump()) - db.commit() - db.refresh(model) + division = db.query(DivisionModel).filter_by(id=division_id).first() + if division: + super().update(model_id, db, **request.model_dump(), division_id=division.id) return model raise HTTPException(status.HTTP_404_NOT_FOUND, detail="division not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{cls.__name__.lower()} not found") - @classmethod - def delete(cls, model_id: int, db: Session, user: UserModel) -> dict: - model = cls.get_db_first(db, "id", model_id) - if model: - cls._compare_permissions(user, cls._fetch_permission_value("DELETE"), model.division, db) - db.delete(model) - db.commit() - return {"msg": f"{cls.__name__.lower()} deleted"} - - @classmethod - def _fetch_permission_value(cls, action: str) -> int: - return getattr(Permissions, f"{action}_{cls.__name__.upper()}") - - @classmethod - def _compare_permissions(cls, user: UserModel, - permission_to_check: int, - division: DivisionModel, - db: Session) -> UserModel: - - user_permissions: UserDivisionPermissionModel | None = db.query(UserDivisionPermissionModel).filter_by( - user=user, - division=division).first() - if user_permissions: - if (user_permissions.permissions & permission_to_check) == permission_to_check: - return user - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail=f"this user does not have the permission to do this action in {division.name} division") diff --git a/api/crud/sub_feature/sub_feature_base.py b/api/crud/sub_feature/sub_feature_base.py index 8bbbf1b..997c71b 100644 --- a/api/crud/sub_feature/sub_feature_base.py +++ b/api/crud/sub_feature/sub_feature_base.py @@ -4,34 +4,36 @@ from pydantic import BaseModel from sqlalchemy.orm import Session, Mapper +from ..core.core_base import CoreBase from ..feature.feature_base import FeatureBase from ...db.models import UserModel +from ...db.models.division_model import DivisionModel T = TypeVar("T") -class SubFeatureBase(FeatureBase): +class SubFeatureBase(CoreBase): """Base class for all sub-feature entities, contains the basic CRUD inherited from CoreBase""" parent_name: str parent_model: T @classmethod - def create(cls, request: BaseModel, db: Session, user: UserModel) -> Mapper: - parent: T | None = db.query(cls.parent_model).filter_by(id=getattr(request, cls.parent_name)).first() - if parent: - setattr(request, cls.parent_name, parent) - cls._compare_permissions(user, cls._fetch_permission_value("CREATE"), parent.division, db) - return super(FeatureBase, cls).create(db, creator=user, **request.model_dump(), division=parent.division) - raise HTTPException(status.HTTP_404_NOT_FOUND, f"{cls.parent_name} not found") + def create(cls, request: BaseModel, db: Session, user: UserModel, feature_to_check: str, + feature_model: T) -> Mapper: + feature: T | None = db.query(feature_model).filter_by(id=getattr(request, feature_to_check)).first() + setattr(request, feature_to_check, feature) + if getattr(request, feature_to_check): + return super().create(db, creator=user, **request.model_dump(), division=feature.division) + raise HTTPException(status.HTTP_404_NOT_FOUND, f"{feature_to_check} not found") @classmethod - def update(cls, model_id: int, db: Session, user: UserModel, **kwargs) -> T: - model = cls.get_db_first(db, "id", model_id) + def update(cls, model_id: int, request: BaseModel, db: Session, division_id: int, user: UserModel | None = None, + **kwargs) -> dict[str: str]: + model = db.query(cls.db_model).filter_by(id=model_id).first() if model: - cls._compare_permissions(user, cls._fetch_permission_value("UPDATE"), - getattr(model, cls.parent_name).division, db) - cls.db_update(model, **kwargs) - db.commit() - db.refresh(model) - return model + division = db.query(DivisionModel).filter_by(id=division_id).first() + if division: + super().update(model_id, db, **request.model_dump()) + return model + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="division not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{cls.__name__.lower()} not found") diff --git a/api/db/models/division_model.py b/api/db/models/division_model.py index 9e01157..fb54c06 100644 --- a/api/db/models/division_model.py +++ b/api/db/models/division_model.py @@ -29,13 +29,12 @@ class DivisionModel(Base): feedback: Mapped[List["FeedbackModel"]] = Relationship("FeedbackModel", back_populates="division", cascade="all, delete") - user_role_division: Mapped[List["UserRoleDivisionModel"]] = Relationship("UserRoleDivisionModel", - back_populates="division", - cascade="all, delete-orphan") - - user_division_permission: Mapped[List["UserDivisionPermissionModel"]] = Relationship("UserDivisionPermissionModel", + user_role_division_permission: Mapped[List["UserRoleDivisionPermissionModel"]] = Relationship("UserRoleDivisionPermissionModel", back_populates="division", cascade="all, delete-orphan") + roles: Mapped[List["RoleModel"]] = Relationship("RoleModel", back_populates="division", + cascade="all, delete-orphan") + def __repr__(self): return f"""(id: {self.id}, name: {self.name}, parent: {self.parent})""" diff --git a/api/db/models/role_model.py b/api/db/models/role_model.py index 277865f..33e080e 100644 --- a/api/db/models/role_model.py +++ b/api/db/models/role_model.py @@ -1,5 +1,5 @@ from typing import List -from sqlalchemy import String, Integer +from sqlalchemy import String, Integer, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, Relationship from api.db.models.base import Base @@ -9,12 +9,19 @@ class RoleModel(Base): __tablename__ = 'roles' id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(30), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(30), nullable=False) + division_id: Mapped[int] = mapped_column(ForeignKey("divisions.id"), nullable=True) permissions: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - user_role_division: Mapped[List["UserRoleDivisionModel"]] = Relationship("UserRoleDivisionModel", - back_populates="role", - cascade="all, delete") + division: Mapped["DivisionModel"] = Relationship("DivisionModel", back_populates="roles") + + user_role: Mapped[List["UserRoleModel"]] = Relationship("UserRoleModel", back_populates="role", + cascade="all, delete-orphan") + + user_role_division_permission: Mapped[List["UserRoleDivisionPermissionModel"]] = Relationship( + "UserRoleDivisionPermissionModel", + back_populates="role", + cascade="all, delete-orphan") def __repr__(self): return f"""Role( diff --git a/api/db/models/user_division_permission.py b/api/db/models/user_division_permission.py deleted file mode 100644 index 3f020b4..0000000 --- a/api/db/models/user_division_permission.py +++ /dev/null @@ -1,17 +0,0 @@ -from .base import Base -from sqlalchemy import ForeignKey, Integer -from sqlalchemy.orm import Relationship, mapped_column, Mapped - - -class UserDivisionPermissionModel(Base): - __tablename__ = "user_division_permission" - - user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), primary_key=True) - division_id: Mapped[int] = mapped_column(Integer, ForeignKey("divisions.id"), primary_key=True) - permissions: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - - user: Mapped["UserModel"] = Relationship("UserModel", back_populates="user_division_permission") - division: Mapped["DivisionModel"] = Relationship("DivisionModel", back_populates="user_division_permission") - - def __repr__(self): - return f"" diff --git a/api/db/models/user_model.py b/api/db/models/user_model.py index 66ed329..834496a 100644 --- a/api/db/models/user_model.py +++ b/api/db/models/user_model.py @@ -31,6 +31,7 @@ class UserModel(Base): bio: Mapped[str] = mapped_column(String, nullable=True) confirmed: Mapped[bool] = mapped_column(Boolean, default=False) date_created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.datetime.now()) + permissions: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # One-to-Many relationships announcements: Mapped[List["AnnouncementModel"]] = Relationship("AnnouncementModel", back_populates="creator", @@ -45,12 +46,14 @@ class UserModel(Base): cascade="all, delete-orphan") feedback: Mapped[List["FeedbackModel"]] = Relationship("FeedbackModel", back_populates="creator", cascade="all, delete-orphan") - user_role_division: Mapped[List["UserRoleDivisonModel"]] = Relationship("UserRoleDivisionModel", - back_populates="user", - cascade="all, delete-orphan") - user_division_permission: Mapped[List["UserDivisionPermissionModel"]] = Relationship("UserDivisionPermissionModel", - back_populates="user", - cascade="all, delete-orphan") + + user_role_division_permission: Mapped[List["UserRoleDivisionPermissionModel"]] = Relationship( + "UserRoleDivisionPermissionModel", + back_populates="user", + cascade="all, delete-orphan") + + user_role: Mapped[List["UserRoleModel"]] = Relationship("UserRoleModel", back_populates="user", + cascade="all, delete-orphan") def set_password(self, password) -> str: self.password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") diff --git a/api/db/models/user_role.py b/api/db/models/user_role.py new file mode 100644 index 0000000..ec831e2 --- /dev/null +++ b/api/db/models/user_role.py @@ -0,0 +1,20 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, Relationship + +from api.db.models.base import Base + + +class UserRoleModel(Base): + __tablename__ = "user_role" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, primary_key=True) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False, primary_key=True) + + user: Mapped["UserModel"] = Relationship("UserModel", back_populates="user_role") + role: Mapped["RoleModel"] = Relationship("RoleModel", back_populates="user_role") + + def __repr__(self): + return f"""UserRole( + "user_id": {self.user_id} + "role_id": {self.role_id} + )""" diff --git a/api/db/models/user_role_division.py b/api/db/models/user_role_division.py deleted file mode 100644 index a01e1d3..0000000 --- a/api/db/models/user_role_division.py +++ /dev/null @@ -1,17 +0,0 @@ -from .base import Base -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Relationship, mapped_column, Mapped - - -class UserRoleDivisionModel(Base): - __tablename__ = "user_role_division" - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) - role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True) - division_id: Mapped[int] = mapped_column(ForeignKey("divisions.id"), primary_key=True) - - user: Mapped["UserModel"] = Relationship("UserModel", back_populates="user_role_division") - role: Mapped["RoleModel"] = Relationship("RoleModel", back_populates="user_role_division") - division: Mapped["DivisionModel"] = Relationship("DivisionModel", back_populates="user_role_division") - - def __repr__(self): - return f"" diff --git a/api/db/models/user_role_division_permission.py b/api/db/models/user_role_division_permission.py new file mode 100644 index 0000000..a2cff45 --- /dev/null +++ b/api/db/models/user_role_division_permission.py @@ -0,0 +1,18 @@ +from .base import Base +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.orm import Relationship, mapped_column, Mapped + +class UserRoleDivisionPermissionModel(Base): + __tablename__ = "user_role_division_permission" + + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), primary_key=True) + role_id: Mapped[int] = mapped_column(Integer, ForeignKey("roles.id"), primary_key=True, nullable=True) + division_id: Mapped[int] = mapped_column(Integer, ForeignKey("divisions.id"), primary_key=True) + permissions: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + user: Mapped["UserModel"] = Relationship("UserModel", back_populates="user_role_division_permission") + role: Mapped["RoleModel"] = Relationship("RoleModel", back_populates="user_role_division_permission") + division: Mapped["DivisionModel"] = Relationship("DivisionModel", back_populates="user_role_division_permission") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/api/dependencies.py b/api/dependencies.py index a0d8a2d..e91c7f2 100644 --- a/api/dependencies.py +++ b/api/dependencies.py @@ -1,15 +1,12 @@ -from typing import Any - from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.orm import Session, Mapper +from sqlalchemy import Integer +from sqlalchemy.orm import Session, aliased from api.db import SessionLocal from api.db.models import UserModel # unresolved reference ignored -from api.db.models.assignment_model import AssignmentModel from api.db.models.division_model import DivisionModel -from api.db.models.role_model import RoleModel -from api.db.models.user_division_permission import UserDivisionPermissionModel +from api.db.models.user_role_division_permission import UserRoleDivisionPermissionModel from api.utils import decode_token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="users/signin") @@ -43,32 +40,49 @@ def __init__(self, permission_to_check: int, core: bool = False): async def __call__(self, request: Request, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> UserModel: - if self.core: - if not db.query(RoleModel).first() or not db.query( - DivisionModel).first(): # check if there is any role in the database + self.check_user_permission(self.permission_to_check, user) + else: + division: DivisionModel | None = db.query(DivisionModel).filter_by( + id=request.path_params["division_id"]).first() + special_user = self.check_special_user_permission(self.permission_to_check, user, division, db) + if special_user is not None: + return special_user + else: + # Check role permission + self.check_role_permission(self.permission_to_check, user, division, db) return user - division: DivisionModel | None = db.query(DivisionModel).filter_by(parent=None).first() - return self._compare_permissions(user, self.permission_to_check, division, db) - - @classmethod - async def _read_value_from_request(cls, request: Request, key: str) -> Any: - return (await request.json())[key] + @staticmethod + def check_user_permission(permission_to_check: int, user: UserModel) -> UserModel: + if user.permissions & permission_to_check: + return user + else: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User does not have permission") - @classmethod - def _compare_permissions(cls, user: UserModel, - permission_to_check: int, - division: DivisionModel, - db: Session) -> UserModel: - if division: - user_permissions: UserDivisionPermissionModel | None = db.query(UserDivisionPermissionModel).filter_by( - user=user, - division=division).first() - if user_permissions: - if (user_permissions.permissions & permission_to_check) == permission_to_check: + @staticmethod + def check_role_permission(permission_to_check: int, user: UserModel, division: DivisionModel, + db: Session = Depends(get_db)) -> UserModel: + user_division_permission_records = db.query(UserRoleDivisionPermissionModel) \ + .filter(user_id=user.id, division_id=division.id) \ + .all() + if user_division_permission_records: + for record in user_division_permission_records: + if record.role.permissions & permission_to_check: return user - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, - detail=f"this user does not have the permission to do this action in {division.name} division") - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, - detail="the division this request is sent to does not exist") + else: + continue + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User does not have permission") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User does not have role in this division") + + @staticmethod + def check_special_user_permission(permission_to_check: int, user: UserModel, division: DivisionModel, + db: Session = Depends(get_db)) -> UserModel: + user_division_permission = db.query(UserRoleDivisionPermissionModel) \ + .filter_by(user_id=user.id, division_id=division.id) \ + .all() + for record in user_division_permission: + if record.permissions & permission_to_check: + return user + else: + return None diff --git a/api/routes/announcements.py b/api/routes/announcements.py index 4a00324..2210f7a 100644 --- a/api/routes/announcements.py +++ b/api/routes/announcements.py @@ -1,6 +1,6 @@ from fastapi import Depends, APIRouter from sqlalchemy.orm import Session - +from api.const import FeaturePermissions from api.crud.feature.announcement import Announcement from api.db.models import UserModel # unresolved reference ignored from api.validators import AnnouncementValidator, AnnouncementUpdateValidator @@ -16,20 +16,21 @@ async def get_announcements(db: Session = Depends(get_db)): return Announcement.get_db_dump(db) -@announcementsRouter.post("/announcements") -async def post_announcement(request: AnnouncementValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Announcement.create(request, db, user) +@announcementsRouter.post("/announcements/{division_id}") +async def post_announcement(division_id: int, request: AnnouncementValidator, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_ANNOUNCEMENT))): + return Announcement.create(request, db, user, division_id) -@announcementsRouter.put("/announcements/{announcement_id}") +@announcementsRouter.put("/announcements/{announcement_id}/{division_id}") async def update_announcement(announcement_id: int, request: AnnouncementUpdateValidator, + division_id: int, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Announcement.update(announcement_id, request, db, user) + user: UserModel = Depends(CheckPermission(FeaturePermissions.UPDATE_ANNOUNCEMENT))): + return Announcement.update(announcement_id, request, db, division_id, user) -@announcementsRouter.delete("/announcements/{model_id}") -async def delete_announcement(model_id: int, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Announcement.delete(model_id, db, user) +@announcementsRouter.delete("/announcements/{announcement_id}/{division_id}") +async def delete_announcement(announcement_id: int, division_id: int, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.DELETE_ANNOUNCEMENT))): + return Announcement.delete(announcement_id, db, user) diff --git a/api/routes/app.py b/api/routes/app.py new file mode 100644 index 0000000..0ceb61f --- /dev/null +++ b/api/routes/app.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from fastapi.responses import RedirectResponse + +app: FastAPI = FastAPI() + + +@app.get("/", include_in_schema=False) +async def redirect_docs(): + return RedirectResponse(url="/docs") \ No newline at end of file diff --git a/api/routes/assignments.py b/api/routes/assignments.py index a8a0c1b..10ddad4 100644 --- a/api/routes/assignments.py +++ b/api/routes/assignments.py @@ -1,9 +1,10 @@ from fastapi import APIRouter, status, Depends from sqlalchemy.orm import Session +from api.const import FeaturePermissions from api.crud.feature.assignment import Assignment from api.db.models import UserModel # unresolved reference ignored -from api.dependencies import get_current_user, get_db +from api.dependencies import get_current_user, get_db, CheckPermission from api.validators import AssignmentValidator, AssignmentUpdateValidator assignmentsRouter = APIRouter( @@ -17,19 +18,20 @@ async def get_assignments(db: Session = Depends(get_db), return Assignment.get_db_dump(db) -@assignmentsRouter.post("/assignments", status_code=status.HTTP_201_CREATED) -async def create_assignment(request: AssignmentValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Assignment.create(request, db, user) +@assignmentsRouter.post("/assignments/{division_id}", status_code=status.HTTP_201_CREATED) +async def create_assignment(division_id: int, request: AssignmentValidator, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_ASSIGNMENT))): + return Assignment.create(request, db, user, division_id) -@assignmentsRouter.put("/assignments/{assignment_id}") -async def update_assignment(assignment_id: int, request: AssignmentUpdateValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Assignment.update(assignment_id, request, db, user) +@assignmentsRouter.put("/assignments/{assignment_id}/{division_id}") +async def update_assignment(assignment_id: int, request: AssignmentUpdateValidator, division_id: int, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.UPDATE_ASSIGNMENT))): + return Assignment.update(assignment_id, request, db, division_id, user) -@assignmentsRouter.delete("/assignments/{model_id}") -async def delete_assignment(model_id: int, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Assignment.delete(model_id, db, user) +@assignmentsRouter.delete("/assignments/{assignment_id}/{division_id}") +async def delete_assignment(assignment_id: int, division_id: int, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.DELETE_ASSIGNMENT))): + return Assignment.delete(assignment_id, db, user) diff --git a/api/routes/divisions.py b/api/routes/divisions.py index bf73484..698bd7a 100644 --- a/api/routes/divisions.py +++ b/api/routes/divisions.py @@ -5,7 +5,7 @@ from api.db.models import UserModel, DivisionModel # unresolved reference ignored from api.dependencies import get_current_user, get_db, CheckPermission from api.validators import DivisionValidator, DivisionUpdateValidator -from api.const import Permissions +from api.const import CorePermissions divisionsRouter = APIRouter( tags=["Divisions"] @@ -19,7 +19,7 @@ async def get_divisions(db: Session = Depends(get_db)): @divisionsRouter.post("/divisions", status_code=status.HTTP_201_CREATED) async def create_division(request: DivisionValidator, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.CREATE_DIVISION, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.CREATE_DIVISION, core=True))): request.name = request.name.strip().lower() request.parent = request.parent.strip().lower() if request.parent else None Division.check_division_validity(db, request) @@ -28,7 +28,7 @@ async def create_division(request: DivisionValidator, db: Session = Depends(get_ @divisionsRouter.put("/divisions/{division_id}") async def update_division(division_id: int, request: DivisionUpdateValidator, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.UPDATE_DIVISION, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.UPDATE_DIVISION, core=True))): division = db.query(DivisionModel).filter_by(id=division_id).first() # fetch division to be edited if division: Division.check_division_validity(db, request, division_id) @@ -43,5 +43,5 @@ async def update_division(division_id: int, request: DivisionUpdateValidator, db @divisionsRouter.delete("/divisions/{division_id}") async def delete_division(division_id: int, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.DELETE_DIVISION, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.DELETE_DIVISION, core=True))): return Division.delete(division_id, db) diff --git a/api/routes/excuses.py b/api/routes/excuses.py index aea376b..acfc3ba 100644 --- a/api/routes/excuses.py +++ b/api/routes/excuses.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import FeaturePermissions from api.crud.sub_feature.excuse import Excuse from api.db.models import UserModel, AssignmentModel # unresolved reference ignored from api.dependencies import get_db, get_current_user, CheckPermission @@ -18,18 +18,18 @@ async def get_excuses(db: Session = Depends(get_db)): @excusesRouter.post("/excuses") -async def create_excuse(request: ExcuseValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Excuse.create(request, db, user) +async def create_excuse(request: ExcuseValidator, division_id: int, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_EXCUSE))): + return Excuse.create(request, db, user, "assignment", AssignmentModel) -@excusesRouter.put("/excuses/{excuse_id}") -async def update_excuse(excuse_id: int, request: ExcuseUpdateValidator, db: Session = Depends(get_db), +@excusesRouter.put("/excuses/{excuse_id}/{division_id}") +async def update_excuse(excuse_id: int, division_id: int, request: ExcuseUpdateValidator, db: Session = Depends(get_db), user: UserModel = Depends(get_current_user)): - return Excuse.update(excuse_id, db, user, **request.model_dump()) + return Excuse.update(excuse_id, request, db, division_id, user) -@excusesRouter.delete("/excuses/{model_id}") -async def delete_excuse(model_id: int, db: Session = Depends(get_db), +@excusesRouter.delete("/excuses/{model_id}/{division_id}") +async def delete_excuse(model_id: int, division_id: int, db: Session = Depends(get_db), user: UserModel = Depends(get_current_user)): return Excuse.delete(model_id, db, user) diff --git a/api/routes/feedback.py b/api/routes/feedback.py index 302d343..3073469 100644 --- a/api/routes/feedback.py +++ b/api/routes/feedback.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import FeaturePermissions from api.crud.sub_feature.feedback import Feedback from api.db.models import UserModel, SubmissionModel # unresolved reference ignored from api.dependencies import get_db, get_current_user, CheckPermission @@ -17,19 +17,20 @@ async def get_feedback(db: Session = Depends(get_db)): return Feedback.get_db_dump(db) -@feedbacksRouter.post("/feedback") -async def create_feedback(request: FeedbackValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Feedback.create(request, db, user) +@feedbacksRouter.post("/feedback/{division_id}") +async def create_feedback(request: FeedbackValidator, division_id: int, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_FEEDBACK))): + return Feedback.create(request, db, user, "submission", SubmissionModel) @feedbacksRouter.put("/feedback/{feedback_id}") -async def update_feedback(feedback_id: int, request: FeedbackUpdateValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Feedback.update(feedback_id, db, user, **request.model_dump()) +async def update_feedback(feedback_id: int, division_id: int, request: FeedbackUpdateValidator, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_FEEDBACK))): + return Feedback.update(feedback_id, request, db, division_id, user) -@feedbacksRouter.delete("/feedback/{model_id}") -async def delete_feedback(model_id: int, db: Session = Depends(get_db), +@feedbacksRouter.delete("/feedback/{feedback_id}") +async def delete_feedback(feedback_id: int, division_id: int, db: Session = Depends(get_db), user: UserModel = Depends(get_current_user)): - return Feedback.delete(model_id, db, user) + return Feedback.delete(feedback_id, db, user) diff --git a/api/routes/meetings.py b/api/routes/meetings.py index 66bebd4..7ccbf55 100644 --- a/api/routes/meetings.py +++ b/api/routes/meetings.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import FeaturePermissions from api.crud.feature.meeting import Meeting from api.db.models import UserModel # unresolved reference ignored from api.dependencies import get_db, get_current_user, CheckPermission @@ -13,24 +13,25 @@ @meetingsRouter.get("/meetings") -async def get_meetings(db: Session = Depends(get_db), _: UserModel = Depends(get_current_user)): +async def get_meetings(db: Session = Depends(get_db)): return Meeting.get_db_dump(db) -@meetingsRouter.post("/meetings") -async def post_meeting(request: MeetingValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Meeting.create(request, db, user) +@meetingsRouter.post("/meetings/{division_id}") +async def post_meeting(division_id: int, request: MeetingValidator, db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_MEETING))): + return Meeting.create(request, db, user, division_id) -@meetingsRouter.put("/meetings/{meeting_id}") -async def update_meeting(meeting_id: int, request: MeetingUpdateValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Meeting.update(meeting_id, request, db, user) +@meetingsRouter.put("/meetings/{meeting_id}/{division_id}") +async def update_meeting(meeting_id: int, request: MeetingUpdateValidator, division_id: int, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.UPDATE_MEETING))): + return Meeting.update(meeting_id, request, db, division_id, user) -@meetingsRouter.delete("/meetings/{model_id}") -async def delete_meeting(model_id: int, +@meetingsRouter.delete("/meetings/{model_id}/{division_id}") +async def delete_meeting(meeting_id: int, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Meeting.delete(model_id, db, user) + user: UserModel = Depends(CheckPermission(FeaturePermissions.DELETE_MEETING))): + return Meeting.delete(meeting_id, db, user) diff --git a/api/routes/roles.py b/api/routes/roles.py index c7f5252..66670a0 100644 --- a/api/routes/roles.py +++ b/api/routes/roles.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Body from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import CorePermissions from api.crud.core.role import Role from api.db.models import UserModel from api.dependencies import get_db, get_current_user, CheckPermission @@ -14,7 +14,7 @@ ) -@rolesRouter.get(path="/roles", dependencies=[Depends(get_current_user)]) +@rolesRouter.get(path="/roles") async def get_roles(db: Session = Depends(get_db)): return Role.get_db_dump(db) @@ -22,17 +22,17 @@ async def get_roles(db: Session = Depends(get_db)): @rolesRouter.post(path="/roles") async def create_role(request: RoleValidator, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.CREATE_ROLE, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.CREATE_ROLE, core=True))): return Role.create(db, **request.model_dump()) @rolesRouter.put(path="/roles/{role_id}") async def update_role(role_id: int, request: RoleUpdateValidator, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.UPDATE_ROLE, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.UPDATE_ROLE, core=True))): return Role.update(role_id, db, **request.model_dump()) -@rolesRouter.delete(path="/roles/{role_id}", dependencies=[Depends(get_current_user)]) +@rolesRouter.delete(path="/roles/{role_id}") async def delete_role(role_id: int, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.DELETE_ROLE, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.DELETE_ROLE, core=True))): return Role.delete(role_id, db) diff --git a/api/routes/submissions.py b/api/routes/submissions.py index 12637af..1d003de 100644 --- a/api/routes/submissions.py +++ b/api/routes/submissions.py @@ -1,10 +1,11 @@ from fastapi import Depends, APIRouter from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import FeaturePermissions from api.crud.sub_feature.submission import Submission from api.db.models import UserModel # unresolved reference ignored -from api.dependencies import get_db, get_current_user +from api.db.models.assignment_model import AssignmentModel +from api.dependencies import get_db, get_current_user, CheckPermission from api.validators import SubmissionValidator, SubmissionUpdateValidator # unresolved reference ignored submissionsRouter = APIRouter( @@ -17,19 +18,22 @@ async def get_submissions(db: Session = Depends(get_db)): return Submission.get_db_dump(db) -@submissionsRouter.post("/submissions") -async def create_submission(request: SubmissionValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Submission.create(request, db, user) +@submissionsRouter.post("/submissions/{division_id}") +async def create_submission(request: SubmissionValidator, division_id: int, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.CREATE_SUBMISSION))): + return Submission.create(request, db, user, "assignment", AssignmentModel) -@submissionsRouter.put("/submissions/{submission_id}") -async def update_submission(submission_id: int, request: SubmissionUpdateValidator, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Submission.update(submission_id, db, user, **request.model_dump()) +@submissionsRouter.put("/submissions/{submission_id}/{division_id}") +async def update_submission(submission_id: int, division_id: int, request: SubmissionUpdateValidator, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.UPDATE_SUBMISSION))): + return Submission.update(submission_id, request, db, division_id, user) -@submissionsRouter.delete("/submissions/{model_id}") -async def delete_submission(model_id: int, db: Session = Depends(get_db), - user: UserModel = Depends(get_current_user)): - return Submission.delete(model_id, db, user) +@submissionsRouter.delete("/submissions/{submission_id}/{division_id}") +async def delete_submission(submission_id: int, division_id: int, + db: Session = Depends(get_db), + user: UserModel = Depends(CheckPermission(FeaturePermissions.DELETE_SUBMISSION))): + return Submission.delete(submission_id, db, user) diff --git a/api/routes/users.py b/api/routes/users.py index 3bc4088..42943fe 100644 --- a/api/routes/users.py +++ b/api/routes/users.py @@ -2,13 +2,15 @@ from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session -from api.const import Permissions +from api.const import CorePermissions, FeaturePermissions +from api.crud.core.role import Role from api.crud.core.user import User -from api.crud.core.userroledivision import UserRoleDivision -from api.db.models.user_model import UserModel +from api.crud.core.userroledivisionpermission import UserRoleDivisionPermission +from api.db.models import UserModel, RoleModel, UserRoleModel from api.dependencies import get_db, CheckPermission -from api.utils import create_token -from api.validators import UserValidator, UsernameValidator, HTTPErrorValidator +from api.utils import create_token, decode_permissions +from api.validators import UserValidator, UsernameValidator, FeaturePermissionValidator, CorePermissionValidator, \ + HTTPErrorValidator, AssignToDivisionValidator usersRouter: APIRouter = APIRouter( prefix="/users", @@ -54,20 +56,75 @@ async def validate_username(request: UsernameValidator, @usersRouter.delete("/delete/{user_id}") async def delete_user(user_id: int, db: Session = Depends(get_db), - _: UserModel = Depends(CheckPermission(Permissions.DELETE_USER, core=True))): + _: UserModel = Depends(CheckPermission(CorePermissions.DELETE_USER, core=True))): return User.delete(user_id, db) -@usersRouter.get("/assign_user_role_division") -async def assign_user_role_division(db: Session = Depends(get_db)): - return UserRoleDivision.get_db_dump(db) +@usersRouter.get("/users_assigned_to_divisions") +async def get_users_with_divisions_permissions(db: Session = Depends(get_db)): + return UserRoleDivisionPermission.get_db_dump(db) -@usersRouter.post("/assign_user_role_division") -async def assign_user_role_division(user_id: int, role_id: int, division_id: int, db: Session = Depends(get_db)): - return UserRoleDivision.create(db, user_id, role_id, division_id) +@usersRouter.get("/get_user_in_division/{division_id}") +async def get_users_in_division(division_id: int, db: Session = Depends(get_db)): + records = UserRoleDivisionPermission.get_db_all(db, "division_id", division_id) + return [record.user for record in records] -@usersRouter.delete("/get_user_role_division/{user_id}/{role_id}/{division_id}") -async def delete_user_role_division(user_id: int, role_id: int, division_id: int, db: Session = Depends(get_db)): - return UserRoleDivision.delete(db, user_id, role_id, division_id) +@usersRouter.post("/assign_user_division_permissions") +async def assign_user_division_permissions(user_id: int, division_id: int, request: FeaturePermissionValidator, + db: Session = Depends(get_db)): + return UserRoleDivisionPermission.create(db, user_id, division_id, role_id=None, **request.model_dump()) + + +@usersRouter.delete("/delete_user_special_permissions/{user_id}/{division_id}") +async def delete_user_division_permissions(user_id: int, division_id: int, db: Session = Depends(get_db)): + return UserRoleDivisionPermission.delete(db, user_id, division_id, role_id=None) + + +@usersRouter.get("/get_user_permissions/{user_id}") +async def get_user_permissions(user_id: int, db: Session = Depends(get_db)): + return decode_permissions(User.get_db_first(db, "id", user_id).permissions, list(CorePermissions)) + + +@usersRouter.post("/assign_user_permissions") +async def assign_user_permissions(user_id: int, request: CorePermissionValidator, db: Session = Depends(get_db)): + return User.edit_user_permissions(db, user_id, **request.model_dump()) + + +@usersRouter.post("/assign_user_role") +async def assign_user_role(user_id: int, role_id: int, db: Session = Depends(get_db)): + role = Role.get_db_first(db, "id", role_id) + if role: + try: + UserRoleDivisionPermission.create(db, user_id, role.division_id, role_id, + **decode_permissions(0, list(FeaturePermissions))) + except HTTPException: + HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This role does not exist in any division") + return User.assign_user_role(db, user_id, role_id) + + +@usersRouter.delete("/delete_user_role/{user_id}/{role_id}") +async def delete_user_role(user_id: int, division_id: int, role_id: int, db: Session = Depends(get_db)): + return UserRoleDivisionPermission.delete(db, user_id=user_id, role_id=role_id, + division_id=division_id) + + +@usersRouter.get("/get_user_roles/{user_id}") +async def get_user_roles(user_id: int, db: Session = Depends(get_db)): + user_roles = ( + db.query(UserRoleDivisionPermission) + .filter_by(user_id=user_id) + .all() + ) + return [role for role in user_roles] + + +@usersRouter.post("/assign_user_to_division/{user_id}/{division_id}") +async def assign_user_to_division(request: AssignToDivisionValidator, user_id: int, division_id: int, + db: Session = Depends(get_db)): + role = Role.get_db_first(db, "id", request.role_id) + if not role: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + return UserRoleDivisionPermission.create(db, user_id, division_id, request.role_id, + **decode_permissions(0, list(FeaturePermissions))) diff --git a/api/utils.py b/api/utils.py index 77fdf52..7b225e6 100644 --- a/api/utils.py +++ b/api/utils.py @@ -7,13 +7,15 @@ from fastapi import HTTPException, status from jose import jwt, JWTError -from api.const import Permissions - SECRET_KEY = "1e0788a28e2e503315a3a894d353abaa36ace075faae8650f714d7c880f01da5" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 +def decode_permissions(permissions: int, permissions_list: list) -> dict: + return {permission.name: bool(permissions & permission.value) for permission in permissions_list} + + def create_token(payload: dict, duration: int = ACCESS_TOKEN_EXPIRE_MINUTES): payload["exp"] = datetime.datetime.now() + timedelta(minutes=duration) return jwt.encode(payload, SECRET_KEY, ALGORITHM) @@ -52,4 +54,4 @@ def email_sender_for_pass_reset(email_sender, email_token_password, email_receiv with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as smtp: smtp.login(email_sender, email_token_password) - smtp.sendmail(email_sender, email_receiver, em.as_string()) \ No newline at end of file + smtp.sendmail(email_sender, email_receiver, em.as_string()) diff --git a/api/validators/announcement_validator.py b/api/validators/announcement_validator.py index 8126a95..7cd6177 100644 --- a/api/validators/announcement_validator.py +++ b/api/validators/announcement_validator.py @@ -10,7 +10,6 @@ class AnnouncementBaseValidator(BaseModel): description: str = Field(min_length=2, strip_whitespace=True) date: datetime | None category: AnnouncementsCategory - division: str = Field(min_length=2, strip_whitespace=True, to_lower=True, strict=True) @field_validator("date") def validate_date_future(cls, v: datetime) -> datetime: @@ -27,7 +26,6 @@ class Config: "description": "this is really an announcement", "date": "2025-04-24T22:01:32.904Z", "category": "internship", - "division": "RAS", } } @@ -40,6 +38,5 @@ class Config: "description": "update the announcement", "date": "2025-04-24T22:01:32.904Z", "category": "event", - "division": "RAS", } } diff --git a/api/validators/assignment_validator.py b/api/validators/assignment_validator.py index aa7fb28..688c200 100644 --- a/api/validators/assignment_validator.py +++ b/api/validators/assignment_validator.py @@ -8,7 +8,6 @@ class AssignmentBaseValidator(BaseModel): description: str = Field(min_length=2, strip_whitespace=True) deadline: datetime weight: int = Field(gt=0) - division: str = Field(min_length=2, strip_whitespace=True, to_lower=True, strict=True) @field_validator("deadline") def validate_date_future(cls, v: datetime) -> datetime: @@ -25,7 +24,6 @@ class Config: "description": "this is really an assignment", "deadline": "2025-04-24T22:01:32.904Z", "weight": "20", - "division": "CS", } } @@ -38,6 +36,5 @@ class Config: "description": "update assignment", "deadline": "2025-04-24T22:01:32.904Z", "weight": "30", - "division": "CS", } } diff --git a/api/validators/assigntodivision_validator.py b/api/validators/assigntodivision_validator.py new file mode 100644 index 0000000..3474478 --- /dev/null +++ b/api/validators/assigntodivision_validator.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class AssignToDivisionValidator(BaseModel): + role_id: Optional[int] = Field(ge=0, nullable=True) + + class Config: + json_schema_extra = { + "example": { + "role_id": 0 + } + } diff --git a/api/validators/meeting_validator.py b/api/validators/meeting_validator.py index 1575550..29e6453 100644 --- a/api/validators/meeting_validator.py +++ b/api/validators/meeting_validator.py @@ -10,7 +10,6 @@ class MeetingBaseValidator(BaseModel): location_text: str | None = None location_lat: float | None = None location_long: float | None = None - division: str = Field(min_length=2, strip_whitespace=True, to_lower=True, strict=True) @field_validator("date") def validate_date_future(cls, v: datetime) -> datetime: @@ -37,7 +36,6 @@ class Config: "location_text": "our lovely college", "location_long": "30.586388", "location_lat": "31.482434", - "division": "CS", } } @@ -52,6 +50,5 @@ class Config: "location_text": "our lovely college", "location_long": "30.586388", "location_lat": "31.482434", - "division": "CS", } } diff --git a/api/validators/permission_validator.py b/api/validators/permission_validator.py index 29ae878..d86895a 100644 --- a/api/validators/permission_validator.py +++ b/api/validators/permission_validator.py @@ -2,6 +2,10 @@ class PermissionValidator(BaseModel): + pass + + +class CorePermissionValidator(PermissionValidator): UPDATE_USER: bool DELETE_USER: bool CREATE_DIVISION: bool @@ -10,6 +14,23 @@ class PermissionValidator(BaseModel): CREATE_ROLE: bool UPDATE_ROLE: bool DELETE_ROLE: bool + + class Config: + json_schema_extra = { + "example": { + "UPDATE_USER": True, + "DELETE_USER": True, + "CREATE_DIVISION": True, + "UPDATE_DIVISION": True, + "DELETE_DIVISION": True, + "CREATE_ROLE": True, + "UPDATE_ROLE": True, + "DELETE_ROLE": True + } + } + + +class FeaturePermissionValidator(PermissionValidator): CREATE_ASSIGNMENT: bool UPDATE_ASSIGNMENT: bool DELETE_ASSIGNMENT: bool @@ -32,31 +53,23 @@ class PermissionValidator(BaseModel): class Config: json_schema_extra = { "example": { - "UPDATE_USER": True, - "DELETE_USER": True, - "CREATE_DIVISION": True, - "UPDATE_DIVISION": True, - "DELETE_DIVISION": True, - "CREATE_ROLE": True, - "UPDATE_ROLE": True, - "DELETE_ROLE": True, - "CREATE_ASSIGNMENT": True, - "UPDATE_ASSIGNMENT": True, - "DELETE_ASSIGNMENT": True, - "CREATE_ANNOUNCEMENT": True, - "UPDATE_ANNOUNCEMENT": True, - "DELETE_ANNOUNCEMENT": True, - "CREATE_MEETING": True, - "UPDATE_MEETING": True, - "DELETE_MEETING": True, - "CREATE_SUBMISSION": True, - "UPDATE_SUBMISSION": True, - "DELETE_SUBMISSION": True, - "CREATE_EXCUSE": True, - "UPDATE_EXCUSE": True, - "DELETE_EXCUSE": True, - "CREATE_FEEDBACK": True, - "UPDATE_FEEDBACK": True, - "DELETE_FEEDBACK": True + "CREATE_ASSIGNMENT": True, + "UPDATE_ASSIGNMENT": True, + "DELETE_ASSIGNMENT": True, + "CREATE_ANNOUNCEMENT": True, + "UPDATE_ANNOUNCEMENT": True, + "DELETE_ANNOUNCEMENT": True, + "CREATE_MEETING": True, + "UPDATE_MEETING": True, + "DELETE_MEETING": True, + "CREATE_SUBMISSION": True, + "UPDATE_SUBMISSION": True, + "DELETE_SUBMISSION": True, + "CREATE_EXCUSE": True, + "UPDATE_EXCUSE": True, + "DELETE_EXCUSE": True, + "CREATE_FEEDBACK": True, + "UPDATE_FEEDBACK": True, + "DELETE_FEEDBACK": True + } } - } diff --git a/api/validators/role_validator.py b/api/validators/role_validator.py index 920e5a9..f05a4a8 100644 --- a/api/validators/role_validator.py +++ b/api/validators/role_validator.py @@ -1,11 +1,14 @@ +from typing import Optional + from pydantic import BaseModel, Field -from api.validators.permission_validator import PermissionValidator +from api.validators.permission_validator import FeaturePermissionValidator class RoleBaseValidator(BaseModel): name: str = Field(min_length=2, strip_whitespace=True) - permissions: PermissionValidator + division_id: Optional[int] = Field(ge=0, nullable=True) + permissions: FeaturePermissionValidator class RoleValidator(RoleBaseValidator): @@ -13,8 +16,9 @@ class Config: json_schema_extra = { "example": { "name": "chairman", + "division_id": 1, "permissions": - PermissionValidator.Config.json_schema_extra["example"] + FeaturePermissionValidator.Config.json_schema_extra["example"] } } @@ -24,7 +28,8 @@ class Config: json_schema_extra = { "example": { "name": "member", + "division_id": 1, "permissions": - PermissionValidator.Config.json_schema_extra["example"] + FeaturePermissionValidator.Config.json_schema_extra["example"] } } diff --git a/main.py b/main.py index 5427b6f..16dfb85 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,7 @@ -from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware - +from api.routes.app import app from api.routes import divisions, announcements, users, meetings, assignments, excuses, feedback, submissions, roles -app: FastAPI = FastAPI( - title="Student Activity Platform API", - version="0.0.1", -) - origins = [ "*" ] diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..04d03e3 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.11.2