diff --git a/.vscode/launch.json b/.vscode/launch.json index 636b6bdc..1a5f7a8d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,13 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, { "name": "Python: FastAPI (debug, no reload)", "type": "debugpy", @@ -22,22 +29,5 @@ "PYTHONUNBUFFERED": "1" } }, - { - "name": "Angular: Chrome", - "type": "chrome", - "request": "launch", - "preLaunchTask": "frontend: dev (npm start)", - "url": "http://localhost:4200", - "webRoot": "${workspaceFolder}/overlord" - } ], - "compounds": [ - { - "name": "Dev: Backend + Frontend", - "configurations": [ - "Python: FastAPI (debug, no reload)", - "Angular: Chrome" - ] - } - ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6703a6fe..00e6877b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,60 +1,8 @@ { + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 "version": "2.0.0", "tasks": [ { - "label": "backend: dev (uvicorn reload)", - "type": "shell", - "command": "poetry run uvicorn brewman.main:app --reload", - "options": { - "cwd": "${workspaceFolder}/brewman" - }, - "problemMatcher": { - "owner": "python", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s+(error|warning):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - ], - "background": { - "activeOnStart": true, - "beginsPattern": ".*Uvicorn running on.*", - "endsPattern": ".*Application startup complete.*" - } - } - }, - { - "label": "backend: debug (no reload)", - "type": "shell", - "command": "poetry run uvicorn brewman.main:app", - "options": { - "cwd": "${workspaceFolder}/brewman" - }, - "problemMatcher": { - "owner": "python", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s+(error|warning):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - ], - "background": { - "activeOnStart": true, - "beginsPattern": ".*Uvicorn running on.*", - "endsPattern": ".*Application startup complete.*" - } - } - }, - { - "label": "frontend: dev (npm start)", "type": "npm", "script": "start", "isBackground": true, @@ -67,21 +15,34 @@ "background": { "activeOnStart": true, "beginsPattern": { - "regexp": "Changes detected" + "regexp": "(.*?)" }, "endsPattern": { - "regexp": "bundle generation (complete|failed)" + "regexp": "bundle generation complete" } } } }, { - "label": "dev: run both", - "dependsOn": [ - "backend: dev (uvicorn reload)", - "frontend: dev (npm start)" - ], - "dependsOrder": "parallel" + "type": "npm", + "script": "test", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}/overlord" + }, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } } ] } \ No newline at end of file diff --git a/brewman/alembic/versions/5facd5f8a04c_sku_integrity.py b/brewman/alembic/versions/5facd5f8a04c_sku_integrity.py new file mode 100644 index 00000000..f115132d --- /dev/null +++ b/brewman/alembic/versions/5facd5f8a04c_sku_integrity.py @@ -0,0 +1,124 @@ +"""sku_integrity + +Revision ID: 5facd5f8a04c +Revises: b6aee7be6d68 +Create Date: 2026-02-28 13:37:09.434999 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "5facd5f8a04c" +down_revision = "b6aee7be6d68" +branch_labels = None +depends_on = None + + +def upgrade(): + # 1) Add the column (nullable for backfill) + op.add_column("sku_versions", sa.Column("product_id", sa.UUID(), nullable=True)) + # 2) Define lightweight table objects (no autoload; explicit columns only) + sku_versions = sa.Table( + "sku_versions", + sa.MetaData(), + sa.Column("id", sa.UUID(), primary_key=True), + sa.Column("sku_id", sa.UUID(), nullable=False), + sa.Column("product_id", sa.UUID(), nullable=True), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_till", sa.Date(), nullable=True), + sa.Column("units", sa.Unicode(), nullable=False), + ) + + stock_keeping_units = sa.Table( + "stock_keeping_units", + sa.MetaData(), + sa.Column("id", sa.UUID(), primary_key=True), + sa.Column("product_id", sa.UUID(), nullable=False), + ) + + # 3) Backfill via SQLAlchemy Core UPDATE..SET..(SELECT ...) + product_id_subq = ( + sa.select(stock_keeping_units.c.product_id) + .where(stock_keeping_units.c.id == sku_versions.c.sku_id) + .scalar_subquery() + ) + + backfill_stmt = ( + sa.update(sku_versions).values(product_id=product_id_subq).where(sku_versions.c.product_id.is_(None)) + ) + op.execute(backfill_stmt) + + # 5) Enforce NOT NULL at schema level + op.alter_column("sku_versions", "product_id", nullable=False) + + # 6) Trigger keeps product_id in sync when sku_id changes + op.execute( + sa.text( + """ + CREATE OR REPLACE FUNCTION sku_versions_set_product_id() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + SELECT s.product_id INTO NEW.product_id + FROM stock_keeping_units s + WHERE s.id = NEW.sku_id; + + IF NEW.product_id IS NULL THEN + RAISE EXCEPTION 'Invalid sku_id %, no product found', NEW.sku_id; + END IF; + + RETURN NEW; + END; + $$; + """ + ) + ) + + op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;")) + op.execute( + sa.text( + """ + CREATE TRIGGER trg_sku_versions_set_product_id + BEFORE INSERT OR UPDATE OF sku_id + ON sku_versions + FOR EACH ROW + EXECUTE FUNCTION sku_versions_set_product_id(); + """ + ) + ) + + # 7) Exclusion constraint: product_id + units must not overlap in time + # daterange(valid_from, valid_till, '[]') overlap operator &&. + sv = sa.table( + "sku_versions", + sa.column("product_id", sa.UUID()), + sa.column("units", sa.UUID()), + sa.column("valid_from", sa.Date()), + sa.column("valid_till", sa.Date()), + ) + + op.create_foreign_key( + op.f("fk_sku_versions_product_id_products"), "sku_versions", "products", ["product_id"], ["id"] + ) + + op.create_exclude_constraint( + op.f("uq_sku_versions_product_id_units"), + "sku_versions", + (sv.c.product_id, "="), + (sv.c.units, "="), + (sa.func.daterange(sv.c.valid_from, sv.c.valid_till, sa.text("'[]'")), "&&"), + ) + + +def downgrade(): + op.drop_constraint("uq_sku_versions_product_id_units", "sku_versions", type_="unique") + + # Drop trigger + function + op.execute(sa.text("DROP TRIGGER IF EXISTS trg_sku_versions_set_product_id ON sku_versions;")) + op.execute(sa.text("DROP FUNCTION IF EXISTS sku_versions_set_product_id();")) + + # Drop column + op.drop_column("sku_versions", "product_id") diff --git a/brewman/alembic/versions/b6aee7be6d68_role_includes.py b/brewman/alembic/versions/b6aee7be6d68_role_includes.py new file mode 100644 index 00000000..62a732da --- /dev/null +++ b/brewman/alembic/versions/b6aee7be6d68_role_includes.py @@ -0,0 +1,40 @@ +"""role includes + +Revision ID: b6aee7be6d68 +Revises: e4ee5746aea5 +Create Date: 2026-02-28 13:16:02.073095 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "b6aee7be6d68" +down_revision = "e4ee5746aea5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "role_includes", + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("role_id", sa.Uuid(), nullable=False), + sa.Column("included_role_id", sa.Uuid(), nullable=False), + sa.CheckConstraint("role_id <> included_role_id", name=op.f("ck_role_includes_no_self_include")), + sa.ForeignKeyConstraint( + ["included_role_id"], ["roles.id"], name=op.f("fk_role_includes_included_role_id_roles") + ), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], name=op.f("fk_role_includes_role_id_roles")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role_includes")), + sa.UniqueConstraint("role_id", "included_role_id", name=op.f("uq_role_includes_role_id")), + sa.Index(op.f("ix_role_includes_role_id"), "role_id"), + sa.Index(op.f("ix_role_includes_included_role_id"), "included_role_id"), + ) + + +def downgrade(): + op.drop_table("role_includes") diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index cebcac17..7b268919 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -31,7 +31,9 @@ from ..models.recipe_item import RecipeItem from ..models.recipe_tag import RecipeTag from ..models.recipe_template import RecipeTemplate from ..models.role import Role +from ..models.role_include import RoleInclude from ..models.role_permission import RolePermission +from ..models.sku_version import SkuVersion from ..models.stock_keeping_unit import StockKeepingUnit from ..models.tag import Tag from ..models.user import User @@ -75,7 +77,9 @@ __all__ = [ "RecipeTemplate", "reg", "Role", + "RoleInclude", "RolePermission", + "SkuVersion", "StockKeepingUnit", "Tag", "User", diff --git a/brewman/brewman/db/friendly_db_error.py b/brewman/brewman/db/friendly_db_error.py index 5de77718..8bddf0c2 100644 --- a/brewman/brewman/db/friendly_db_error.py +++ b/brewman/brewman/db/friendly_db_error.py @@ -26,139 +26,154 @@ _SQLSTATE_DEFAULTS: dict[str, tuple[int, str, str]] = { # Constraint-name → friendly mapping for your schema # Keep this as the "source of truth" for frontend-friendly messages. _CONSTRAINT_FRIENDLY: dict[str, FriendlyDBError] = { - # --- customers --- - "uq_customers_phone": FriendlyDBError( - 409, "CUSTOMER_PHONE_EXISTS", "A customer with this phone number already exists." + # --- account_types --- + "uq_account_types_name": FriendlyDBError( + 409, "ACCOUNT_TYPE_NAME_EXISTS", "An account type with this name already exists." ), - "ck_customers_phone_not_blank": FriendlyDBError(400, "CUSTOMER_PHONE_BLANK", "Phone number cannot be blank."), - "ck_customers_name_not_blank": FriendlyDBError(400, "CUSTOMER_NAME_BLANK", "Customer name cannot be blank."), - # --- devices / tables / sections / printers / settings --- - "uq_devices_name": FriendlyDBError(409, "DEVICE_NAME_EXISTS", "A device with this name already exists."), - "uq_food_tables_name": FriendlyDBError(409, "FOOD_TABLE_NAME_EXISTS", "A table with this name already exists."), - "uq_sections_name": FriendlyDBError(409, "SECTION_NAME_EXISTS", "A section with this name already exists."), - "uq_printers_name": FriendlyDBError(409, "PRINTER_NAME_EXISTS", "A printer with this name already exists."), - "uq_settings_name": FriendlyDBError(409, "SETTING_NAME_EXISTS", "A setting with this name already exists."), - # --- regimes / taxes / sale_categories --- - "uq_regimes_name": FriendlyDBError(409, "REGIME_NAME_EXISTS", "A regime with this name already exists."), - "uq_regimes_prefix": FriendlyDBError(409, "REGIME_PREFIX_EXISTS", "A regime with this prefix already exists."), - "uq_taxes_name": FriendlyDBError(409, "TAX_NAME_EXISTS", "A tax with this name already exists."), - "uq_sale_categories_name": FriendlyDBError( - 409, "SALE_CATEGORY_NAME_EXISTS", "A sale category with this name already exists." + # --- accounts --- + "uq_accounts_name": FriendlyDBError(409, "ACCOUNT_NAME_EXISTS", "An account with this name already exists."), + # --- clients --- + "uq_clients_code": FriendlyDBError(409, "CLIENT_CODE_EXISTS", "A client with this code already exists."), + "uq_clients_name": FriendlyDBError(409, "CLIENT_NAME_EXISTS", "A client with this name already exists."), + # --- closing_stocks / cost_centres --- + "uq_closing_stocks_date": FriendlyDBError( + 409, "CLOSING_STOCKS_DATE_EXISTS", "Closing stock already exists for this date." ), - # --- roles / permissions / settle_options --- - "uq_roles_name": FriendlyDBError(409, "ROLE_NAME_EXISTS", "A role with this name already exists."), - "uq_permissions_name": FriendlyDBError( - 409, "PERMISSION_NAME_EXISTS", "A permission with this name already exists." + "uq_cost_centres_name": FriendlyDBError( + 409, "COST_CENTRE_NAME_EXISTS", "A cost centre with this name already exists." ), - "uq_settle_options_name": FriendlyDBError( - 409, "SETTLE_OPTION_NAME_EXISTS", "A settle option with this name already exists." + # --- fingerprints --- + "uq_fingerprints_date": FriendlyDBError( + 409, "FINGERPRINT_DATE_EXISTS", "Fingerprint entry already exists for this date." ), - # --- kots / vouchers / bills / overview / settlements / reprints --- - "uq_kots_code": FriendlyDBError(409, "KOT_CODE_EXISTS", "This KOT code already exists."), - "uq_vouchers_kot_id": FriendlyDBError(409, "VOUCHER_FOR_KOT_EXISTS", "A voucher already exists for this KOT."), - "uq_bills_voucher_id": FriendlyDBError(409, "BILL_FOR_VOUCHER_EXISTS", "A bill already exists for this voucher."), - "uq_bills_regime_id": FriendlyDBError(409, "BILL_REGIME_CONFLICT", "Bill regime conflicts with an existing bill."), - "uq_overview_voucher_id": FriendlyDBError( - 409, "OVERVIEW_FOR_VOUCHER_EXISTS", "An overview already exists for this voucher." + # --- inventories --- + "uq_inventories_voucher_id": FriendlyDBError( + 409, "INVENTORY_FOR_VOUCHER_EXISTS", "Inventory entry already exists for this voucher." ), - "uq_overview_food_table_id": FriendlyDBError( - 409, "OVERVIEW_FOR_TABLE_EXISTS", "An overview already exists for this table." - ), - "uq_overview_guest_book_id": FriendlyDBError( - 409, "OVERVIEW_FOR_GUEST_EXISTS", "An overview already exists for this guest entry." - ), - "uq_settlements_voucher_id": FriendlyDBError( - 409, "SETTLEMENT_FOR_VOUCHER_EXISTS", "A settlement already exists for this voucher." - ), - # --- login_history / user_roles / role_includes / role_permissions --- + # --- login_history --- "uq_login_history_user_id": FriendlyDBError( 409, "LOGIN_HISTORY_EXISTS", "Login history already exists for this user." ), + # --- mozimo_stock_register --- + "uq_mozimo_stock_register_date": FriendlyDBError( + 409, "STOCK_REGISTER_DATE_EXISTS", "Stock register entry already exists for this date." + ), + # --- periods (EXCLUDE) --- + "uq_periods_valid_from": FriendlyDBError(409, "PERIOD_OVERLAP", "The period overlaps with an existing period."), + # --- permissions / roles / user_roles --- + "uq_permissions_name": FriendlyDBError( + 409, "PERMISSION_NAME_EXISTS", "A permission with this name already exists." + ), + "uq_roles_name": FriendlyDBError(409, "ROLE_NAME_EXISTS", "A role with this name already exists."), "uq_user_roles_user_id": FriendlyDBError( 409, "USER_ROLE_EXISTS", "A role assignment already exists for this user." ), - "uq_role_includes_role_id": FriendlyDBError( - 409, "ROLE_INCLUDE_EXISTS", "This role-include relation already exists." + # --- prices / product_groups --- + "uq_prices_period_id": FriendlyDBError(409, "PRICE_FOR_PERIOD_EXISTS", "A price already exists for this period."), + "uq_product_groups_name": FriendlyDBError( + 409, "PRODUCT_GROUP_NAME_EXISTS", "A product group with this name already exists." ), - "uq_role_permissions_permission_id": FriendlyDBError( - 409, "ROLE_PERMISSION_EXISTS", "This permission is already assigned." - ), - # --- customer_discount --- - "uq_customer_discount_customer_id": FriendlyDBError( - 409, "CUSTOMER_DISCOUNT_EXISTS", "Discount is already configured for this customer." - ), - # --- inventory_modifiers / inventories --- - "uq_inventory_modifiers_inventory_id": FriendlyDBError( - 409, "INVENTORY_MODIFIERS_EXISTS", "Modifiers already exist for this inventory item." - ), - "uq_inventories_kot_id": FriendlyDBError( - 409, "INVENTORY_FOR_KOT_EXISTS", "Inventory entry already exists for this KOT." - ), - # --- menu_categories / modifier_categories / modifiers --- - "uq_menu_categories_name": FriendlyDBError( - 409, "MENU_CATEGORY_NAME_EXISTS", "A menu category with this name already exists." - ), - "uq_modifier_categories_name": FriendlyDBError( - 409, "MODIFIER_CATEGORY_NAME_EXISTS", "A modifier category with this name already exists." - ), - "uq_modifiers_name": FriendlyDBError(409, "MODIFIER_NAME_EXISTS", "A modifier with this name already exists."), - # --- exclude constraints (time-range overlaps / duplicates) --- - "uq_bundle_items_bundle_id_item_id": FriendlyDBError( - 409, "BUNDLE_ITEM_EXISTS", "This item is already in the bundle." + # --- product_versions (EXCLUDE) --- + "uq_product_versions_handle": FriendlyDBError( + 409, "PRODUCT_HANDLE_OVERLAP", "Product handle overlaps with an existing version in the selected date range." ), "uq_product_versions_name": FriendlyDBError( - 409, - "PRODUCT_VERSION_NAME_OVERLAP", - "Product name overlaps with an existing version in the selected date range.", + 409, "PRODUCT_NAME_OVERLAP", "Product name overlaps with an existing version in the selected date range." ), "uq_product_versions_product_id": FriendlyDBError( 409, "PRODUCT_VERSION_OVERLAP", "Product version overlaps with an existing version in the selected date range." ), + # --- rate contracts / recipes / tags --- + "uq_rate_contract_items_rate_contract_id": FriendlyDBError( + 409, "RATE_CONTRACT_ITEM_EXISTS", "This item already exists in the rate contract." + ), + "uq_recipe_items_recipe_id": FriendlyDBError(409, "RECIPE_ITEMS_EXISTS", "Items already exist for this recipe."), + "uq_recipe_tags_recipe_id": FriendlyDBError(409, "RECIPE_TAGS_EXISTS", "Tags already exist for this recipe."), + "uq_recipe_templates_name": FriendlyDBError( + 409, "RECIPE_TEMPLATE_NAME_EXISTS", "A recipe template with this name already exists." + ), + "uq_recipes_sku_id": FriendlyDBError(409, "RECIPE_FOR_SKU_EXISTS", "A recipe already exists for this SKU."), + "uq_tags_name": FriendlyDBError(409, "TAG_NAME_EXISTS", "A tag with this name already exists."), + # --- role_permissions --- + "uq_role_permissions_permission_id": FriendlyDBError( + 409, "ROLE_PERMISSION_EXISTS", "This permission is already assigned." + ), + # --- settings --- + "uq_settings_name": FriendlyDBError(409, "SETTING_NAME_EXISTS", "A setting with this name already exists."), + # --- users --- + "uq_users_username": FriendlyDBError(409, "USERNAME_EXISTS", "This username is already taken."), + "uq_users_name": FriendlyDBError( + 409, "USER_NAME_EXISTS", "A user with this name already exists." + ), # UNIQUE INDEX (lower(name)) + # --- voucher_tags --- + "uq_voucher_tags_voucher_id": FriendlyDBError(409, "VOUCHER_TAGS_EXISTS", "Tags already exist for this voucher."), + # --- UNIQUE INDEXES (partial/expression) --- "uq_sku_versions_product_id_units": FriendlyDBError( 409, "SKU_PRODUCT_UNITS_OVERLAP", "This unit already exists for the product in the selected date range." ), - "uq_sku_versions_sku_id_units": FriendlyDBError( - 409, "SKU_UNITS_OVERLAP", "This unit already exists for the SKU in the selected date range." + "only_one_valid_attendance": FriendlyDBError( + 409, "ATTENDANCE_EXISTS", "A valid attendance already exists for this employee on this date." ), - "uq_sku_versions_sku_id": FriendlyDBError( - 409, "SKU_VERSION_OVERLAP", "SKU version overlaps with an existing version in the selected date range." + "only_one_selected_template": FriendlyDBError( + 409, "TEMPLATE_ALREADY_SELECTED", "Only one template can be selected at a time." ), } - -# --- UNIQUE INDEXES (not constraints) --- -_UNIQUE_INDEX_FRIENDLY: dict[str, FriendlyDBError] = { - "uq_users_name": FriendlyDBError( - 409, - "USER_NAME_EXISTS", - "A user with this name already exists.", - ), -} - -# Optional: FK constraint names → friendlier messages (instead of generic “referenced record not found”) -# Add only the ones where you want specific UX. +# Optional: FK-specific friendly messages (use when you want better UX than generic FK failure) _FK_FRIENDLY: dict[str, FriendlyDBError] = { - "fk_guest_book_customer_id_customers": FriendlyDBError(409, "CUSTOMER_NOT_FOUND", "Customer does not exist."), - "fk_vouchers_customer_id_customers": FriendlyDBError(409, "CUSTOMER_NOT_FOUND", "Customer does not exist."), - "fk_vouchers_food_table_id_food_tables": FriendlyDBError(409, "FOOD_TABLE_NOT_FOUND", "Food table does not exist."), - "fk_kots_food_table_id_food_tables": FriendlyDBError(409, "FOOD_TABLE_NOT_FOUND", "Food table does not exist."), - "fk_kots_user_id_users": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), - "fk_vouchers_user_id_users": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), - "fk_devices_section_id_sections": FriendlyDBError(409, "SECTION_NOT_FOUND", "Section does not exist."), - "fk_food_tables_section_id_sections": FriendlyDBError(409, "SECTION_NOT_FOUND", "Section does not exist."), - "fk_sku_versions_menu_category_id_menu_categories": FriendlyDBError( - 409, "MENU_CATEGORY_NOT_FOUND", "Menu category does not exist." + # accounts + "fk_accounts_type_account_types": FriendlyDBError(409, "ACCOUNT_TYPE_NOT_FOUND", "Account type does not exist."), + "accounts_cost_centre_id_fkey": FriendlyDBError(409, "COST_CENTRE_NOT_FOUND", "Cost centre does not exist."), + # closing_stocks + "fk_closing_stocks_cost_centre_id_cost_centres": FriendlyDBError( + 409, "COST_CENTRE_NOT_FOUND", "Cost centre does not exist." ), + "fk_closing_stocks_sku_id_stock_keeping_units": FriendlyDBError(409, "SKU_NOT_FOUND", "SKU does not exist."), + # batches / recipes / sku + "fk_batches_sku_id_stock_keeping_units": FriendlyDBError(409, "SKU_NOT_FOUND", "SKU does not exist."), + "fk_recipes_sku_id_stock_keeping_units": FriendlyDBError(409, "SKU_NOT_FOUND", "SKU does not exist."), "fk_stock_keeping_units_product_id_products": FriendlyDBError(409, "PRODUCT_NOT_FOUND", "Product does not exist."), + "fk_prices_product_id_products": FriendlyDBError(409, "PRODUCT_NOT_FOUND", "Product does not exist."), "fk_product_versions_product_id_products": FriendlyDBError(409, "PRODUCT_NOT_FOUND", "Product does not exist."), - "fk_products_sale_category_id_sale_categories": FriendlyDBError( - 409, "SALE_CATEGORY_NOT_FOUND", "Sale category does not exist." + # periods / prices + "fk_prices_period_id_periods": FriendlyDBError(409, "PERIOD_NOT_FOUND", "Period does not exist."), + # product versions dependencies + "fk_product_versions_account_id_accounts": FriendlyDBError(409, "ACCOUNT_NOT_FOUND", "Account does not exist."), + "fk_product_versions_product_group_id_product_groups": FriendlyDBError( + 409, "PRODUCT_GROUP_NOT_FOUND", "Product group does not exist." ), - "fk_customer_discount_sale_category_id_sale_categories": FriendlyDBError( - 409, "SALE_CATEGORY_NOT_FOUND", "Sale category does not exist." + # voucher_tags + "fk_voucher_tags_tag_id_tags": FriendlyDBError(409, "TAG_NOT_FOUND", "Tag does not exist."), + # your voucher FK names are generic "*_fkey"; keep them if you want: + "fk_voucher_tags_voucher_id_vouchers": FriendlyDBError(409, "VOUCHER_NOT_FOUND", "Voucher does not exist."), + # rate contracts + "fk_rate_contracts_user_id_users": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), + "fk_rate_contracts_vendor_id_accounts": FriendlyDBError(409, "VENDOR_NOT_FOUND", "Vendor account does not exist."), + "fk_rate_contract_items_rate_contract_id_rate_contracts": FriendlyDBError( + 409, "RATE_CONTRACT_NOT_FOUND", "Rate contract does not exist." ), - "fk_sale_categories_tax_id_taxes": FriendlyDBError(409, "TAX_NOT_FOUND", "Tax does not exist."), - "fk_taxes_regime_id_regimes": FriendlyDBError(409, "REGIME_NOT_FOUND", "Regime does not exist."), + "fk_rate_contract_items_sku_id_stock_keeping_units": FriendlyDBError(409, "SKU_NOT_FOUND", "SKU does not exist."), + # journals / inventories / attendance / auth + "journals_account_id_fkey": FriendlyDBError(409, "ACCOUNT_NOT_FOUND", "Account does not exist."), + "entities_journals_CostCenterID_fkey": FriendlyDBError(409, "COST_CENTRE_NOT_FOUND", "Cost centre does not exist."), + "journals_VoucherID_fkey": FriendlyDBError(409, "VOUCHER_NOT_FOUND", "Voucher does not exist."), + "inventories_VoucherID_fkey": FriendlyDBError(409, "VOUCHER_NOT_FOUND", "Voucher does not exist."), + "entities_inventories_BatchID_fkey": FriendlyDBError(409, "BATCH_NOT_FOUND", "Batch does not exist."), + "auth_login_history_client_id_fkey": FriendlyDBError(409, "CLIENT_NOT_FOUND", "Client does not exist."), + "auth_login_history_user_id_fkey": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), + "entities_attendances_EmployeeID_fkey": FriendlyDBError(409, "EMPLOYEE_NOT_FOUND", "Employee does not exist."), + "entities_attendances_UserID_fkey": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), + "entities_fingerprints_EmployeeID_fkey": FriendlyDBError(409, "EMPLOYEE_NOT_FOUND", "Employee does not exist."), + # employees + "employees_id_fkey": FriendlyDBError(409, "EMPLOYEE_NOT_FOUND", "Employee does not exist."), + # incentives / employee_benefit + "fk_incentives_journal_id_journals": FriendlyDBError(409, "JOURNAL_NOT_FOUND", "Journal does not exist."), + "fk_incentives_voucher_id_vouchers": FriendlyDBError(409, "VOUCHER_NOT_FOUND", "Voucher does not exist."), + "fk_employee_benefit_journal_id_journals": FriendlyDBError(409, "JOURNAL_NOT_FOUND", "Journal does not exist."), + "fk_employee_benefit_voucher_id_vouchers": FriendlyDBError(409, "VOUCHER_NOT_FOUND", "Voucher does not exist."), + # vouchers + "vouchers_user_id_fkey": FriendlyDBError(409, "USER_NOT_FOUND", "User does not exist."), + "vouchers_poster_id_fkey": FriendlyDBError(409, "USER_NOT_FOUND", "Poster user does not exist."), } @@ -191,10 +206,6 @@ def constraint_to_friendly(exc: IntegrityError) -> FriendlyDBError: e = _CONSTRAINT_FRIENDLY[constraint] return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint) - if constraint in _UNIQUE_INDEX_FRIENDLY: - e = _UNIQUE_INDEX_FRIENDLY[constraint] - return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint) - if constraint in _FK_FRIENDLY: e = _FK_FRIENDLY[constraint] return FriendlyDBError(e.status, e.code, e.detail, constraint=constraint) diff --git a/brewman/brewman/models/role.py b/brewman/brewman/models/role.py index 2f439426..cbcb1ca3 100644 --- a/brewman/brewman/models/role.py +++ b/brewman/brewman/models/role.py @@ -6,6 +6,7 @@ from sqlalchemy import Unicode, Uuid, text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg +from .role_include import RoleInclude from .role_permission import RolePermission @@ -28,6 +29,23 @@ class Role: back_populates="roles", ) + included_roles: Mapped[list[Role]] = relationship( + "Role", + secondary=RoleInclude.__table__, # type: ignore[attr-defined] + primaryjoin=(id == RoleInclude.role_id), + secondaryjoin=(id == RoleInclude.included_role_id), + back_populates="included_by_roles", + ) + + # "included_by_roles" = roles that include THIS role (parents) + included_by_roles: Mapped[list[Role]] = relationship( + "Role", + secondary=RoleInclude.__table__, # type: ignore[attr-defined] + primaryjoin=(id == RoleInclude.included_role_id), + secondaryjoin=(id == RoleInclude.role_id), + back_populates="included_roles", + ) + def __init__(self, name: str, id_: uuid.UUID | None = None) -> None: self.name = name if id_ is not None: diff --git a/brewman/brewman/models/role_include.py b/brewman/brewman/models/role_include.py new file mode 100644 index 00000000..0044e4a2 --- /dev/null +++ b/brewman/brewman/models/role_include.py @@ -0,0 +1,28 @@ +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint, Uuid, text +from sqlalchemy.orm import Mapped, mapped_column + +from ..db.base_class import reg + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class RoleInclude: + """ + A "role includes role" edge: + - role_id (parent/composite role) + - included_role_id (child role that is included) + + This lets Role A inherit all permissions of Role B (and B's included roles, recursively). + """ + + __tablename__ = "role_includes" + __table_args__ = ( + UniqueConstraint("role_id", "included_role_id"), + CheckConstraint("role_id <> included_role_id", name="no_self_include"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) + + role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True) + included_role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False, index=True) diff --git a/brewman/brewman/models/sku_version.py b/brewman/brewman/models/sku_version.py index 26c56071..45d2e0dc 100644 --- a/brewman/brewman/models/sku_version.py +++ b/brewman/brewman/models/sku_version.py @@ -25,6 +25,15 @@ class SkuVersion: Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") ) # product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) # should i remove this? + # DB column is "product_id", but ORM attribute is private: "_product_id" + _product_id: Mapped[uuid.UUID] = mapped_column( + "product_id", + Uuid, + ForeignKey("products.id"), + nullable=False, + repr=False, # hides in dataclass repr + init=False, # not part of __init__ signature (dataclass) + ) sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) units: Mapped[str] = mapped_column(Unicode, nullable=False) fraction: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False) @@ -38,15 +47,16 @@ class SkuVersion: sku: Mapped[StockKeepingUnit] = relationship("StockKeepingUnit", back_populates="versions") __table_args__ = ( - # postgresql.ExcludeConstraint( - # (product_id, "="), - # (units, "="), - # (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), - # ), # type: ignore[no-untyped-call] postgresql.ExcludeConstraint( (sku_id, "="), (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), ), + # product-level uniqueness per time range + postgresql.ExcludeConstraint( + (_product_id, "="), + (units, "="), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + ), ) def __init__( diff --git a/brewman/brewman/models/stock_keeping_unit.py b/brewman/brewman/models/stock_keeping_unit.py index 7968efa7..0a1c5d1c 100644 --- a/brewman/brewman/models/stock_keeping_unit.py +++ b/brewman/brewman/models/stock_keeping_unit.py @@ -24,7 +24,7 @@ class StockKeepingUnit: id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") ) - product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), unique=True, nullable=False) + product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) product: Mapped[Product] = relationship("Product", back_populates="skus") batches: Mapped[list[Batch]] = relationship("Batch", back_populates="sku") diff --git a/brewman/brewman/routers/login.py b/brewman/brewman/routers/login.py index 5622481a..da45a878 100644 --- a/brewman/brewman/routers/login.py +++ b/brewman/brewman/routers/login.py @@ -1,3 +1,5 @@ +import uuid + from datetime import UTC, datetime, timedelta from typing import Annotated @@ -14,6 +16,7 @@ from fastapi import ( from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy import delete, or_, select +from sqlalchemy.orm import Session from .. import __version__ from ..core.config import settings @@ -27,12 +30,36 @@ from ..core.security import ( from ..db.session import SessionDep from ..models.client import Client from ..models.login_history import LoginHistory +from ..models.permission import Permission +from ..models.role_include import RoleInclude +from ..models.role_permission import RolePermission +from ..models.user_role import UserRole from ..schemas.user import UserToken router = APIRouter() +def user_effective_permission_scopes(user_id: uuid.UUID, db: Session) -> list[str]: + """ + Returns the effective permission scope strings for a user, considering: + user_roles -> role_includes (recursive) -> role_permissions -> permissions + """ + # Start roles = user's direct roles + roles = select(UserRole.role_id.label("role_id")).where(UserRole.user_id == user_id).cte("roles", recursive=True) + roles = roles.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles.c.role_id)) + + q = ( + select(Permission.name) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles, roles.c.role_id == RolePermission.role_id) + .distinct() + ) + names = list(db.execute(q).scalars().all()) + # match your existing scope normalization + return sorted({n.replace(" ", "-").lower() for n in names}) + + @router.post("/token", response_model=Token) async def login_for_access_token( response: Response, @@ -86,11 +113,11 @@ async def login_for_access_token( not_allowed_response.set_cookie(key="client_id", value=str(client.code), max_age=10 * 365 * 24 * 60 * 60) return not_allowed_response access_token_expires = timedelta(minutes=settings.JWT_TOKEN_EXPIRE_MINUTES) + perm_scopes = user_effective_permission_scopes(user.id, db) access_token = create_access_token( data={ "sub": user.name, - "scopes": ["authenticated"] - + list({p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions}), # noqa: W503 + "scopes": ["authenticated"] + perm_scopes, "userId": str(user.id), "lockedOut": user.locked_out, "ver": __version__.__version__, diff --git a/brewman/brewman/routers/reports/closing_stock.py b/brewman/brewman/routers/reports/closing_stock.py index a41a25bc..2bcce172 100644 --- a/brewman/brewman/routers/reports/closing_stock.py +++ b/brewman/brewman/routers/reports/closing_stock.py @@ -5,7 +5,7 @@ from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, Security, status -from sqlalchemy.orm import Session, contains_eager +from sqlalchemy.orm import Session from sqlalchemy.sql.expression import delete, distinct, func, or_, select, update from ...core.security import get_current_active_user as get_user @@ -124,35 +124,25 @@ def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> list[sc amount_sum = func.sum(Journal.debit * Inventory.quantity * Inventory.rate * (1 + Inventory.tax)).label("amount") quantity_sum = func.sum(Journal.debit * Inventory.quantity).label("quantity") - query: list[tuple[SkuVersion, Decimal, Decimal]] = ( - db.execute( - select(SkuVersion, quantity_sum, amount_sum) - .select_from(Voucher) - .join(Voucher.journals) - .join(Voucher.inventories) - .join(Inventory.batch) - .join(Batch.sku) - .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) - .join(StockKeepingUnit.product) - .join(ProductVersion, onclause=_pv_onclause(Voucher.date_)) - .join(ProductVersion.product_group) - .options( - contains_eager(SkuVersion.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions) - .contains_eager(ProductVersion.product_group) - ) - .where( - Voucher.date_ <= date_, - Journal.cost_centre_id == cost_centre_id, - or_(Voucher.voucher_type != VoucherType.CLOSING_STOCK, Voucher.date_ != date_), - ) - .group_by(StockKeepingUnit, Product, ProductVersion, ProductGroup, SkuVersion) # type: ignore - .order_by(ProductGroup.name, ProductVersion.name, SkuVersion.units) + query = db.execute( + select(StockKeepingUnit.id, ProductVersion.name, SkuVersion.units, ProductGroup.name, quantity_sum, amount_sum) + .select_from(Voucher) + .join(Voucher.journals) + .join(Voucher.inventories) + .join(Inventory.batch) + .join(Batch.sku) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .join(StockKeepingUnit.product) + .join(ProductVersion, onclause=_pv_onclause(date_)) + .join(ProductVersion.product_group) + .where( + Voucher.date_ <= date_, + Journal.cost_centre_id == cost_centre_id, + or_(Voucher.voucher_type != VoucherType.CLOSING_STOCK, Voucher.date_ != date_), ) - .unique() - .all() - ) + .group_by(StockKeepingUnit.id, ProductVersion.name, SkuVersion.units, ProductGroup.name) + .order_by(ProductGroup.name, ProductVersion.name, SkuVersion.units) + ).all() physical_list = ( db.execute( @@ -177,18 +167,16 @@ def build_report(date_: date, cost_centre_id: uuid.UUID, db: Session) -> list[sc ).all() body = [] - for sku_version, quantity, amount in query: + for sku_id, name, units, group_name, quantity, amount in query: if quantity != 0 and amount != 0: - id_ = next((p.id for p in physical_list if p.sku_id == sku_version.sku_id), None) - physical = next((p.quantity for p in physical_list if p.sku_id == sku_version.sku_id), quantity) - cc = next((CostCentreLink(id_=c) for (c, s) in ccs if s == sku_version.sku_id), None) + id_ = next((p.id for p in physical_list if p.sku_id == sku_id), None) + physical = next((p.quantity for p in physical_list if p.sku_id == sku_id), quantity) + cc = next((CostCentreLink(id_=c) for (c, s) in ccs if s == sku_id), None) body.append( schemas.ClosingStockItem( id_=id_, - product=ProductLink( - id_=sku_version.sku_id, name=f"{sku_version.sku.product.versions[0].name} ({sku_version.units})" - ), - group=sku_version.sku.product.versions[0].product_group.name, + product=ProductLink(id_=sku_id, name=f"{name} ({units})"), + group=group_name, quantity=quantity, amount=amount, physical=physical, diff --git a/brewman/brewman/routers/role.py b/brewman/brewman/routers/role.py index cf20b8fe..09ea3a08 100644 --- a/brewman/brewman/routers/role.py +++ b/brewman/brewman/routers/role.py @@ -1,10 +1,9 @@ import uuid -from collections.abc import Sequence from typing import Annotated from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.orm import Session from sqlalchemy.sql.functions import count @@ -12,11 +11,12 @@ from ..core.security import get_current_active_user as get_user from ..db.session import SessionDep from ..models.permission import Permission from ..models.role import Role +from ..models.role_include import RoleInclude from ..models.role_permission import RolePermission from ..models.user_role import UserRole from ..schemas import role as schemas from ..schemas.permission import PermissionItem -from ..schemas.user import UserToken +from ..schemas.user_token import UserToken router = APIRouter() @@ -29,6 +29,7 @@ def save( item = Role(data.name) db.add(item) add_permissions(item, data.permissions, db) + add_included_roles(item, data.included_roles, db) db.commit() return role_info(item, db) @@ -43,19 +44,11 @@ def update_route( item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one() item.name = data.name add_permissions(item, data.permissions, db) + add_included_roles(item, data.included_roles, db) db.commit() return role_info(item, db) -def add_permissions(role: Role, permissions: list[PermissionItem], db: Session) -> None: - for permission in permissions: - gp = next((p for p in role.permissions if p.id == permission.id_), None) - if permission.enabled and gp is None: - role.permissions.append(db.execute(select(Permission).where(Permission.id == permission.id_)).scalar_one()) - elif not permission.enabled and gp: - role.permissions.remove(gp) - - @router.delete("/{id_}", response_model=schemas.RoleBlank) def delete_route( id_: uuid.UUID, user: Annotated[UserToken, Security(get_user, scopes=["users"])], db: SessionDep @@ -70,7 +63,16 @@ def delete_route( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="This Role has permissions and cannot be deleted.", ) - # item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one() + if ( + db.execute( + select(count(RoleInclude.id)).where(or_(RoleInclude.role_id == id_, RoleInclude.included_role_id == id_)) + ).scalar_one() + > 0 + ): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="This Role is used in role composition (includes/being included) and cannot be deleted.", + ) item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one() db.delete(item) db.commit() @@ -83,18 +85,17 @@ def show_blank(user: Annotated[UserToken, Security(get_user, scopes=["users"])], @router.get("/list", response_model=list[schemas.RoleList]) -async def show_list( - user: Annotated[UserToken, Security(get_user, scopes=["users"])], - db: SessionDep, +def show_list( + user: Annotated[UserToken, Security(get_user, scopes=["users"])], db: SessionDep ) -> list[schemas.RoleList]: - roles: Sequence[Role] = db.execute(select(Role).order_by(Role.name)).scalars().all() return [ schemas.RoleList( id_=item.id, name=item.name, permissions=[p.name for p in sorted(item.permissions, key=lambda p: p.name)], + included_roles=[r.name for r in sorted(item.included_roles, key=lambda r: r.name)], ) - for item in roles + for item in db.execute(select(Role).order_by(Role.name)).scalars().all() ] @@ -107,6 +108,8 @@ def show_id( def role_info(item: Role, db: Session) -> schemas.Role: + all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all() + all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all() return schemas.Role( id_=item.id, name=item.name, @@ -116,16 +119,119 @@ def role_info(item: Role, db: Session) -> schemas.Role: name=p.name, enabled=p in item.permissions, ) - for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all() + for p in all_perms + ], + included_roles=[ + schemas.RoleItem( + id_=r.id, + name=r.name, + enabled=r in item.included_roles, + permission_ids=role_effective_permission_ids(r.id, db), + ) + for r in all_roles + if r.id != item.id ], ) def role_blank(db: Session) -> schemas.RoleBlank: + all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all() + all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all() + return schemas.RoleBlank( name="", - permissions=[ - PermissionItem(id_=p.id, name=p.name, enabled=False) - for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all() + permissions=[PermissionItem(id_=p.id, name=p.name, enabled=False) for p in all_perms], + included_roles=[ + schemas.RoleItem( + id_=r.id, + name=r.name, + enabled=False, + permission_ids=role_effective_permission_ids(r.id, db), + ) + for r in all_roles ], ) + + +def add_permissions(role: Role, permissions: list[PermissionItem], db: Session) -> None: + for permission in permissions: + gp = next((p for p in role.permissions if p.id == permission.id_), None) + if permission.enabled and gp is None: + role.permissions.append(db.execute(select(Permission).where(Permission.id == permission.id_)).scalar_one()) + elif not permission.enabled and gp: + role.permissions.remove(gp) + + +def add_included_roles(role: Role, included_roles: list[schemas.RoleItem], db: Session) -> None: + for inc in included_roles: + if inc.id_ == role.id: + raise HTTPException(status_code=400, detail="A role cannot include itself.") + + existing = next((r for r in role.included_roles if r.id == inc.id_), None) + + if inc.enabled and existing is None: + # cycle prevention: if role is already reachable from inc.id_, adding would create a cycle + if role_is_reachable_from(start_role_id=inc.id_, target_role_id=role.id, db=db): + raise HTTPException( + status_code=400, + detail="Invalid include: would create a cycle in role hierarchy.", + ) + + role.included_roles.append(db.execute(select(Role).where(Role.id == inc.id_)).scalar_one()) + + elif not inc.enabled and existing is not None: + role.included_roles.remove(existing) + + +def role_is_reachable_from(start_role_id: uuid.UUID, target_role_id: uuid.UUID, db: Session) -> bool: + """ + Returns True if target_role_id is reachable from start_role_id via role_includes edges. + This prevents cycles when adding: role -> included_role. + """ + cte = ( + select(RoleInclude.included_role_id.label("role_id")) + .where(RoleInclude.role_id == start_role_id) + .cte("role_tree", recursive=True) + ) + cte = cte.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == cte.c.role_id)) + + q = select(count()).select_from(cte).where(cte.c.role_id == target_role_id) + return db.execute(q).scalar_one() > 0 + + +def role_effective_permission_names(role_id: uuid.UUID, db: Session) -> list[str]: + """ + All permissions for this role INCLUDING inherited permissions via included roles (recursive). + """ + # role closure CTE + roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True) + roles_cte = roles_cte.union_all( + select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id) + ) + + q = ( + select(Permission.name) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles_cte, roles_cte.c.role_id == RolePermission.role_id) + .distinct() + .order_by(Permission.name) + ) + return list(db.execute(q).scalars().all()) + + +def role_effective_permission_ids(role_id: uuid.UUID, db: Session) -> list[uuid.UUID]: + """ + All permission IDs for this role INCLUDING inherited permissions via included roles (recursive). + """ + roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True) + roles_cte = roles_cte.union_all( + select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id) + ) + + q = ( + select(Permission.id) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles_cte, roles_cte.c.role_id == RolePermission.role_id) + .distinct() + ) + return list(db.execute(q).scalars().all()) diff --git a/brewman/brewman/schemas/role.py b/brewman/brewman/schemas/role.py index 7fcd4191..cdfbd7e3 100644 --- a/brewman/brewman/schemas/role.py +++ b/brewman/brewman/schemas/role.py @@ -6,9 +6,18 @@ from . import to_camel from .permission import PermissionItem +class RoleItem(BaseModel): + id_: uuid.UUID + name: str + enabled: bool + permission_ids: list[uuid.UUID] = [] + model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True, populate_by_name=True) + + class RoleIn(BaseModel): name: str = Field(..., min_length=1) permissions: list[PermissionItem] + included_roles: list[RoleItem] model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True) @@ -21,13 +30,7 @@ class RoleList(BaseModel): id_: uuid.UUID name: str permissions: list[str] - model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True, populate_by_name=True) - - -class RoleItem(BaseModel): - id_: uuid.UUID - name: str - enabled: bool + included_roles: list[str] model_config = ConfigDict(alias_generator=to_camel, str_strip_whitespace=True, populate_by_name=True) diff --git a/brewman/brewman/schemas/user_token.py b/brewman/brewman/schemas/user_token.py new file mode 100644 index 00000000..8c6d3b24 --- /dev/null +++ b/brewman/brewman/schemas/user_token.py @@ -0,0 +1,11 @@ +import uuid + +from pydantic import BaseModel + + +class UserToken(BaseModel): + id_: uuid.UUID + name: str + locked_out: bool = False + password: str + permissions: list[str] diff --git a/overlord/src/app/role/role-detail/role-detail.component.css b/overlord/src/app/role/role-detail/role-detail.component.css index e69de29b..78028595 100644 --- a/overlord/src/app/role/role-detail/role-detail.component.css +++ b/overlord/src/app/role/role-detail/role-detail.component.css @@ -0,0 +1,9 @@ +.two-col { + display: flex; + gap: 16px; +} + +.col { + flex: 1; + min-width: 0; +} diff --git a/overlord/src/app/role/role-detail/role-detail.component.html b/overlord/src/app/role/role-detail/role-detail.component.html index a0936ae9..5f5ca1a5 100644 --- a/overlord/src/app/role/role-detail/role-detail.component.html +++ b/overlord/src/app/role/role-detail/role-detail.component.html @@ -7,12 +7,24 @@ -
- @for (p of item.permissions; track p; let i = $index) { -
- {{ p.name }} -
- } +
+
+

Permissions

+ @for (p of item.permissions; track p; let i = $index) { +
+ {{ p.name }} +
+ } +
+ +
+

Includes Roles

+ @for (r of item.includedRoles; track r; let i = $index) { +
+ {{ r.name }} +
+ } +
diff --git a/overlord/src/app/role/role-detail/role-detail.component.ts b/overlord/src/app/role/role-detail/role-detail.component.ts index b9b8b6e7..3f0fe251 100644 --- a/overlord/src/app/role/role-detail/role-detail.component.ts +++ b/overlord/src/app/role/role-detail/role-detail.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, inject, OnInit, ViewChild, OnDestroy } from '@angular/core'; import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -8,6 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { Role } from '../role'; @@ -26,13 +27,15 @@ import { RoleService } from '../role.service'; MatButtonModule, ], }) -export class RoleDetailComponent implements OnInit, AfterViewInit { +export class RoleDetailComponent implements OnInit, AfterViewInit, OnDestroy { private route = inject(ActivatedRoute); private router = inject(Router); private snackBar = inject(MatSnackBar); private dialog = inject(MatDialog); private ser = inject(RoleService); + private destroyed$ = new Subject(); + @ViewChild('nameElement', { static: true }) nameElement!: ElementRef; form: FormGroup<{ name: FormControl; @@ -41,6 +44,11 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { permission: FormControl; }> >; + includedRoles: FormArray< + FormGroup<{ + role: FormControl; + }> + >; }>; item: Role = new Role(); @@ -49,6 +57,7 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { this.form = new FormGroup({ name: new FormControl(null), permissions: new FormArray }>>([]), + includedRoles: new FormArray }>>([]), }); } @@ -66,6 +75,26 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { }), ), ); + + this.form.controls.includedRoles.clear(); + this.item.includedRoles.forEach((x) => + this.form.controls.includedRoles.push( + new FormGroup({ + role: new FormControl(x.enabled, { nonNullable: true }), + }), + ), + ); + + // Rebind listeners (important when route data changes) + this.destroyed$.next(); + + // When included roles change, recompute locks + this.form.controls.includedRoles.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(() => { + this.applyIncludedRolePermissionLocks(); + }); + + // Apply initial locks immediately + this.applyIncludedRolePermissionLocks(); }); } @@ -75,6 +104,43 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { }, 0); } + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + private applyIncludedRolePermissionLocks(): void { + // 1) collect all permission IDs implied by enabled included roles + const implied = new Set(); + + this.item.includedRoles.forEach((roleOpt, idx) => { + const checked = this.form.controls.includedRoles.at(idx).controls.role.value; + if (checked) { + (roleOpt.permissionIds || []).forEach((pid) => implied.add(pid)); + } + }); + + // 2) apply to permission checkboxes + this.item.permissions.forEach((perm, idx) => { + const ctrl = this.form.controls.permissions.at(idx).controls.permission; + + const isImplied = implied.has(perm.id as string); + const isDirect = perm.enabled === true; // from backend: direct assignment + + if (isImplied) { + // must be checked + disabled + ctrl.setValue(true, { emitEvent: false }); + ctrl.disable({ emitEvent: false }); + } else { + // not implied -> enable checkbox + ctrl.enable({ emitEvent: false }); + + // restore to direct-enabled state (so user sees what is explicitly on the role) + ctrl.setValue(isDirect, { emitEvent: false }); + } + }); + } + save() { this.ser.saveOrUpdate(this.getItem()).subscribe({ next: () => { @@ -115,9 +181,21 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { getItem(): Role { const formModel = this.form.value; this.item.name = formModel.name ?? ''; - const array = this.form.controls.permissions; - this.item.permissions.forEach((item, index) => { - item.enabled = array.controls[index].value.permission ?? false; + const permArray = this.form.controls.permissions; + this.item.permissions.forEach((p, index) => { + // If disabled, keep p.enabled as it was originally direct (don’t accidentally mark direct) + if (permArray.at(index).controls.permission.disabled) { + // leave p.enabled unchanged + return; + } + p.enabled = permArray.at(index).controls.permission.value; + }); + // this.item.permissions.forEach((item, index) => { + // item.enabled = permArray.controls[index].value.permission ?? false; + // }); + const includeArray = this.form.controls.includedRoles; + this.item.includedRoles.forEach((r, index) => { + r.enabled = includeArray.controls[index]?.value.role ?? false; }); return this.item; } diff --git a/overlord/src/app/role/role-list/role-list.component.html b/overlord/src/app/role/role-list/role-list.component.html index 7f3810bf..bd084d44 100644 --- a/overlord/src/app/role/role-list/role-list.component.html +++ b/overlord/src/app/role/role-list/role-list.component.html @@ -15,9 +15,25 @@ > + + + Includes + + @if (row.includedRoles?.length) { +
    + @for (r of row.includedRoles; track r) { +
  • {{ r }}
  • + } +
+ } @else { + - + } +
+
+ - Permissions + Permissions
    @for (permission of row.permissions; track permission) { diff --git a/overlord/src/app/role/role-list/role-list.component.ts b/overlord/src/app/role/role-list/role-list.component.ts index 0fb2a60f..4e77d4a5 100644 --- a/overlord/src/app/role/role-list/role-list.component.ts +++ b/overlord/src/app/role/role-list/role-list.component.ts @@ -22,7 +22,7 @@ export class RoleListComponent implements OnInit { list: Role[] = []; dataSource: RoleListDatasource = new RoleListDatasource(this.list, this.paginator, this.sort); /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ - displayedColumns = ['name', 'permissions']; + displayedColumns = ['name', 'includedRoles', 'permissions']; ngOnInit() { this.route.data.subscribe((value) => { diff --git a/overlord/src/app/role/role.ts b/overlord/src/app/role/role.ts index 648cb8fb..0282091b 100644 --- a/overlord/src/app/role/role.ts +++ b/overlord/src/app/role/role.ts @@ -1,13 +1,32 @@ import { Permission } from './permission'; +export class RoleItem { + id: string | undefined; + name: string; + enabled: boolean; + + permissionIds: string[]; + + public constructor(init?: Partial) { + this.id = undefined; + this.name = ''; + this.enabled = false; + this.permissionIds = []; + + Object.assign(this, init); + } +} export class Role { id: string | undefined; name: string; permissions: Permission[]; + includedRoles: RoleItem[]; public constructor(init?: Partial) { + this.id = undefined; this.name = ''; this.permissions = []; + this.includedRoles = []; Object.assign(this, init); } }