From bd7be6068cab27cedda5850e0d9742abf714529c Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 7 Apr 2025 17:09:52 -0400 Subject: [PATCH 1/5] refactor templates, (mostly) implement organization page --- docs/templates.qmd | 321 ------------------ exceptions/http_exceptions.py | 25 +- main.py | 3 +- pyproject.toml | 2 +- routers/account.py | 10 +- routers/organization.py | 184 ++++++++-- routers/role.py | 6 +- routers/static_pages.py | 6 +- routers/user.py | 126 ++++++- templates/{ => account}/auth_base.html | 0 .../forgot_password.html | 0 .../{authentication => account}/login.html | 0 .../{authentication => account}/register.html | 0 .../reset_password.html | 0 templates/base.html | 4 +- .../{components => base/macros}/logo.html | 0 .../macros}/silhouette.html | 0 .../{components => base/partials}/footer.html | 0 .../{components => base/partials}/header.html | 6 +- .../{components => base/partials}/nav.html | 0 .../dashboard/organization_overview.html | 11 - templates/dashboard/organizations/create.html | 0 templates/dashboard/organizations/delete.html | 0 templates/dashboard/organizations/detail.html | 0 templates/dashboard/organizations/edit.html | 0 .../dashboard/organizations/members.html | 4 - .../organizations/members/delete.html | 0 .../dashboard/organizations/members/edit.html | 0 .../organizations/members/invite.html | 0 templates/emails/base_email.html | 2 +- templates/index.html | 2 +- .../modals/delete_organization_modal.html | 23 ++ .../modals/edit_organization_modal.html | 26 ++ .../organization/modals/members_card.html | 143 ++++++++ templates/organization/modals/roles_card.html | 167 +++++++++ templates/organization/organization.html | 39 +++ templates/{ => static_pages}/about.html | 0 .../{ => static_pages}/privacy_policy.html | 0 .../{ => static_pages}/terms_of_service.html | 0 .../macros}/organizations.html | 2 +- templates/users/organization.html | 90 ----- templates/users/profile.html | 4 +- tests/conftest.py | 236 ++++++++++++- tests/routers/test_account.py | 16 +- tests/routers/test_organization.py | 303 ++++++++++++++++- tests/routers/test_role.py | 182 ++++++++++ tests/routers/test_user.py | 73 +++- uv.lock | 2 +- 48 files changed, 1514 insertions(+), 504 deletions(-) delete mode 100644 docs/templates.qmd rename templates/{ => account}/auth_base.html (100%) rename templates/{authentication => account}/forgot_password.html (100%) rename templates/{authentication => account}/login.html (100%) rename templates/{authentication => account}/register.html (100%) rename templates/{authentication => account}/reset_password.html (100%) rename templates/{components => base/macros}/logo.html (100%) rename templates/{components => base/macros}/silhouette.html (100%) rename templates/{components => base/partials}/footer.html (100%) rename templates/{components => base/partials}/header.html (93%) rename templates/{components => base/partials}/nav.html (100%) delete mode 100644 templates/dashboard/organization_overview.html delete mode 100644 templates/dashboard/organizations/create.html delete mode 100644 templates/dashboard/organizations/delete.html delete mode 100644 templates/dashboard/organizations/detail.html delete mode 100644 templates/dashboard/organizations/edit.html delete mode 100644 templates/dashboard/organizations/members.html delete mode 100644 templates/dashboard/organizations/members/delete.html delete mode 100644 templates/dashboard/organizations/members/edit.html delete mode 100644 templates/dashboard/organizations/members/invite.html create mode 100644 templates/organization/modals/delete_organization_modal.html create mode 100644 templates/organization/modals/edit_organization_modal.html create mode 100644 templates/organization/modals/members_card.html create mode 100644 templates/organization/modals/roles_card.html create mode 100644 templates/organization/organization.html rename templates/{ => static_pages}/about.html (100%) rename templates/{ => static_pages}/privacy_policy.html (100%) rename templates/{ => static_pages}/terms_of_service.html (100%) rename templates/{components => users/macros}/organizations.html (96%) delete mode 100644 templates/users/organization.html diff --git a/docs/templates.qmd b/docs/templates.qmd deleted file mode 100644 index 2eafda8..0000000 --- a/docs/templates.qmd +++ /dev/null @@ -1,321 +0,0 @@ ---- -title: "Template Variables Documentation" ---- - -This file documents the required context variables for each template in the application. - -## Table of Contents - -- [authentication](#authentication) -- [components](#components) -- [dashboard](#dashboard) -- [dashboard > organizations](#dashboard-organizations) -- [dashboard > organizations > members](#dashboard-organizations-members) -- [emails](#emails) -- [errors](#errors) -- [root](#root) -- [users](#users) - -## authentication - -### forgot_password.html - -**Path:** `authentication/forgot_password.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `show_form` | | -| `url_for` | | - -### login.html - -**Path:** `authentication/login.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### register.html - -**Path:** `authentication/register.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `password_pattern` | | -| `url_for` | | - -### reset_password.html - -**Path:** `authentication/reset_password.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `email` | | -| `password_pattern` | | -| `token` | | -| `url_for` | | - -## components - -### footer.html - -**Path:** `components/footer.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### header.html - -**Path:** `components/header.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | -| `user` | | - -### logo.html - -**Path:** `components/logo.html` - -**No variables required** - -### nav.html - -**Path:** `components/nav.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### organizations.html - -**Path:** `components/organizations.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### silhouette.html - -**Path:** `components/silhouette.html` - -**No variables required** - -## dashboard - -### index.html - -**Path:** `dashboard/index.html` - -**No variables required** - -### organization_overview.html - -**Path:** `dashboard/organization_overview.html` - -**No variables required** - -## dashboard > organizations - -### create.html - -**Path:** `dashboard/organizations/create.html` - -**No variables required** - -### delete.html - -**Path:** `dashboard/organizations/delete.html` - -**No variables required** - -### detail.html - -**Path:** `dashboard/organizations/detail.html` - -**No variables required** - -### edit.html - -**Path:** `dashboard/organizations/edit.html` - -**No variables required** - -### members.html - -**Path:** `dashboard/organizations/members.html` - -**No variables required** - -## dashboard > organizations > members - -### delete.html - -**Path:** `dashboard/organizations/members/delete.html` - -**No variables required** - -### edit.html - -**Path:** `dashboard/organizations/members/edit.html` - -**No variables required** - -### invite.html - -**Path:** `dashboard/organizations/members/invite.html` - -**No variables required** - -## emails - -### base_email.html - -**Path:** `emails/base_email.html` - -**No variables required** - -### reset_email.html - -**Path:** `emails/reset_email.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `reset_url` | | - -### update_email_email.html - -**Path:** `emails/update_email_email.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `confirmation_url` | | -| `current_email` | | -| `new_email` | | - -## errors - -### error.html - -**Path:** `errors/error.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `detail` | | -| `status_code` | | -| `url_for` | | - -### validation_error.html - -**Path:** `errors/validation_error.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `errors` | | -| `url_for` | | - -## root - -### about.html - -**Path:** `about.html` - -**No variables required** - -### auth_base.html - -**Path:** `auth_base.html` - -**No variables required** - -### base.html - -**Path:** `base.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `url_for` | | - -### index.html - -**Path:** `index.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `render_logo` | | -| `url_for` | | - -### privacy_policy.html - -**Path:** `privacy_policy.html` - -**No variables required** - -### terms_of_service.html - -**Path:** `terms_of_service.html` - -**No variables required** - -## users - -### organization.html - -**Path:** `users/organization.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `organization` | | -| `render_silhouette` | | -| `url_for` | | - -### profile.html - -**Path:** `users/profile.html` - -**Required Variables:** - -| Variable | Description | -| --- | --- | -| `allowed_formats` | | -| `email_update_requested` | | -| `email_updated` | | -| `max_dimension` | | -| `max_file_size_mb` | | -| `min_dimension` | | -| `render_organizations` | | -| `render_silhouette` | | -| `show_form` | | -| `url_for` | | -| `user` | | diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index d51d636..13585ae 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -44,12 +44,11 @@ def __init__(self): ) -# TODO: Consolidate these two into a single validation error -class EmptyOrganizationNameError(HTTPException): - def __init__(self): +class OrganizationSetupError(HTTPException): + def __init__(self, message: str = "Organization setup failed"): super().__init__( - status_code=400, - detail="Organization name cannot be empty" + status_code=500, + detail=message ) @@ -69,6 +68,22 @@ def __init__(self): ) +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="User not found" + ) + + +class UserAlreadyMemberError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="User is already a member of this organization" + ) + + class InvalidPermissionError(HTTPException): """Raised when a user attempts to assign an invalid permission to a role""" diff --git a/main.py b/main.py index 865d919..080be61 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,8 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.exceptions import RequestValidationError, StarletteHTTPException +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException from routers import account, dashboard, organization, role, user, static_pages from utils.dependencies import ( get_optional_user diff --git a/pyproject.toml b/pyproject.toml index f7fdb5a..c8c8400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,12 @@ dependencies = [ dev = [ "graphviz<1.0.0,>=0.20.3", "quarto<1.0.0,>=0.1.0", - "mypy<2.0.0,>=1.11.2", "jupyter<2.0.0,>=1.1.1", "notebook<8.0.0,>=7.2.2", "pytest<9.0.0,>=8.3.3", "sqlalchemy-schemadisplay<3.0,>=2.0", "perplexity-cli", + "mypy>=1.15.0", ] [tool.uv.sources] diff --git a/routers/account.py b/routers/account.py index 237f989..b46f3a2 100644 --- a/routers/account.py +++ b/routers/account.py @@ -122,7 +122,7 @@ async def read_login( if user: return RedirectResponse(url="/dashboard", status_code=302) return templates.TemplateResponse( - "authentication/login.html", + "account/login.html", {"request": request, "user": user, "email_updated": email_updated} ) @@ -139,7 +139,7 @@ async def read_register( return RedirectResponse(url="/dashboard", status_code=302) return templates.TemplateResponse( - "authentication/register.html", + "account/register.html", {"request": request, "user": user, "password_pattern": HTML_PASSWORD_PATTERN} ) @@ -157,7 +157,7 @@ async def read_forgot_password( return RedirectResponse(url="/dashboard", status_code=302) return templates.TemplateResponse( - "authentication/forgot_password.html", + "account/forgot_password.html", {"request": request, "user": user, "show_form": show_form == "true"} ) @@ -180,7 +180,7 @@ async def read_reset_password( raise CredentialsError(message="Invalid or expired token") return templates.TemplateResponse( - "authentication/reset_password.html", + "account/reset_password.html", {"request": request, "user": user, "email": email, "token": token, "password_pattern": HTML_PASSWORD_PATTERN} ) @@ -212,7 +212,7 @@ async def register( session.flush() # Flush to get the account ID # Create the user - account.user = User(name=name) + account.user = User(name=name, account_id=account.id) session.add(account) session.commit() session.refresh(account) diff --git a/routers/organization.py b/routers/organization.py index 82e596e..291ad60 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -4,11 +4,17 @@ from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from sqlmodel import Session, select -from utils.db import get_session, default_roles +from sqlalchemy.orm import selectinload +from utils.db import get_session, default_roles, create_default_roles from utils.dependencies import get_authenticated_user, get_user_with_relations -from utils.models import Organization, User, Role, utc_time +from utils.models import Organization, User, Role, Account, utc_time from utils.enums import ValidPermissions -from exceptions.http_exceptions import OrganizationNotFoundError, OrganizationNameTakenError, InsufficientPermissionsError, EmptyOrganizationNameError +from exceptions.http_exceptions import ( + OrganizationNotFoundError, OrganizationNameTakenError, + InsufficientPermissionsError, OrganizationSetupError, + UserNotFoundError, UserAlreadyMemberError, DataIntegrityError +) +from pydantic import EmailStr logger = getLogger("uvicorn.error") @@ -23,7 +29,8 @@ async def read_organization( org_id: int, request: Request, - user: User = Depends(get_user_with_relations) + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session) ): # Get the organization only if the user is a member of it org = next( @@ -32,9 +39,35 @@ async def read_organization( ) if not org: raise OrganizationNotFoundError() - + + # Calculate the user's permissions for this organization + user_permissions = set() + for role in user.roles: + if role.organization_id == org_id: + for permission in role.permissions: + user_permissions.add(permission.name) + + # Load the organization with fully loaded roles and users + organization = session.exec( + select(Organization) + .where(Organization.id == org_id) + .options( + selectinload(Organization.roles).selectinload(Role.users), + selectinload(Organization.roles).selectinload(Role.users).selectinload(User.roles), + selectinload(Organization.roles).selectinload(Role.permissions) + ) + ).first() + + # Pass all required context to the template return templates.TemplateResponse( - request, "users/organization.html", {"organization": org} + request, + "organization/organization.html", + { + "organization": organization, + "user": user, + "user_permissions": user_permissions, + "ValidPermissions": ValidPermissions + } ) @@ -65,27 +98,50 @@ def create_organization( session.flush() # Create default roles with organization_id - initial_roles = [ - Role(name=role_name, organization_id=db_org.id) - for role_name in default_roles - ] - session.add_all(initial_roles) - session.flush() + if db_org.id is None: + logger.error("Failed to obtain organization ID after flush.") + raise OrganizationSetupError() + + # Use the utility function to create default roles and assign permissions + # This also handles committing the roles and permissions + try: + create_default_roles(session, db_org.id, check_first=False) + except Exception as e: + logger.exception(f"Failed to create default roles for org ID {db_org.id}") + # Rollback might be needed if create_default_roles doesn't handle it + session.rollback() + raise OrganizationSetupError("Failed during role creation") from e + + # Refresh the org object to load the roles relationship + session.refresh(db_org) + + # Get owner role for user assignment (roles should now exist) + owner_role = next((role for role in db_org.roles if role.name == "Owner"), None) - # Get owner role for user assignment - owner_role = next(role for role in db_org.roles if role.name == "Owner") + if owner_role is None: + logger.error(f"'Owner' role not found for newly created org ID {db_org.id} after create_default_roles call.") + # Rollback might be needed + session.rollback() + raise OrganizationSetupError("Owner role missing after creation") # Assign user to owner role user.roles.append(owner_role) - # Commit changes - session.commit() - session.refresh(db_org) + # Commit the user role link + try: + session.commit() + logger.info(f"Successfully created organization '{db_org.name}' (ID: {db_org.id}) and assigned owner (User ID: {user.id}).") + except Exception as e: + logger.exception(f"Failed to commit user-owner role link for org ID {db_org.id} and user ID {user.id}") + session.rollback() + raise OrganizationSetupError("Failed to assign owner role") from e + + session.refresh(db_org) # Refresh again to be safe before redirect return RedirectResponse(url=f"/organizations/{db_org.id}", status_code=303) -@router.post("/update/{org_id}", name="update_organization", response_class=RedirectResponse) +@router.post("/update/{org_id}", response_class=RedirectResponse) def update_organization( org_id: int, name: Annotated[str, Form( @@ -130,18 +186,98 @@ def delete_organization( user: User = Depends(get_user_with_relations), session: Session = Depends(get_session) ) -> RedirectResponse: - # Check if user has permission to delete organization + # Find the organization the user belongs to organization: Organization | None = next( (org for org in user.organizations if org.id == org_id), None) - if not organization or not any( - p.name == ValidPermissions.DELETE_ORGANIZATION - for role in organization.roles - for p in role.permissions - ): + + # Check if the user is a member and has permission to delete the organization + if not organization or not user.has_permission(ValidPermissions.DELETE_ORGANIZATION, organization): + logger.warning(f"User {user.id} attempted to delete organization {org_id} without permission.") raise InsufficientPermissionsError() # Delete organization + logger.info(f"User {user.id} deleting organization {org_id} ('{organization.name}').") session.delete(organization) session.commit() return RedirectResponse(url="/profile", status_code=303) + + +@router.post("/invite/{org_id}", response_class=RedirectResponse) +def invite_member( + org_id: int, + email: Annotated[EmailStr, Form( + description="Email of the user to invite", + title="Email" + )], + user: User = Depends(get_user_with_relations), + session: Session = Depends(get_session) +) -> RedirectResponse: + # Check if the user has permission to invite members + if not user.has_permission(ValidPermissions.INVITE_USER, org_id): + raise InsufficientPermissionsError() + + # Find the organization with all needed relationships + organization = session.exec( + select(Organization) + .where(Organization.id == org_id) + .options( + selectinload(Organization.roles), + selectinload(Organization.roles).selectinload(Role.users) + ) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Log organization and roles state + org_identity = session.identity_key(instance=organization) + role_info = [(r.id, r.name, session.identity_key(instance=r)) for r in organization.roles] + + # Find the account and associated user by email + account = session.exec( + select(Account) + .where(Account.email == email) + .options( + selectinload(Account.user) + ) + ).first() + + if not account or not account.user: + raise UserNotFoundError() + + invited_user = account.user + user_identity = session.identity_key(instance=invited_user) + + # Check if user is already a member of this organization + is_already_member = False + for role in organization.roles: + if invited_user.id in [u.id for u in role.users]: + is_already_member = True + break + + if is_already_member: + raise UserAlreadyMemberError() + + # Find the default "Member" role for this organization + member_role = next( + (role for role in organization.roles if role.name == "Member"), + None + ) + + if not member_role: + raise DataIntegrityError(resource="Organization roles") + + # Add the invited user to the Member role + try: + member_role.users.append(invited_user) + session.commit() + except Exception as e: + session.rollback() + raise + + # Return to the organization page + return RedirectResponse( + url=f"/organizations/{org_id}", + status_code=303 + ) diff --git a/routers/role.py b/routers/role.py index 83ce4de..321a8d3 100644 --- a/routers/role.py +++ b/routers/role.py @@ -55,7 +55,7 @@ def create_role( # Commit transaction session.commit() - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) @router.post("/update", response_class=RedirectResponse) @@ -117,7 +117,7 @@ def update_role( session.commit() session.refresh(db_role) - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) @router.post("/delete", response_class=RedirectResponse) @@ -149,4 +149,4 @@ def delete_role( session.delete(db_role) session.commit() - return RedirectResponse(url="/profile", status_code=303) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) diff --git a/routers/static_pages.py b/routers/static_pages.py index 03c27c0..207ee71 100644 --- a/routers/static_pages.py +++ b/routers/static_pages.py @@ -9,9 +9,9 @@ # Define valid static pages to prevent arbitrary template access VALID_PAGES = { - "about": "about.html", - "privacy-policy": "privacy_policy.html", - "terms-of-service": "terms_of_service.html" + "about": "static_pages/about.html", + "privacy-policy": "static_pages/privacy_policy.html", + "terms-of-service": "static_pages/terms_of_service.html" } @router.get("/{page_name}", name="read_static_page") diff --git a/routers/user.py b/routers/user.py index 629df25..436d13f 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,12 +1,19 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File, Request +from fastapi import APIRouter, Depends, Form, UploadFile, File, Request, HTTPException from fastapi.responses import RedirectResponse, Response -from sqlmodel import Session -from typing import Optional +from sqlmodel import Session, select +from typing import Optional, List from fastapi.templating import Jinja2Templates -from utils.models import User, DataIntegrityError +from sqlalchemy.orm import selectinload +from utils.models import User, DataIntegrityError, Role, Organization from utils.db import get_session -from utils.dependencies import get_authenticated_user +from utils.dependencies import get_authenticated_user, get_user_with_relations from utils.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES +from utils.enums import ValidPermissions +from exceptions.http_exceptions import ( + InsufficientPermissionsError, + UserNotFoundError, + OrganizationNotFoundError +) router = APIRouter(prefix="/user", tags=["user"]) templates = Jinja2Templates(directory="templates") @@ -18,7 +25,7 @@ @router.get("/profile") async def read_profile( request: Request, - user: User = Depends(get_authenticated_user), + user: User = Depends(get_user_with_relations), email_update_requested: Optional[str] = "false", email_updated: Optional[str] = "false" ): @@ -78,3 +85,110 @@ async def get_avatar( content=user.avatar_data, media_type=user.avatar_content_type ) + + +@router.post("/role/update", response_class=RedirectResponse) +def update_user_role( + user_id: int = Form(...), + organization_id: int = Form(...), + roles: Optional[List[int]] = Form(None), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +) -> RedirectResponse: + """Update the roles of a user in an organization""" + # Check if the current user has permission to edit user roles + if not user.has_permission(ValidPermissions.EDIT_USER_ROLE, organization_id): + raise InsufficientPermissionsError() + + # Find the organization + organization = session.exec( + select(Organization) + .where(Organization.id == organization_id) + .options(selectinload(Organization.roles)) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Find the target user + target_user = session.exec( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ).first() + + if not target_user: + raise UserNotFoundError() + + # Get all roles for this organization + org_roles = {role.id: role for role in organization.roles} + + # Remove all current organization roles from the user + for role in list(target_user.roles): + if role.organization_id == organization_id: + target_user.roles.remove(role) + + # Add selected roles to the user + if roles: + for role_id in roles: + fetched_role = org_roles.get(role_id) + if fetched_role is not None: + target_user.roles.append(fetched_role) + + session.commit() + + return RedirectResponse( + url=f"/organizations/{organization_id}", + status_code=303 + ) + + +@router.post("/organization/remove", response_class=RedirectResponse) +def remove_user_from_organization( + user_id: int = Form(...), + organization_id: int = Form(...), + user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session) +) -> RedirectResponse: + """Remove a user from an organization by removing all their roles in that organization""" + # Check if the current user has permission to remove users + if not user.has_permission(ValidPermissions.REMOVE_USER, organization_id): + raise InsufficientPermissionsError() + + # Find the organization + organization = session.exec( + select(Organization) + .where(Organization.id == organization_id) + ).first() + + if not organization: + raise OrganizationNotFoundError() + + # Find the target user + target_user = session.exec( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ).first() + + if not target_user: + raise UserNotFoundError() + + # Prevent removing oneself + if target_user.id == user.id: + raise HTTPException( + status_code=400, + detail="You cannot remove yourself from the organization" + ) + + # Remove all organization roles from the user + for role in list(target_user.roles): + if role.organization_id == organization_id: + target_user.roles.remove(role) + + session.commit() + + return RedirectResponse( + url=f"/organizations/{organization_id}", + status_code=303 + ) diff --git a/templates/auth_base.html b/templates/account/auth_base.html similarity index 100% rename from templates/auth_base.html rename to templates/account/auth_base.html diff --git a/templates/authentication/forgot_password.html b/templates/account/forgot_password.html similarity index 100% rename from templates/authentication/forgot_password.html rename to templates/account/forgot_password.html diff --git a/templates/authentication/login.html b/templates/account/login.html similarity index 100% rename from templates/authentication/login.html rename to templates/account/login.html diff --git a/templates/authentication/register.html b/templates/account/register.html similarity index 100% rename from templates/authentication/register.html rename to templates/account/register.html diff --git a/templates/authentication/reset_password.html b/templates/account/reset_password.html similarity index 100% rename from templates/authentication/reset_password.html rename to templates/account/reset_password.html diff --git a/templates/base.html b/templates/base.html index efa4af3..7259491 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,7 +19,7 @@
- {% include 'components/header.html' %} + {% include 'base/partials/header.html' %}
@@ -30,7 +30,7 @@
- {% include 'components/footer.html' %} + {% include 'base/partials/footer.html' %}
diff --git a/templates/components/logo.html b/templates/base/macros/logo.html similarity index 100% rename from templates/components/logo.html rename to templates/base/macros/logo.html diff --git a/templates/components/silhouette.html b/templates/base/macros/silhouette.html similarity index 100% rename from templates/components/silhouette.html rename to templates/base/macros/silhouette.html diff --git a/templates/components/footer.html b/templates/base/partials/footer.html similarity index 100% rename from templates/components/footer.html rename to templates/base/partials/footer.html diff --git a/templates/components/header.html b/templates/base/partials/header.html similarity index 93% rename from templates/components/header.html rename to templates/base/partials/header.html index 9416fbe..6a05733 100644 --- a/templates/components/header.html +++ b/templates/base/partials/header.html @@ -1,5 +1,5 @@ -{% from 'components/logo.html' import render_logo %} -{% from 'components/silhouette.html' import render_silhouette %} +{% from 'base/macros/logo.html' import render_logo %} +{% from 'base/macros/silhouette.html' import render_silhouette %}