diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d32377..4eff473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,80 @@ +## v0.12.1 (2023-10-23) + +### Fix + +* fix: Changed policy_holder_type for new policies + +The policy_holder_type was set to "member", updated the method to use type of policy_holder via get_document_type() method ([`6f595ff`](https://github.com/unipoll/api/commit/6f595ffb31be2b257ce477a91348499317e303e5)) + +### Refactor + +* refactor: Changed argument type in ErrorWhileRemovingMember + +Changed user: Account to member: Member ([`4301c36`](https://github.com/unipoll/api/commit/4301c36e258d206ff5ae94a2f69b87cc5636a4fa)) + +### Unknown + +* Merge pull request #78 from unipoll/members + +Patch Policies ([`a5100c0`](https://github.com/unipoll/api/commit/a5100c0b39265c63744b39fcb3e37f95c7ae9941)) + + +## v0.12.0 (2023-10-23) + +### Feature + +* feat: Added Member document ([`8199204`](https://github.com/unipoll/api/commit/8199204906b16fe2ca4170b68c2ad004ad571d62)) + +### Fix + +* fix: Removed PathOperations import ([`1cbfc93`](https://github.com/unipoll/api/commit/1cbfc9382cf38609a6a7572ca84db150cf7a0b0c)) + +* fix: Updated group request to get policies + +Updated route get_group_policies to find member using dependency and use it for get_policies action ([`c16a981`](https://github.com/unipoll/api/commit/c16a9812a3f54be8f3f3387ad38bbd71d4b88098)) + +### Refactor + +* refactor: Fixed mypy issues ([`d6ec5b8`](https://github.com/unipoll/api/commit/d6ec5b83b7a0e14f31b28995b94e13f4b9b4f785)) + +* refactor: Added get_document_type classmethod + +Added classmethod to Beanie Document get_document_type which returns Document type as a string ([`cfeebf7`](https://github.com/unipoll/api/commit/cfeebf7efacac5e46699baa1948c625e9fd23804)) + +* refactor: Updated to accommodate member update ([`73e4017`](https://github.com/unipoll/api/commit/73e4017d3ce140bceba55ee43e779b657cd68fcd)) + +* refactor: Deleted obsolete path_operations file + +This file included functions to read actions for building permissions and check permissions based on operation_id, both functions are not obsolete ([`095c103`](https://github.com/unipoll/api/commit/095c103f2aed2f9a66dfdcdf3de861d931185dc1)) + +### Style + +* style: flake8 ([`b08c103`](https://github.com/unipoll/api/commit/b08c103344bbfc86c77544c2c4ba62f76d9a0124)) + +* style: Updated comments + +Changed account to member ([`d29213a`](https://github.com/unipoll/api/commit/d29213a82dc473d081811e382982099cb4fb41a7)) + +### Test + +* test: Updated tests to use new member document ([`8f7f352`](https://github.com/unipoll/api/commit/8f7f35244d09fe7159bf2f50c8980d32b1805e98)) + +* test: Updated workspace tests due to member update ([`af1dd25`](https://github.com/unipoll/api/commit/af1dd25c9d1d74a8d32e7158361a0d557dfdc949)) + +### Unknown + +* Merge pull request #77 from unipoll/members + +Updated Members ([`102afff`](https://github.com/unipoll/api/commit/102afff96b4df76433c4d497540f168084359040)) + +* Update README.md + +Fixed link to developer wiki ([`a521dac`](https://github.com/unipoll/api/commit/a521dac3b95e70bc2a75c63eb5b961d89fb13df6)) + + ## v0.11.3 (2023-10-17) ### Ci diff --git a/pyproject.toml b/pyproject.toml index 3729d85..06e8b22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "unipoll-api" -version = "0.11.3" +version = "0.12.1" description = "Unipoll API" authors = [{email = "help@unipoll.cc"}, {name = "University Polling"}] readme = "README.md" diff --git a/src/unipoll_api/__version__.py b/src/unipoll_api/__version__.py index 1b637f1..a2787f6 100644 --- a/src/unipoll_api/__version__.py +++ b/src/unipoll_api/__version__.py @@ -1 +1 @@ -version = "0.11.3" +version = "0.12.1" diff --git a/src/unipoll_api/actions/group.py b/src/unipoll_api/actions/group.py index 867639c..ef95b42 100644 --- a/src/unipoll_api/actions/group.py +++ b/src/unipoll_api/actions/group.py @@ -8,6 +8,7 @@ from unipoll_api.schemas import GroupSchemas, WorkspaceSchemas from unipoll_api.exceptions import GroupExceptions, WorkspaceExceptions, ResourceExceptions from unipoll_api.utils import Permissions +from unipoll_api.dependencies import get_member # Get list of groups @@ -22,7 +23,7 @@ async def get_groups(workspace: Workspace | None = None, if workspace: search_filter['workspace._id'] = workspace.id # type: ignore if account: - search_filter['members._id'] = account.id # type: ignore + search_filter['members.account._id'] = account.id # type: ignore search_result = await Group.find(search_filter, fetch_links=True).to_list() # TODO: Rewrite to iterate over list of workspaces @@ -47,6 +48,8 @@ async def create_group(workspace: Workspace, await Permissions.check_permissions(workspace, "add_groups", check_permissions) account = AccountManager.active_user.get() + member = await get_member(account, workspace) + # Check if group name is unique group: Group # For type hinting, until Link type is supported for group in workspace.groups: # type: ignore @@ -63,7 +66,7 @@ async def create_group(workspace: Workspace, raise GroupExceptions.ErrorWhileCreating(new_group) # Add the account to group member list - await new_group.add_member(account, Permissions.GROUP_ALL_PERMISSIONS) + await new_group.add_member(member, Permissions.GROUP_ALL_PERMISSIONS) # Create a policy for the new group await workspace.add_policy(new_group, Permissions.WORKSPACE_BASIC_PERMISSIONS, False) diff --git a/src/unipoll_api/actions/members.py b/src/unipoll_api/actions/members.py index 3363fb3..f616ff2 100644 --- a/src/unipoll_api/actions/members.py +++ b/src/unipoll_api/actions/members.py @@ -1,20 +1,24 @@ from beanie import WriteRules from beanie.operators import In -from unipoll_api.documents import Account, Group, ResourceID, Workspace +from unipoll_api.documents import Account, Group, ResourceID, Workspace, Member from unipoll_api.utils import Permissions from unipoll_api.schemas import MemberSchemas # from unipoll_api import AccountManager from unipoll_api.exceptions import ResourceExceptions +from unipoll_api.dependencies import get_member async def get_members(resource: Workspace | Group, check_permissions: bool = True) -> MemberSchemas.MemberList: # Check if the user has permission to add members await Permissions.check_permissions(resource, "get_members", check_permissions) - def build_member_scheme(member: Account) -> MemberSchemas.Member: - member_data = member.model_dump(include={'id', 'first_name', 'last_name', 'email'}) - member_scheme = MemberSchemas.Member(**member_data) - return member_scheme + def build_member_scheme(member: Member) -> MemberSchemas.Member: + account: Account = member.account # type: ignore + return MemberSchemas.Member(id=member.id, + account_id=account.id, + first_name=account.first_name, + last_name=account.last_name, + email=account.email) member_list = [build_member_scheme(member) for member in resource.members] # type: ignore # Return the list of members @@ -36,29 +40,46 @@ async def add_members(resource: Workspace | Group, account_list = await Account.find(In(Account.id, accounts)).to_list() # Add the accounts to the group member list with basic permissions + new_members = [] + for account in account_list: - default_permissions = eval("Permissions." + resource.resource_type.upper() + "_BASIC_PERMISSIONS") - await resource.add_member(account, default_permissions, save=False) + default_permissions = eval("Permissions." + resource.get_document_type().upper() + "_BASIC_PERMISSIONS") + if resource.get_document_type() == "Group": + member = await get_member(account, resource.workspace) # type: ignore + new_member = await resource.add_member(member, default_permissions, save=False) + new_members.append(new_member) + elif resource.get_document_type() == "Workspace": + new_member = await resource.add_member(account, default_permissions, save=False) + new_members.append(new_member) await resource.save(link_rule=WriteRules.WRITE) # type: ignore + member_list = [] + for new_member in new_members: + account: Account = new_member.account # type: ignore + member_list.append(MemberSchemas.Member(id=new_member.id, + account_id=account.id, + first_name=account.first_name, + last_name=account.last_name, + email=account.email)) + # Return the list of members added to the group - return MemberSchemas.MemberList(members=[MemberSchemas.Member(**account.model_dump()) for account in account_list]) + return MemberSchemas.MemberList(members=member_list) # Remove a member from a workspace async def remove_member(resource: Workspace | Group, - account: Account, + member: Member, permission_check: bool = True) -> MemberSchemas.MemberList: # Check if the user has permission to add members await Permissions.check_permissions(resource, "remove_members", permission_check) # Check if the account is a member of the workspace - if account.id not in [ResourceID(member.id) for member in resource.members]: # type: ignore - raise ResourceExceptions.UserNotMember(resource, account) + if member.id not in [ResourceID(member.id) for member in resource.members]: # type: ignore + raise ResourceExceptions.ResourceNotFound("Member", member.id) # Remove the account from the workspace/group - if await resource.remove_member(account): + if await resource.remove_member(member): # Return the list of members added to the group member_list = [MemberSchemas.Member(**account.model_dump()) for account in resource.members] # type: ignore return MemberSchemas.MemberList(members=member_list) - raise ResourceExceptions.ErrorWhileRemovingMember(resource, account) + raise ResourceExceptions.ErrorWhileRemovingMember(resource, member) diff --git a/src/unipoll_api/actions/policy.py b/src/unipoll_api/actions/policy.py index de92dd5..d849574 100644 --- a/src/unipoll_api/actions/policy.py +++ b/src/unipoll_api/actions/policy.py @@ -1,9 +1,10 @@ from unipoll_api import AccountManager -from unipoll_api.documents import Account, Workspace, Group, Policy, Resource -from unipoll_api.schemas import MemberSchemas, PolicySchemas +from unipoll_api.documents import Account, Workspace, Group, Policy, Resource, Member +from unipoll_api.schemas import MemberSchemas, PolicySchemas, GroupSchemas from unipoll_api.exceptions import ResourceExceptions from unipoll_api.utils import Permissions from unipoll_api.utils.permissions import check_permissions +from unipoll_api.dependencies import get_member # Helper function to get policies from a resource @@ -14,15 +15,17 @@ async def get_policies_from_resource(resource: Resource) -> list[Policy]: await check_permissions(resource, "get_policies") return resource.policies # type: ignore except ResourceExceptions.UserNotAuthorized: + print("User not authorized") account = AccountManager.active_user.get() + member = await get_member(account, resource) for policy in resource.policies: - if policy.policy_holder.ref.id == account.id: # type: ignore + if policy.policy_holder.ref.id == member.id: # type: ignore policies.append(policy) # type: ignore return policies # Get all policies of a workspace -async def get_policies(policy_holder: Account | Group | None = None, +async def get_policies(policy_holder: Member | Group | None = None, resource: Resource | None = None) -> PolicySchemas.PolicyList: policy_list = [] policy: Policy @@ -58,16 +61,28 @@ async def get_policy(policy: Policy, permission_check: bool = True) -> PolicySch # Get the policy holder policy_holder = await policy.get_policy_holder() - member = MemberSchemas.Member(**policy_holder.model_dump()) + member, group = None, None + if policy_holder.get_document_type() == "Member": + await policy_holder.fetch_link("account") + account: Account = policy_holder.account # type: ignore + member = MemberSchemas.Member(id=policy_holder.id, + account_id=account.id, + email=account.email, + first_name=account.first_name, + last_name=account.last_name) + elif policy_holder.get_document_type() == "Group": + group = GroupSchemas.Group(id=policy_holder.id, + name=policy_holder.name, + description=policy_holder.description) # Get the permissions based on the resource type and convert it to a list of strings - permission_type = Permissions.PermissionTypes[parent_resource.resource_type] + permission_type = Permissions.PermissionTypes[parent_resource.get_document_type()] permissions = permission_type(policy.permissions).name.split('|') # type: ignore # Return the policy return PolicySchemas.PolicyShort(id=policy.id, policy_holder_type=policy.policy_holder_type, - policy_holder=member.model_dump(exclude_unset=True), + policy_holder=member or group, permissions=permissions) @@ -79,7 +94,7 @@ async def update_policy(policy: Policy, # Check if the user has the required permissions to update the policy await Permissions.check_permissions(parent_resource, "update_policies", check_permissions) - permission_type = Permissions.PermissionTypes[parent_resource.resource_type] + permission_type = Permissions.PermissionTypes[parent_resource.get_document_type()] # Calculate the new permission value from request new_permission_value = 0 @@ -93,7 +108,19 @@ async def update_policy(policy: Policy, await Policy.save(policy) policy_holder = await policy.get_policy_holder() - - return PolicySchemas.PolicyOutput( - permissions=permission_type(policy.permissions).name.split('|'), # type: ignore - policy_holder=policy_holder.model_dump()) + member, group = None, None + if policy_holder.get_document_type() == "Member": + await policy_holder.fetch_link("account") + account: Account = policy_holder.account # type: ignore + member = MemberSchemas.Member(id=policy_holder.id, + account_id=account.id, + email=account.email, + first_name=account.first_name, + last_name=account.last_name) + elif policy_holder.get_document_type() == "Group": + group = GroupSchemas.Group(id=policy_holder.id, + name=policy_holder.name, + description=policy_holder.description) + + return PolicySchemas.PolicyOutput(permissions=permission_type(policy.permissions).name.split('|'), # type: ignore + policy_holder=member or group) diff --git a/src/unipoll_api/actions/workspace.py b/src/unipoll_api/actions/workspace.py index 11a997c..9949302 100644 --- a/src/unipoll_api/actions/workspace.py +++ b/src/unipoll_api/actions/workspace.py @@ -1,23 +1,25 @@ from bson import DBRef from unipoll_api import AccountManager from unipoll_api import actions -from unipoll_api.documents import Workspace, Account, Policy +from unipoll_api.documents import Workspace, Account, Policy, Member from unipoll_api.utils import Permissions from unipoll_api.schemas import WorkspaceSchemas from unipoll_api.exceptions import WorkspaceExceptions +# from unipoll_api.dependencies import get_member # Get a list of workspaces where the account is a owner/member async def get_workspaces(account: Account | None = None) -> WorkspaceSchemas.WorkspaceList: - account = AccountManager.active_user.get() + account = AccountManager.active_user.get() if not account else account workspace_list = [] - search_result = await Workspace.find(Workspace.members.id == account.id).to_list() # type: ignore + members = await Member.find(Member.account.id == account.id, fetch_links=True).to_list() + workspaces = [member.workspace for member in members] # Create a workspace list for output schema using the search results - for workspace in search_result: + for workspace in workspaces: workspace_list.append(WorkspaceSchemas.WorkspaceShort( - **workspace.model_dump(exclude={'members', 'groups', 'permissions'}))) + **workspace.model_dump(exclude={'groups', 'permissions'}))) return WorkspaceSchemas.WorkspaceList(workspaces=workspace_list) @@ -71,14 +73,14 @@ async def update_workspace(workspace: Workspace, await Permissions.check_permissions(workspace, "update_workspace", check_permissions) save_changes = False - # Check if user suplied a name + # Check if user supplied a name if input_data.name and input_data.name != workspace.name: # Check if workspace name is unique if await Workspace.find_one({"name": input_data.name}) and workspace.name != input_data.name: raise WorkspaceExceptions.NonUniqueName(input_data.name) workspace.name = input_data.name # Update the name save_changes = True - # Check if user suplied a description + # Check if user supplied a description if input_data.description and input_data.description != workspace.description: workspace.description = input_data.description # Update the description save_changes = True @@ -86,7 +88,7 @@ async def update_workspace(workspace: Workspace, if save_changes: await Workspace.save(workspace) # Return the updated workspace - return WorkspaceSchemas.Workspace(**workspace.model_dump()) + return WorkspaceSchemas.Workspace(**workspace.model_dump(include={'id', 'name', 'description'})) # Delete a workspace diff --git a/src/unipoll_api/dependencies.py b/src/unipoll_api/dependencies.py index ab42993..8707b36 100644 --- a/src/unipoll_api/dependencies.py +++ b/src/unipoll_api/dependencies.py @@ -1,8 +1,9 @@ from typing import Annotated from functools import wraps +# from bson import DBRef from fastapi import Cookie, Depends, Query, HTTPException, WebSocket from unipoll_api.account_manager import active_user, get_current_active_user -from unipoll_api.documents import ResourceID, Workspace, Group, Account, Poll, Policy +from unipoll_api.documents import ResourceID, Workspace, Group, Account, Poll, Policy, Member from unipoll_api import exceptions as Exceptions @@ -29,6 +30,17 @@ async def get_account(account_id: ResourceID) -> Account: return account +async def get_member(account: Account, resource: Workspace | Group) -> Member: + """ + Returns a member with the given id. + """ + + for member in resource.members: + if member.account.id == account.id: # type: ignore + return member # type: ignore + raise Exceptions.ResourceExceptions.ResourceNotFound("member", account.id) + + async def websocket_auth(websocket: WebSocket, session: Annotated[str | None, Cookie()] = None, token: Annotated[str | None, Query()] = None) -> dict: diff --git a/src/unipoll_api/documents.py b/src/unipoll_api/documents.py index 90b7a1c..66cfacc 100644 --- a/src/unipoll_api/documents.py +++ b/src/unipoll_api/documents.py @@ -1,13 +1,21 @@ # from typing import ForwardRef, NewType, TypeAlias, Optional from typing import Literal from bson import DBRef -from beanie import BackLink, Document, WriteRules, after_event, Insert, Link, PydanticObjectId # BackLink +from beanie import Document as BeanieDocument +from beanie import BackLink, WriteRules, after_event, Insert, Link, PydanticObjectId # BackLink from fastapi_users_db_beanie import BeanieBaseUser from pydantic import Field from unipoll_api.utils import colored_dbg as Debug from unipoll_api.utils.token_db import BeanieBaseAccessToken +# Document +class Document(BeanieDocument): + @classmethod + def get_document_type(cls) -> str: + return cls._document_settings.name # type: ignore + + # Create a link to the Document model async def create_link(document: Document) -> Link: ref = DBRef(collection=document._document_settings.name, # type: ignore @@ -32,7 +40,6 @@ class AccessToken(BeanieBaseAccessToken, Document): # type: ignore class Resource(Document): id: ResourceID = Field(default_factory=ResourceID, alias="_id") - resource_type: Literal["workspace", "group", "poll"] name: str = Field( title="Name", description="Name of the resource", min_length=3, max_length=50) description: str = Field(default="", title="Description", max_length=1000) @@ -40,11 +47,11 @@ class Resource(Document): @after_event(Insert) def create_group(self) -> None: - Debug.info(f'New {self.resource_type} "{self.id}" has been created') + Debug.info(f'New {self.get_document_type()} "{self.id}" has been created') - async def add_policy(self, member: "Group | Account", permissions, save: bool = True) -> None: - new_policy = Policy(policy_holder_type='account', - policy_holder=(await create_link(member)), + async def add_policy(self, policy_holder: "Group | Member", permissions, save: bool = True) -> "Policy": + new_policy = Policy(policy_holder_type=policy_holder.get_document_type(), # type: ignore + policy_holder=(await create_link(policy_holder)), permissions=permissions, parent_resource=(await create_link(self))) # type: ignore @@ -52,6 +59,7 @@ async def add_policy(self, member: "Group | Account", permissions, save: bool = self.policies.append(new_policy) # type: ignore if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore + return new_policy async def remove_policy(self, policy: "Policy", save: bool = True) -> None: for i, p in enumerate(self.policies): @@ -60,9 +68,9 @@ async def remove_policy(self, policy: "Policy", save: bool = True) -> None: if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore - async def remove_member_policy(self, member: "Group | Account", save: bool = True) -> None: + async def remove_policy_by_holder(self, policy_holder: "Group | Member", save: bool = True) -> None: for policy in self.policies: - if policy.policy_holder.ref.id == member.id: # type: ignore + if policy.policy_holder.ref.id == policy_holder.id: # type: ignore self.policies.remove(policy) if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore @@ -83,37 +91,39 @@ class Account(BeanieBaseUser, Document): # type: ignore class Workspace(Resource): - resource_type: Literal["workspace"] = "workspace" - members: list[Link["Account"]] = [] + members: list[Link["Member"]] = [] groups: list[Link["Group"]] = [] polls: list[Link["Poll"]] = [] - async def add_member(self, account: "Account", permissions, save: bool = True) -> "Account": - # Add the account to the group - self.members.append(account) # type: ignore - # Create a policy for the new member - await self.add_policy(account, permissions, save=False) # type: ignore + async def add_member(self, account: "Account", permissions, save: bool = True) -> "Member": + new_member = await Member(account=account, resource=(await create_link(self))).create() # type: ignore + new_policy = await self.add_policy(new_member, permissions, save=False) # type: ignore + new_member.policies.append(new_policy) # type: ignore + + self.members.append(new_member) # type: ignore + if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore - return account + return new_member - async def remove_member(self, account, save: bool = True) -> bool: - # Remove the account from the group - for i, member in enumerate(self.members): - if account.id == member.id: # type: ignore + async def remove_member(self, member_to_delete: "Member", save: bool = True) -> bool: + # Remove the account from the workspace + for member in self.members: + if member.id == member_to_delete.id: # type: ignore self.members.remove(member) + await member.delete() # type: ignore # type: ignore - Debug.info(f"Removed member {member.id} from {self.resource_type} {self.id}") # type: ignore + Debug.info(f"Removed member {member.id} from {self.get_document_type()} {self.id}") # type: ignore break # Remove the policy from the workspace - await self.remove_member_policy(account, save=False) # type: ignore + await self.remove_policy_by_holder(member_to_delete, save=False) # type: ignore # Remove the member from all groups in the workspace group: Group for group in self.groups: # type: ignore - await group.remove_member(account, save=False) - await group.remove_member_policy(account, save=False) + await group.remove_member(member_to_delete, save=False) + await group.remove_policy_by_holder(member_to_delete, save=False) await Group.save(group, link_rule=WriteRules.WRITE) if save: @@ -122,37 +132,36 @@ async def remove_member(self, account, save: bool = True) -> bool: class Group(Resource): - resource_type: Literal["group"] = "group" workspace: BackLink[Workspace] = Field(original_field="groups") - members: list[Link["Account"]] = [] + members: list[Link["Member"]] = [] groups: list[Link["Group"]] = [] - async def add_member(self, account: "Account", permissions, save: bool = True) -> "Account": - if account not in self.workspace.members: # type: ignore + async def add_member(self, member: "Member", permissions, save: bool = True) -> "Member": + if member.workspace.id != self.workspace.id: # type: ignore from unipoll_api.exceptions import WorkspaceExceptions raise WorkspaceExceptions.UserNotMember( - self.workspace, account) # type: ignore + self.workspace, member) # type: ignore - # Add the account to the group - self.members.append(account) # type: ignore + # Add the member to the group's list of members + self.members.append(member) # type: ignore # Create a policy for the new member - await self.add_policy(account, permissions, save=False) # type: ignore + await self.add_policy(member, permissions, save=False) # type: ignore if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore - return account + return member - async def remove_member(self, account, save: bool = True) -> bool: + async def remove_member(self, member: "Member", save: bool = True) -> bool: # Remove the account from the group - for i, member in enumerate(self.members): - if account.id == member.id: # type: ignore - self.members.remove(member) + for _member in self.members: + if _member.id == member.id: # type: ignore + self.members.remove(_member) # type: ignore Debug.info( - f"Removed member {member.id} from {self.resource_type} {self.id}") # type: ignore + f"Removed member {member.id} from {self.get_document_type()} {self.id}") # type: ignore break # Remove the policy from the group - await self.remove_member_policy(account, save=False) # type: ignore + await self.remove_policy_by_holder(member, save=False) # type: ignore if save: await self.save(link_rule=WriteRules.WRITE) # type: ignore @@ -162,7 +171,6 @@ async def remove_member(self, account, save: bool = True) -> bool: class Poll(Resource): id: ResourceID = Field(default_factory=ResourceID, alias="_id") workspace: Link[Workspace] - resource_type: Literal["poll"] = "poll" public: bool published: bool questions: list @@ -172,8 +180,8 @@ class Poll(Resource): class Policy(Document): id: ResourceID = Field(default_factory=ResourceID, alias="_id") parent_resource: Link[Workspace] | Link[Group] | Link[Poll] - policy_holder_type: Literal["account", "group"] - policy_holder: Link["Group"] | Link["Account"] + policy_holder_type: Literal["Member", "Group"] + policy_holder: Link["Group"] | Link["Member"] permissions: int async def get_parent_resource(self, fetch_links: bool = False) -> Workspace | Group | Poll: @@ -186,11 +194,19 @@ async def get_parent_resource(self, fetch_links: bool = False) -> Workspace | Gr self.parent_resource.ref.id) return parent - async def get_policy_holder(self, fetch_links: bool = False) -> Group | Account: + async def get_policy_holder(self, fetch_links: bool = False) -> "Group | Member": from unipoll_api.exceptions.policy import PolicyHolderNotFound collection = eval(self.policy_holder.ref.collection) - policy_holder: Group | Account = await collection.get(self.policy_holder.ref.id, - fetch_links=fetch_links) + policy_holder: Group | Member = await collection.get(self.policy_holder.ref.id, + fetch_links=fetch_links) if not policy_holder: PolicyHolderNotFound(self.policy_holder.ref.id) return policy_holder + + +class Member(Document): + id: ResourceID = Field(default_factory=ResourceID, alias="_id") + account: Link[Account] + workspace: BackLink[Workspace] = Field(original_field="members") + groups: list[BackLink[Group]] = Field(original_field="members") + policies: list[Link[Policy]] = [] diff --git a/src/unipoll_api/exceptions/resource.py b/src/unipoll_api/exceptions/resource.py index 75eb2a7..3559401 100644 --- a/src/unipoll_api/exceptions/resource.py +++ b/src/unipoll_api/exceptions/resource.py @@ -1,5 +1,5 @@ from fastapi import status -from unipoll_api.documents import Account, Resource, ResourceID +from unipoll_api.documents import Account, Resource, ResourceID, Member from unipoll_api.utils import Debug @@ -81,6 +81,6 @@ def __init__(self, resource: Resource, user: Account): # Error while removing member class ErrorWhileRemovingMember(APIException): - def __init__(self, resource: Resource, user: Account): + def __init__(self, resource: Resource, member: Member): super().__init__(code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error while removing user {user.email} from {resource.name} #{resource.id}") + detail=f"Error while removing user {member.id} from {resource.name} #{resource.id}") diff --git a/src/unipoll_api/mongo_db.py b/src/unipoll_api/mongo_db.py index 7c1a2ca..f317a07 100644 --- a/src/unipoll_api/mongo_db.py +++ b/src/unipoll_api/mongo_db.py @@ -15,6 +15,7 @@ Documents.AccessToken, Documents.Resource, Documents.Account, + Documents.Member, Documents.Group, Documents.Workspace, Documents.Policy, diff --git a/src/unipoll_api/routes/group.py b/src/unipoll_api/routes/group.py index 798a347..7695e69 100644 --- a/src/unipoll_api/routes/group.py +++ b/src/unipoll_api/routes/group.py @@ -132,7 +132,8 @@ async def get_group_policies(group: Group = Depends(Dependencies.get_group), account_id: ResourceID = Query(None)) -> PolicySchemas.PolicyList: try: account = await Dependencies.get_account(account_id) if account_id else None - return await PolicyActions.get_policies(resource=group, policy_holder=account) + member = await Dependencies.get_member(account, group) if account else None + return await PolicyActions.get_policies(resource=group, policy_holder=member) except APIException as e: raise HTTPException(status_code=e.code, detail=str(e)) diff --git a/src/unipoll_api/routes/workspace.py b/src/unipoll_api/routes/workspace.py index 0bf246b..99dddfc 100644 --- a/src/unipoll_api/routes/workspace.py +++ b/src/unipoll_api/routes/workspace.py @@ -114,10 +114,12 @@ async def get_workspace(workspace: Workspace = Depends(Dependencies.get_workspac # Update a workspace with the given id -@router.patch("/{workspace_id}", response_description="Updated workspace", response_model=WorkspaceSchemas.Workspace) +@router.patch("/{workspace_id}", + response_description="Updated workspace", + response_model=WorkspaceSchemas.Workspace, + response_model_exclude_none=True) async def update_workspace(workspace: Workspace = Depends(Dependencies.get_workspace), - input_data: WorkspaceSchemas.WorkspaceUpdateRequest = Body(...) - ): + input_data: WorkspaceSchemas.WorkspaceUpdateRequest = Body(...)): """ Updates the workspace with the given id. Query parameters: @@ -222,7 +224,8 @@ async def get_workspace_policies(workspace: Workspace = Depends(Dependencies.get account_id: ResourceID = Query(None)): try: account = await Dependencies.get_account(account_id) if account_id else None - return await actions.PolicyActions.get_policies(resource=workspace, policy_holder=account) + member = await Dependencies.get_member(account, workspace) if account else None + return await actions.PolicyActions.get_policies(resource=workspace, policy_holder=member) except APIException as e: raise HTTPException(status_code=e.code, detail=str(e)) diff --git a/src/unipoll_api/schemas/member.py b/src/unipoll_api/schemas/member.py index 69b782f..f59444c 100644 --- a/src/unipoll_api/schemas/member.py +++ b/src/unipoll_api/schemas/member.py @@ -6,11 +6,10 @@ # Schema for the response with basic member info class Member(BaseModel): id: ResourceID + account_id: Optional[ResourceID] = None email: Optional[EmailStr] = None first_name: Optional[str] = None last_name: Optional[str] = None - name: Optional[str] = None - description: Optional[str] = None model_config = ConfigDict(json_schema_extra={ "example": { "id": "1a2b3c4d5e6f7g8h9i0j", diff --git a/src/unipoll_api/schemas/policy.py b/src/unipoll_api/schemas/policy.py index cf71dfe..9262451 100644 --- a/src/unipoll_api/schemas/policy.py +++ b/src/unipoll_api/schemas/policy.py @@ -1,18 +1,18 @@ from typing import Literal, Any, Optional from pydantic import ConfigDict, BaseModel, Field -from unipoll_api.documents import ResourceID, Account, Group +from unipoll_api.documents import ResourceID, Group, Member class Policy(BaseModel): id: ResourceID - policy_holder_type: Literal["account", "group"] - policy_holder: Account | Group + policy_holder_type: Literal["Member", "Group"] + policy_holder: Member | Group permissions: int class PolicyShort(BaseModel): id: ResourceID - policy_holder_type: Literal["account", "group"] + policy_holder_type: Literal["Member", "Group"] policy_holder: Any = None permissions: Optional[Any] = None @@ -101,17 +101,17 @@ class AddPermission(BaseModel): "example": { "permissions": [ { - "type": "account", + "type": "Account", "id": "1a2b3c4d5e6f7g8h9i0j", "permission": "eff", }, { - "type": "account", + "type": "Account", "id": "2a3b4c5d6e7f8g9h0i1j", "permission": "a3", }, { - "type": "group", + "type": "Group", "id": "3a4b5c6d7e8f9g0h1i2j", "permission": "1", }, diff --git a/src/unipoll_api/utils/__init__.py b/src/unipoll_api/utils/__init__.py index 1c9bb5e..1e3ea09 100644 --- a/src/unipoll_api/utils/__init__.py +++ b/src/unipoll_api/utils/__init__.py @@ -2,6 +2,5 @@ from . import auth_transport as AuthTransport # noqa: F401 from . import cli_args as ArgParser # noqa: F401 from . import colored_dbg as Debug # noqa: F401 -from . import path_operations as PathOperations # noqa: F401 from . import permissions as Permissions # noqa: F401 from . import token_db as TokenDB # noqa: F401 diff --git a/src/unipoll_api/utils/permissions.py b/src/unipoll_api/utils/permissions.py index fd6caac..059c5ef 100644 --- a/src/unipoll_api/utils/permissions.py +++ b/src/unipoll_api/utils/permissions.py @@ -57,9 +57,9 @@ PermissionTypes = { - "workspace": WorkspacePermissions, - "group": GroupPermissions, - "poll": PollPermissions + "Workspace": WorkspacePermissions, + "Group": GroupPermissions, + "Poll": PollPermissions } @@ -92,7 +92,7 @@ def compare_permissions(user_permission: Permissions, required_permission: Permi # TODO: Rename -async def get_all_permissions(resource, account) -> Permissions: +async def get_all_permissions(resource, member) -> Permissions: permission_sum = 0 # print("resource: ", resource.name) # await resource.fetch_link("policies") @@ -100,18 +100,18 @@ async def get_all_permissions(resource, account) -> Permissions: # Get policies for the resource for policy in resource.policies: # Get policy for the user - if policy.policy_holder_type == "account": + if policy.policy_holder_type == "Member": policy_holder_id = None if hasattr(policy.policy_holder, "id"): # In case the policy_holder is an Account Document policy_holder_id = policy.policy_holder.id elif hasattr(policy.policy_holder, "ref"): # In case the policy_holder is a Link policy_holder_id = policy.policy_holder.ref.id - if policy_holder_id == account.id: + if policy_holder_id == member.id: # print("Found policy for user") permission_sum |= policy.permissions # print("User permissions: ", policy.permissions) # If there is a group that user is a member of, add group permissions to the user permissions - elif policy.policy_holder_type == "group": + elif policy.policy_holder_type == "Group": # Try to fetch the group group = await policy.policy_holder.fetch() # BUG: sometimes links are not fetched properly @@ -123,7 +123,7 @@ async def get_all_permissions(resource, account) -> Permissions: if group: await group.fetch_link("policies") # print("Checking group: ", group.name) - if await get_all_permissions(group, account): + if await get_all_permissions(group, member): permission_sum |= policy.permissions # print("Group permissions: ", policy.permissions) @@ -132,12 +132,12 @@ async def get_all_permissions(resource, account) -> Permissions: def convert_string_to_permission(resource_type: str, string: str): try: - # return eval(resource_type.capitalize() + "Permissions")[string] - if resource_type == "workspace": # type: ignore + # return eval(get_document_type().capitalize() + "Permissions")[string] + if resource_type == "Workspace": # type: ignore req_permissions = WorkspacePermissions[string] # type: ignore - elif resource_type == "group": # type: ignore + elif resource_type == "Group": # type: ignore req_permissions = GroupPermissions[string] # type: ignore - elif resource_type == "poll": # type: ignore + elif resource_type == "Poll": # type: ignore req_permissions = PollPermissions[string] # type: ignore else: raise ValueError("Unknown resource type") @@ -149,15 +149,19 @@ def convert_string_to_permission(resource_type: str, string: str): async def check_permissions(resource, required_permissions: str | list[str] | None = None, permission_check=True): if permission_check and required_permissions: account = unipoll_api.AccountManager.active_user.get() # Get the active user - user_permissions = await get_all_permissions(resource, account) # Get the user permissions + + from unipoll_api.dependencies import get_member + member = await get_member(account, resource) + + user_permissions = await get_all_permissions(resource, member) # Get the user permissions if isinstance(required_permissions, str): # If only one permission is required required_permissions = [required_permissions] - permissions_list = [convert_string_to_permission(resource.resource_type, p) for p in required_permissions] - required_permission = eval(resource.resource_type.capitalize() + "Permissions")(sum(permissions_list)) + permissions_list = [convert_string_to_permission(resource.get_document_type(), p) for p in required_permissions] + required_permission = eval(resource.get_document_type() + "Permissions")(sum(permissions_list)) if not compare_permissions(user_permissions, required_permission): actions = ", ".join([" ".join([j.capitalize() for j in i.split("_")]) for i in required_permissions]) raise exceptions.ResourceExceptions.UserNotAuthorized(account, - f"{resource.resource_type} {resource.id}", + f"{resource.get_document_type()} {resource.id}", actions) diff --git a/tests/test_2_workspaces.py b/tests/test_2_workspaces.py index bffa501..afddb8a 100644 --- a/tests/test_2_workspaces.py +++ b/tests/test_2_workspaces.py @@ -153,7 +153,7 @@ async def test_get_workspace_info(client_test: AsyncClient): response = response.json() assert len(response["members"]) == 1 temp = response["members"][0] - assert temp["id"] == active_user.id + assert temp["account_id"] == active_user.id assert temp["email"] == active_user.email assert temp["first_name"] == active_user.first_name assert temp["last_name"] == active_user.last_name @@ -211,9 +211,10 @@ async def test_add_members_to_workspace(client_test: AsyncClient): headers={"Authorization": f"Bearer {active_user.token}"}) assert response.status_code == status.HTTP_200_OK response = response.json() + for i in response["members"]: - assert i["id"] in members - members.remove(i["id"]) + assert i["account_id"] in members + members.remove(i["account_id"]) assert members == [] colored_dbg.test_success("All members have been successfully added to the workspace") @@ -234,13 +235,19 @@ async def test_get_workspace_members(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() assert len(response["members"]) == len(accounts) + for acc in accounts: - assert acc.model_dump(include={"id", "email", "first_name", "last_name"}) in response["members"] + for member in response["members"]: + if member["account_id"] == acc.id: + assert member["email"] == acc.email + assert member["first_name"] == acc.first_name + assert member["last_name"] == acc.last_name + # assert acc.model_dump(include={"id", "email", "first_name", "last_name"}) in members colored_dbg.test_success("The workspace returned the correct list of members") -async def test_get_permissions(client_test: AsyncClient): +async def test_get_user_policy(client_test: AsyncClient): print("\n") colored_dbg.test_info("Getting list of member permissions in workspace" + "[GET /workspaces/{workspace.id}/policies?account_id={account_id}]") @@ -253,15 +260,15 @@ async def test_get_permissions(client_test: AsyncClient): params={"account_id": str(active_user.id)}) assert response.status_code == status.HTTP_200_OK response = response.json() - # Creator of the workspace should have all permissions - policy = response['policies'][0] + + # Creator of the workspace should have all permissions assert policy["permissions"] == Permissions.WORKSPACE_ALL_PERMISSIONS.name.split("|") # type: ignore # Check permission of the rest of the members - for i in range(1, len(accounts)): + for account in accounts[1:]: response = await client_test.get(f"/workspaces/{workspace.id}/policies", - params={"account_id": accounts[i].id}, # type: ignore + params={"account_id": str(account.id)}, headers={"Authorization": f"Bearer {active_user.token}"}) response = response.json() policy = response['policies'][0] @@ -281,10 +288,11 @@ async def test_get_all_policies(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() assert len(response["policies"]) == len(accounts) - temp_acc_list = [acc.model_dump(include={"id", "email", "first_name", "last_name"}) for acc in accounts] + # temp_acc_list = [acc.model_dump(include={"id", "email", "first_name", "last_name"}) for acc in accounts] + temp_acc_list = [acc.id for acc in accounts] for policy in response["policies"]: - assert policy["policy_holder"] in temp_acc_list - if policy["policy_holder"]["id"] == accounts[0].id: + assert policy["policy_holder"]["account_id"] in temp_acc_list + if policy["policy_holder"]["account_id"] == accounts[0].id: assert policy["permissions"] == Permissions.WORKSPACE_ALL_PERMISSIONS.name.split("|") else: assert policy["permissions"] == Permissions.WORKSPACE_BASIC_PERMISSIONS.name.split("|") @@ -348,6 +356,7 @@ async def test_permissions(client_test: AsyncClient): res = await client_test.get(f"/workspaces/{workspace.id}/policies", headers=headers) # assert res.status_code == status.HTTP_403_FORBIDDEN assert res.status_code == status.HTTP_200_OK + print(res.json()) policy = res.json()["policies"][0] # # Try to set workspace permissions diff --git a/tests/test_3_groups.py b/tests/test_3_groups.py index d7f06c4..b0b9cb5 100644 --- a/tests/test_3_groups.py +++ b/tests/test_3_groups.py @@ -71,8 +71,8 @@ async def test_prepare_workspace(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() for i in response["members"]: - assert i["id"] in members - members.remove(i["id"]) + assert i["account_id"] in members + members.remove(i["account_id"]) assert members == [] @@ -177,7 +177,7 @@ async def test_get_group_info(client_test: AsyncClient): response = response.json() assert len(response["members"]) == 1 temp = response["members"][0] - assert temp["id"] == active_user.id + assert temp["account_id"] == active_user.id assert temp["email"] == active_user.email assert temp["first_name"] == active_user.first_name assert temp["last_name"] == active_user.last_name @@ -234,8 +234,8 @@ async def test_add_members_to_group(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() for i in response["members"]: - assert i["id"] in members - members.remove(i["id"]) + assert i["account_id"] in members + members.remove(i["account_id"]) assert members == [] colored_dbg.test_success("All members have been successfully added to the group") @@ -254,12 +254,17 @@ async def test_get_group_members(client_test: AsyncClient): response = response.json() assert len(response["members"]) == 10 # 10 accounts were added to the group for acc in accounts[:10]: - assert acc.model_dump(include={"id", "email", "first_name", "last_name"}) in response["members"] + for member in response["members"]: + if member["account_id"] == acc.id: + assert member["email"] == acc.email + assert member["first_name"] == acc.first_name + assert member["last_name"] == acc.last_name + # assert acc.model_dump(include={"id", "email", "first_name", "last_name"}) in response["members"] colored_dbg.test_success("The group returned the correct list of members") -async def test_get_policy(client_test: AsyncClient): +async def test_get_user_policy(client_test: AsyncClient): print("\n") colored_dbg.test_info("Getting list of member permissions in group [GET /groups/{group.id}/policies]") group = groups[0] @@ -272,14 +277,14 @@ async def test_get_policy(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() policy = response["policies"][0] + # Creator of the group should have all permissions assert policy["permissions"] == Permissions.GROUP_ALL_PERMISSIONS.name.split("|") # type: ignore # Check permission of the rest of the members - students = accounts[1:10] - for account in students: + for account in accounts[1:10]: response = await client_test.get(f"/groups/{group.id}/policies", - params={"account_id": account.id}, # type: ignore + params={"account_id": str(account.id)}, headers={"Authorization": f"Bearer {active_user.token}"}) response = response.json() policy = response["policies"][0] @@ -299,10 +304,10 @@ async def test_get_all_policies(client_test: AsyncClient): assert response.status_code == status.HTTP_200_OK response = response.json() assert len(response["policies"]) == 10 # 10 accounts were added to the group - temp_acc_list = [acc.model_dump(include={"id", "email", "first_name", "last_name"}) for acc in accounts] + temp_acc_list = [acc.id for acc in accounts] for policy in response["policies"]: - assert policy["policy_holder"] in temp_acc_list - if policy["policy_holder"]["id"] == accounts[0].id: + assert policy["policy_holder"]["account_id"] in temp_acc_list + if policy["policy_holder"]["account_id"] == accounts[0].id: assert policy["permissions"] == Permissions.GROUP_ALL_PERMISSIONS.name.split("|") else: assert policy["permissions"] == Permissions.GROUP_BASIC_PERMISSIONS.name.split("|")