Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
df26239
refactor: renamed get_member function to get_member_by_account
michael-pisman Nov 11, 2023
12977e5
fix: remove_group_member function
michael-pisman Nov 11, 2023
096d587
test: Update API endpoint paths for account related tests
michael-pisman Nov 11, 2023
6f86d6e
feat: Update API versioning to include version number in the title
michael-pisman Nov 18, 2023
220c005
refactor: Update Account schema naming
michael-pisman Nov 18, 2023
b743302
refactor: Deleted account and authentication from v2 router
michael-pisman Nov 19, 2023
dea0b72
style: Comments
michael-pisman Nov 19, 2023
e1fe2c5
style: Renamed poll to polls
michael-pisman Nov 19, 2023
27b56b3
style: Renamed
michael-pisman Nov 19, 2023
8329567
style: Renamed
michael-pisman Nov 19, 2023
24471ac
revert: Deleted account and authentication from v2 router
michael-pisman Nov 19, 2023
4462eb0
style: Deleted extra new lines
michael-pisman Nov 19, 2023
ce1cb08
refactor: Update route imports
michael-pisman Nov 19, 2023
ed923a9
refactor: Updated get_openapi entrypoint
michael-pisman Nov 19, 2023
1249205
refactor: Update active_user ContextVar type
michael-pisman Nov 19, 2023
1490e39
fix: Fix workspace deletion bug and optimize deletion process
michael-pisman Nov 19, 2023
1901198
refactor: Add API versions constant
michael-pisman Nov 19, 2023
2982bad
feat: Add version argument to get-openapi command
michael-pisman Nov 19, 2023
0694103
feat: Update get_openapi function to support multiple versions
michael-pisman Nov 19, 2023
714d7a3
feat: Added action to get member info
michael-pisman Nov 24, 2023
3e378e8
feat: Added get member endpoint
michael-pisman Nov 24, 2023
68b7ea8
fix: Fix validation error in AddMembersRequest schema
michael-pisman Nov 24, 2023
43cf320
refactor: Changed member document
michael-pisman Nov 24, 2023
dcf4ef8
refactor: Moved import statements for actions to __interface__.py
michael-pisman Nov 27, 2023
363fa4d
feat: Created a test plugin
michael-pisman Nov 27, 2023
1295d13
feat: Add plugin functionality to the app
michael-pisman Nov 27, 2023
af05bfc
feat: Add UpdatableModel class (not working)
michael-pisman Nov 27, 2023
4448e0d
feat: Add action wrapper for plugin integration
michael-pisman Nov 27, 2023
d4fb116
refactor: Added plugin initialization
michael-pisman Nov 27, 2023
923d244
refactor: Refactor import statements and added plugins wrapper for ge…
michael-pisman Nov 27, 2023
a599ac5
refactor: Moved timer plugin outside of the project
michael-pisman Nov 29, 2023
fad4d23
refactor: Moved entry points to a separate file
michael-pisman Nov 29, 2023
aa10f84
fix: Removed app import
michael-pisman Nov 29, 2023
56ec913
feat: Added plugins field to app config
michael-pisman Nov 29, 2023
2e6744c
refactor: Replaced plugin functions with PluginManger
michael-pisman Nov 29, 2023
424b7d8
Merge branch 'main' into plugins
michael-pisman Jul 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys
sys.path.append("src")
from unipoll_api import app # noqa: E402
from unipoll_api import entry_points # noqa: E402


if __name__ == "__main__":
app.cli_entry_point()
entry_points.cli_entry_point()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
]

[project.scripts]
unipoll-api = "unipoll_api.app:cli_entry_point"
unipoll-api = "unipoll_api.entry_points:cli_entry_point"

[project.optional-dependencies]
dev = [
Expand Down
3 changes: 2 additions & 1 deletion src/unipoll_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# FIXME: Remove all the imports
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address the FIXME comment by either removing the unused imports or removing the comment if the imports are still needed.

Suggested change
# FIXME: Remove all the imports

Copilot uses AI. Check for mistakes.
from . import account_manager as AccountManager # noqa: F401
from . import app as App # noqa: F401
# from . import app as App # noqa: F401
from . import config as Config # noqa: F401
from . import dependencies as Dependencies # noqa: F401
from . import documents as Documents # noqa: F401
Expand Down
24 changes: 23 additions & 1 deletion src/unipoll_api/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,26 @@
from . import workspace as WorkspaceActions # noqa: F401
from . import permissions as PermissionsActions # noqa: F401
from . import members as MembersActions # noqa: F401
from . import websocket as WebsocketActions # noqa: F401
from . import websocket as WebsocketActions # noqa: F401

from functools import wraps

from unipoll_api.plugin_manager import get_plugin_manager

plugin_manager = get_plugin_manager()


def plugins(f):
@wraps(f)
async def wrapper(*args, **kwds):
# print("Action Wrapper")
# print(f"Action: {f}")
# print(f"Args: {args}")
# print(f"Kwds: {kwds}")
# print("\n")

res = await plugin_manager.run_plugins(action=f(*args, **kwds))
# print(f"\nWrapper Result: {res}")

return res
return wrapper
8 changes: 8 additions & 0 deletions src/unipoll_api/actions/__interface__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from . import account as AccountActions # noqa: F401
from . import group as GroupActions # noqa: F401
from . import policy as PolicyActions # noqa: F401
from . import poll as PollActions # noqa: F401
from . import authentication as AuthActions # noqa: F401
from . import workspace as WorkspaceActions # noqa: F401
from . import permissions as PermissionsActions # noqa: F401
from . import members as MembersActions # noqa: F401
4 changes: 2 additions & 2 deletions src/unipoll_api/actions/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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
from unipoll_api.dependencies import get_member_by_account


# Get list of groups
Expand Down Expand Up @@ -48,7 +48,7 @@ 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)
member = await get_member_by_account(account, workspace)

# Check if group name is unique
group: Group # For type hinting, until Link type is supported
Expand Down
32 changes: 27 additions & 5 deletions src/unipoll_api/actions/members.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
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 import AccountManager
from unipoll_api.exceptions import ResourceExceptions
from unipoll_api.dependencies import get_member
from unipoll_api.dependencies import get_member_by_account
from unipoll_api.account_manager import active_user


async def get_members(resource: Workspace | Group,
Expand Down Expand Up @@ -46,11 +47,11 @@ async def add_members(resource: Workspace | Group,
for account in account_list:
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)
member = await get_member_by_account(account, resource.workspace) # type: ignore
new_member = await resource.add_member(member, default_permissions, save=False) # type: ignore
new_members.append(new_member)
elif resource.get_document_type() == "Workspace":
new_member = await resource.add_member(account, default_permissions, save=False)
new_member = await resource.add_member(account, default_permissions, save=False) # type: ignore
new_members.append(new_member)
await resource.save(link_rule=WriteRules.WRITE) # type: ignore

Expand All @@ -67,6 +68,27 @@ async def add_members(resource: Workspace | Group,
return MemberSchemas.MemberList(members=member_list)


# Get member info
async def get_member(member: Member,
group: Group | None = None,
permission_check: bool = True) -> MemberSchemas.Member:
# Check if current user is the member
active_user = AccountManager.active_user.get()
# await member.fetch_link("account")
# print(member.account)
await member.fetch_all_links()
if member.account.id is not active_user.id: # type: ignore
# Check if the user has permission to get members
await Permissions.check_permissions(member.workspace, "get_members", permission_check)

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)


# Remove a member from a workspace
async def remove_member(resource: Workspace | Group,
member: Member,
Expand Down
4 changes: 2 additions & 2 deletions src/unipoll_api/actions/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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
from unipoll_api.dependencies import get_member_by_account


# Helper function to get policies from a resource
Expand All @@ -17,7 +17,7 @@ async def get_policies_from_resource(resource: Resource) -> list[Policy]:
except ResourceExceptions.UserNotAuthorized:
print("User not authorized")
account = AccountManager.active_user.get()
member = await get_member(account, resource)
member = await get_member_by_account(account, resource)
for policy in resource.policies:
if policy.policy_holder.ref.id == member.id: # type: ignore
policies.append(policy) # type: ignore
Expand Down
45 changes: 33 additions & 12 deletions src/unipoll_api/actions/workspace.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from bson import DBRef
from beanie.odm.bulk import BulkWriter
from unipoll_api import AccountManager
from unipoll_api import actions
from . import plugins, group as GroupActions, policy as PolicyActions, poll as PollActions, members as MembersActions
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
# from unipoll_api.dependencies import get_member_by_account


Comment on lines +9 to 11
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove commented-out import on line 9 as it's not being used.

Suggested change
# from unipoll_api.dependencies import get_member_by_account

Copilot uses AI. Check for mistakes.
# Get a list of workspaces where the account is a owner/member
Expand Down Expand Up @@ -45,17 +46,18 @@ async def create_workspace(input_data: WorkspaceSchemas.WorkspaceCreateInput) ->


# Get a workspace
@plugins
async def get_workspace(workspace: Workspace,
include_groups: bool = False,
include_policies: bool = False,
include_members: bool = False,
include_polls: bool = False,
check_permissions: bool = True) -> WorkspaceSchemas.Workspace:
await Permissions.check_permissions(workspace, "get_workspace", check_permissions)
groups = (await actions.GroupActions.get_groups(workspace)).groups if include_groups else None
members = (await actions.MembersActions.get_members(workspace)).members if include_members else None
policies = (await actions.PolicyActions.get_policies(resource=workspace)).policies if include_policies else None
polls = (await actions.PollActions.get_polls(workspace)).polls if include_polls else None
groups = (await GroupActions.get_groups(workspace)).groups if include_groups else None
members = (await MembersActions.get_members(workspace)).members if include_members else None
policies = (await PolicyActions.get_policies(resource=workspace)).policies if include_policies else None
polls = (await PollActions.get_polls(workspace)).polls if include_polls else None
# Return the workspace with the fetched resources
return WorkspaceSchemas.Workspace(id=workspace.id,
name=workspace.name,
Expand Down Expand Up @@ -95,16 +97,35 @@ async def update_workspace(workspace: Workspace,
async def delete_workspace(workspace: Workspace, check_permissions: bool = True):
await Permissions.check_permissions(workspace, "delete_workspace", check_permissions)

workspace_ref = DBRef(collection="Workspace", id=workspace.id)
# workspace_ref = DBRef(collection="Workspace", id=workspace.id)

# Delete all groups in the workspace
for group in workspace.groups:
await actions.GroupActions.delete_group(group) # type: ignore
# for group in workspace.groups:
# await actions.GroupActions.delete_group(group) # type: ignore

# TODO: Delete all polls in the workspace

# Delete Workspace

# BUG: Deleting workspace also deletes account
# TODO: Find a way to keep the account
# from beanie import DeleteRules
# await Workspace.delete(workspace, link_rule=DeleteRules.DELETE_LINKS)

# await Workspace.delete(workspace)

# if await workspace.get(workspace.id):
# raise WorkspaceExceptions.ErrorWhileDeleting(workspace.id)

# await Policy.find({"parent_resource": workspace_ref}).delete()
# mems = await Member.find(Member.workspace.id == workspace.id, fetch_links=True).delete()

Comment on lines +110 to +122
Copy link

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove or address the large block of commented-out code (lines 110-131) as it clutters the implementation. Consider creating a proper TODO item or issue if this represents future work.

Suggested change
# BUG: Deleting workspace also deletes account
# TODO: Find a way to keep the account
# from beanie import DeleteRules
# await Workspace.delete(workspace, link_rule=DeleteRules.DELETE_LINKS)
# await Workspace.delete(workspace)
# if await workspace.get(workspace.id):
# raise WorkspaceExceptions.ErrorWhileDeleting(workspace.id)
# await Policy.find({"parent_resource": workspace_ref}).delete()
# mems = await Member.find(Member.workspace.id == workspace.id, fetch_links=True).delete()
# TODO: Implement proper workspace deletion logic, ensuring related entities (e.g., members, policies) are handled correctly.
# Address the issue where deleting a workspace also deletes the account. Track this work in ISSUE-1234.

Copilot uses AI. Check for mistakes.
async with BulkWriter() as writer:
for member in workspace.members:
await Member.delete(member, bulk_writer=writer)

async with BulkWriter() as writer:
for policy in workspace.policies:
await Policy.delete(policy, bulk_writer=writer)

await Workspace.delete(workspace)
if await workspace.get(workspace.id):
raise WorkspaceExceptions.ErrorWhileDeleting(workspace.id)
await Policy.find({"parent_resource": workspace_ref}).delete()
78 changes: 2 additions & 76 deletions src/unipoll_api/app.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import json
import uvicorn
import os
import argparse
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.middleware.cors import CORSMiddleware
from beanie import init_beanie
# from unipoll_api.routes import router, websocket
from unipoll_api.routes import create_router
from unipoll_api.routes import create_router, v1_router, v2_router
from unipoll_api.mongo_db import mainDB, documentModels
from unipoll_api.config import get_settings
from unipoll_api.__version__ import version
from unipoll_api.utils import cli_args, colored_dbg


# Apply setting from configuration file
Expand Down Expand Up @@ -55,72 +49,4 @@ async def on_startup() -> None:
await init_beanie(
database=mainDB, # type: ignore
document_models=documentModels # type: ignore
)


# Run the application
def start_server(host: str = settings.host, port: int = settings.port, reload: bool = settings.reload):
uvicorn.run('unipoll_api.app:app', reload=reload, host=host, port=port)


# Check if IP address is valid
def check_ip(arg_value):
address = arg_value.split(".")
if len(address) != 4:
raise argparse.ArgumentTypeError("invalid host value")
for i in address:
if int(i) > 255 or int(i) < 0:
raise argparse.ArgumentTypeError("invalid host value")
return arg_value


def cli_entry_point():
args = cli_args.parse_args()

if args.command == "run":
run(args.host, args.port, args.reload)
elif args.command == "setup":
setup()
elif args.command == "get-openapi":
get_openapi()
else:
print("Invalid command")


def run(host=settings.host, port=settings.port, reload=settings.reload):
# args = run_parser.parse_args()
colored_dbg.info("University Polling API v{}".format(version))
start_server(host, port, reload)


def setup():
# Print current directory
# print("Current directory: {}".format(os.getcwd()))

# Get user input
host = input("Host IP address [{}]: ".format(settings.host))
port = input("Host port number [{}]: ".format(settings.port))
mongodb_url = input("MongoDB URL [{}]: ".format(settings.mongodb_url))
origins = input("Origins [{}]: ".format(settings.origins))
admin_email = input("Admin email [{}]: ".format(settings.admin_email))

# Write to .env file
with open(".env", "w") as f:
f.write("HOST={}\n".format(host if host else settings.host))
f.write("PORT={}\n".format(port if port else settings.port))
f.write("MONGODB_URL={}\n".format(mongodb_url if mongodb_url else settings.mongodb_url))
f.write("ORIGINS={}\n".format(origins if origins else settings.origins))
f.write("ADMIN_EMAIL={}\n".format(admin_email if admin_email else settings.admin_email))

# Print success message
print(f"Your configuration has been saved to {os.getcwd()}/.env")


def get_openapi():
if not app.openapi_schema:
openapi_schema = app.openapi()
app.openapi_schema = openapi_schema
json.dump(app.openapi_schema, open("openapi.json", "w"), indent=2)

# Print success message
print(f"OpenAPI schema saved to {os.getcwd()}/openapi.json")
)
2 changes: 2 additions & 0 deletions src/unipoll_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class Settings(BaseSettings): # type: ignore
port: int = 9000
reload: bool = True
model_config = SettingsConfigDict(env_file=".env")
# plugins: list = ["timer"]
plugins: list = ["test_plugin"]


@lru_cache()
Expand Down
14 changes: 13 additions & 1 deletion src/unipoll_api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,18 @@ async def get_account(account_id: ResourceID) -> Account:
return account


async def get_member(account: Account, resource: Workspace | Group) -> Member:
@http_dependency
async def get_member(member_id: ResourceID) -> Member:
"""
Returns a member with the given id.
"""
member = await Member.get(member_id)
if not member:
raise Exceptions.ResourceExceptions.ResourceNotFound("member", member_id)
return member


async def get_member_by_account(account: Account, resource: Workspace | Group) -> Member:
"""
Returns a member with the given id.
"""
Expand All @@ -49,6 +60,7 @@ async def websocket_auth(session: Annotated[str | None, Cookie()] = None,
strategy=Depends(get_database_strategy)
) -> Account:
user = None

if token:
max_age = datetime.now(timezone.utc) - timedelta(seconds=strategy.lifetime_seconds)
token_data = await token_db.get_by_token(token, max_age)
Expand Down
2 changes: 1 addition & 1 deletion src/unipoll_api/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class Workspace(Resource):
polls: list[Link["Poll"]] = []

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_member = await Member(account=account, workspace=(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

Expand Down
Loading
Loading