RABC fejlesztése és beélesítése hibák kijavításával
This commit is contained in:
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
|
||||
|
||||
Migrates legacy person_id-based ownership to the new organization_id / organization_members
|
||||
RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
|
||||
in organization_members, and orphaned vehicles are rescued into their owner's garage.
|
||||
|
||||
TASKS:
|
||||
A) Ensure Personal Garages exist for every user who lacks one
|
||||
B) Bind users to their garages via organization_members (OWNER role) + update scope_id
|
||||
C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
|
||||
|
||||
Usage:
|
||||
docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
|
||||
_raw_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||
)
|
||||
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
|
||||
print("=" * 70)
|
||||
print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||
print()
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
# ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
|
||||
print("📋 TASK A: Ensure Personal Garages Exist")
|
||||
print("-" * 50)
|
||||
|
||||
# Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
|
||||
users_without_garage = await conn.fetch("""
|
||||
SELECT u.id AS user_id,
|
||||
u.email,
|
||||
p.id AS person_id,
|
||||
p.first_name,
|
||||
p.last_name
|
||||
FROM identity.users u
|
||||
JOIN identity.persons p ON p.id = u.person_id
|
||||
WHERE u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM fleet.organizations o
|
||||
WHERE o.owner_id = u.id
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
)
|
||||
ORDER BY u.id
|
||||
""")
|
||||
|
||||
print(f" Found {len(users_without_garage)} users without a personal garage.")
|
||||
garages_created = 0
|
||||
|
||||
for row in users_without_garage:
|
||||
user_id = row['user_id']
|
||||
person_id = row['person_id']
|
||||
first_name = row['first_name'] or "Felhasználó"
|
||||
last_name = row['last_name'] or ""
|
||||
|
||||
garage_name = f"{first_name} Garázsa"
|
||||
folder_slug = f"personal-{user_id}-{person_id}"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Direct INSERT with all NOT NULL columns explicitly provided
|
||||
await conn.execute("""
|
||||
INSERT INTO fleet.organizations
|
||||
(full_name, name, display_name, folder_slug, org_type,
|
||||
owner_id, status, is_active, is_deleted,
|
||||
subscription_plan, base_asset_limit, purchased_extra_slots,
|
||||
notification_settings, external_integration_config,
|
||||
is_ownership_transferable, is_verified,
|
||||
first_registered_at, current_lifecycle_started_at,
|
||||
lifecycle_index, created_at,
|
||||
visual_settings, aliases, tags, settings)
|
||||
VALUES
|
||||
($1, $2, $3, $4, 'individual',
|
||||
$5, 'active', true, false,
|
||||
'FREE', 5, 0,
|
||||
$6::json, '{}'::json,
|
||||
true, false,
|
||||
$7, $8,
|
||||
1, $9,
|
||||
$10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
|
||||
""",
|
||||
garage_name, # $1 full_name
|
||||
garage_name, # $2 name
|
||||
garage_name, # $3 display_name
|
||||
folder_slug, # $4 folder_slug
|
||||
user_id, # $5 owner_id
|
||||
json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
|
||||
now, # $7 first_registered_at
|
||||
now, # $8 current_lifecycle_started_at
|
||||
now, # $9 created_at
|
||||
json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
|
||||
json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
|
||||
)
|
||||
|
||||
garages_created += 1
|
||||
print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
|
||||
|
||||
print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
|
||||
print()
|
||||
|
||||
# ── TASK B: BIND USERS TO GARAGES (RBAC) ──
|
||||
print("📋 TASK B: Bind Users to Garages (RBAC)")
|
||||
print("-" * 50)
|
||||
|
||||
# Get all active users with their personal garages
|
||||
users_and_garages = await conn.fetch("""
|
||||
SELECT u.id AS user_id,
|
||||
u.email,
|
||||
u.scope_id,
|
||||
o.id AS org_id,
|
||||
o.name AS org_name
|
||||
FROM identity.users u
|
||||
JOIN fleet.organizations o ON o.owner_id = u.id
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
WHERE u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
ORDER BY u.id
|
||||
""")
|
||||
|
||||
members_added = 0
|
||||
scopes_updated = 0
|
||||
|
||||
for row in users_and_garages:
|
||||
user_id = row['user_id']
|
||||
org_id = row['org_id']
|
||||
scope_id = row['scope_id']
|
||||
|
||||
# Check if user is already a member of this org
|
||||
existing_member = await conn.fetchrow("""
|
||||
SELECT id FROM fleet.organization_members
|
||||
WHERE organization_id = $1 AND user_id = $2
|
||||
""", org_id, user_id)
|
||||
|
||||
if not existing_member:
|
||||
await conn.execute("""
|
||||
INSERT INTO fleet.organization_members
|
||||
(organization_id, user_id, role, status,
|
||||
is_permanent, is_verified, created_at)
|
||||
VALUES
|
||||
($1, $2, 'OWNER', 'active',
|
||||
true, true, NOW())
|
||||
""", org_id, user_id)
|
||||
members_added += 1
|
||||
print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
|
||||
else:
|
||||
# Ensure role is OWNER
|
||||
await conn.execute("""
|
||||
UPDATE fleet.organization_members
|
||||
SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
|
||||
WHERE organization_id = $1 AND user_id = $2
|
||||
""", org_id, user_id)
|
||||
print(f" ℹ️ Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
|
||||
|
||||
# Update scope_id if NULL
|
||||
if scope_id is None or scope_id == "":
|
||||
await conn.execute("""
|
||||
UPDATE identity.users
|
||||
SET scope_id = $1::text
|
||||
WHERE id = $2
|
||||
""", str(org_id), user_id)
|
||||
scopes_updated += 1
|
||||
print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
|
||||
|
||||
print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
|
||||
print()
|
||||
|
||||
# ── TASK C: RESCUE ORPHANED VEHICLES ──
|
||||
print("📋 TASK C: Rescue Orphaned Vehicles")
|
||||
print("-" * 50)
|
||||
|
||||
# Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
|
||||
orphaned_vehicles = await conn.fetch("""
|
||||
SELECT a.id AS asset_id,
|
||||
a.license_plate,
|
||||
a.vin,
|
||||
a.owner_person_id,
|
||||
a.current_organization_id,
|
||||
a.owner_org_id,
|
||||
a.brand,
|
||||
a.model
|
||||
FROM vehicle.assets a
|
||||
WHERE a.owner_person_id IS NOT NULL
|
||||
AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
|
||||
AND a.status != 'deleted'
|
||||
ORDER BY a.owner_person_id, a.id
|
||||
""")
|
||||
|
||||
print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
|
||||
|
||||
vehicles_rescued = 0
|
||||
vehicles_skipped = 0
|
||||
|
||||
for vehicle in orphaned_vehicles:
|
||||
owner_person_id = vehicle['owner_person_id']
|
||||
asset_id = vehicle['asset_id']
|
||||
plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
|
||||
|
||||
# Find the user who owns this person record
|
||||
user_row = await conn.fetchrow("""
|
||||
SELECT u.id AS user_id
|
||||
FROM identity.users u
|
||||
WHERE u.person_id = $1
|
||||
AND u.is_deleted = false
|
||||
AND u.is_active = true
|
||||
LIMIT 1
|
||||
""", owner_person_id)
|
||||
|
||||
if not user_row:
|
||||
print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
|
||||
vehicles_skipped += 1
|
||||
continue
|
||||
|
||||
user_id = user_row['user_id']
|
||||
|
||||
# Find the user's personal garage
|
||||
garage = await conn.fetchrow("""
|
||||
SELECT o.id AS org_id
|
||||
FROM fleet.organizations o
|
||||
WHERE o.owner_id = $1
|
||||
AND o.org_type = 'individual'
|
||||
AND o.is_deleted = false
|
||||
LIMIT 1
|
||||
""", user_id)
|
||||
|
||||
if not garage:
|
||||
print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
|
||||
vehicles_skipped += 1
|
||||
continue
|
||||
|
||||
org_id = garage['org_id']
|
||||
|
||||
# Update the vehicle's organization fields
|
||||
updates = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if vehicle['current_organization_id'] is None:
|
||||
updates.append(f"current_organization_id = ${param_idx}")
|
||||
params.append(org_id)
|
||||
param_idx += 1
|
||||
|
||||
if vehicle['owner_org_id'] is None:
|
||||
updates.append(f"owner_org_id = ${param_idx}")
|
||||
params.append(org_id)
|
||||
param_idx += 1
|
||||
|
||||
if updates:
|
||||
params.append(asset_id)
|
||||
update_sql = f"""
|
||||
UPDATE vehicle.assets
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = ${param_idx}::uuid
|
||||
"""
|
||||
await conn.execute(update_sql, *params)
|
||||
vehicles_rescued += 1
|
||||
print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
|
||||
|
||||
print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
|
||||
print()
|
||||
|
||||
# ── FINAL REPORT ──
|
||||
print("=" * 70)
|
||||
print("📊 FINAL MIGRATION REPORT")
|
||||
print("=" * 70)
|
||||
print(f" 🏗️ Task A - New Garages Created: {garages_created}")
|
||||
print(f" 👥 Task B - Members Added: {members_added}")
|
||||
print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
|
||||
print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
|
||||
print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
|
||||
print()
|
||||
print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
|
||||
print("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ FATAL ERROR: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
|
||||
"""
|
||||
🔄 RBAC Phase 1: PostgreSQL Enum Migration
|
||||
|
||||
This script handles the PostgreSQL enum migration for:
|
||||
1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||
2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
|
||||
|
||||
Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
|
||||
we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
|
||||
|
||||
Futtatás:
|
||||
docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from app.database import AsyncSessionLocal
|
||||
from sqlalchemy import text
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Migrate-RBAC-Enums")
|
||||
|
||||
|
||||
async def migrate_userrole_enum():
|
||||
"""Migrate identity.userrole enum to new values.
|
||||
|
||||
Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
|
||||
New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||
|
||||
Mapping:
|
||||
superadmin -> SUPERADMIN
|
||||
admin -> ADMIN
|
||||
moderator -> MODERATOR
|
||||
sales_agent -> SALES_REP
|
||||
user -> USER
|
||||
service_owner -> USER (fallback)
|
||||
fleet_manager -> USER (fallback)
|
||||
driver -> USER (fallback)
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
logger.info("🔍 Checking current identity.userrole enum...")
|
||||
|
||||
# Check current enum values
|
||||
result = await session.execute(
|
||||
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||
)
|
||||
current_values = {row[0] for row in result.fetchall()}
|
||||
logger.info(f"Current enum values: {current_values}")
|
||||
|
||||
new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
|
||||
|
||||
# If already migrated, skip
|
||||
if new_values.issubset(current_values):
|
||||
logger.info("✅ Enum already has the new values. Skipping migration.")
|
||||
return
|
||||
|
||||
logger.info("🔄 Starting enum migration...")
|
||||
|
||||
# Step 1: Create new enum type
|
||||
logger.info("Step 1: Creating new enum type 'userrole_new'...")
|
||||
await session.execute(text("""
|
||||
CREATE TYPE identity.userrole_new AS ENUM (
|
||||
'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
|
||||
)
|
||||
"""))
|
||||
|
||||
# Step 2: Alter the users table to use text temporarily
|
||||
logger.info("Step 2: Altering column to text type...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role TYPE text
|
||||
USING role::text
|
||||
"""))
|
||||
|
||||
# Step 3: Update old values to new values
|
||||
logger.info("Step 3: Updating role values...")
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
|
||||
"""))
|
||||
await session.execute(text("""
|
||||
UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
|
||||
"""))
|
||||
|
||||
# Step 4: Drop default before altering type
|
||||
logger.info("Step 4: Dropping default value...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role DROP DEFAULT
|
||||
"""))
|
||||
|
||||
# Step 5: Alter column to use new enum
|
||||
logger.info("Step 5: Altering column to new enum type...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role TYPE identity.userrole_new
|
||||
USING role::text::identity.userrole_new
|
||||
"""))
|
||||
|
||||
# Step 6: Set default
|
||||
logger.info("Step 6: Setting default value...")
|
||||
await session.execute(text("""
|
||||
ALTER TABLE identity.users
|
||||
ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
|
||||
"""))
|
||||
|
||||
# Step 7: Drop old enum and rename new one
|
||||
logger.info("Step 7: Dropping old enum and renaming new one...")
|
||||
await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
|
||||
await session.execute(text("""
|
||||
ALTER TYPE identity.userrole_new RENAME TO userrole
|
||||
"""))
|
||||
|
||||
await session.commit()
|
||||
logger.info("✅ Enum migration completed successfully!")
|
||||
|
||||
# Verify
|
||||
result = await session.execute(
|
||||
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||
)
|
||||
final_values = [row[0] for row in result.fetchall()]
|
||||
logger.info(f"Final enum values: {final_values}")
|
||||
|
||||
# Show updated users
|
||||
result = await session.execute(
|
||||
text("SELECT id, email, role::text FROM identity.users ORDER BY id")
|
||||
)
|
||||
users = result.fetchall()
|
||||
logger.info(f"\nUpdated {len(users)} users:")
|
||||
for row in users:
|
||||
logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
|
||||
logger.info("=" * 60)
|
||||
await migrate_userrole_enum()
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Migration completed.")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
|
||||
|
||||
Steps:
|
||||
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
|
||||
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
|
||||
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
|
||||
4. Verification & Reporting
|
||||
|
||||
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger("p0-backfill")
|
||||
|
||||
# Database URL - read from environment or use default
|
||||
import os
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 70)
|
||||
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
|
||||
logger.info("=" * 70)
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# =========================================================
|
||||
# STEP 0: DISCOVERY - Get all subscription tier IDs
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 0] Discovering subscription tiers...")
|
||||
stmt = text("""
|
||||
SELECT id, name, rules->>'type' AS tier_type,
|
||||
rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||
FROM system.subscription_tiers
|
||||
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
|
||||
ORDER BY id
|
||||
""")
|
||||
result = await db.execute(stmt)
|
||||
tiers = result.fetchall()
|
||||
tier_map = {}
|
||||
for row in tiers:
|
||||
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
|
||||
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
|
||||
|
||||
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
|
||||
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
|
||||
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
|
||||
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
|
||||
|
||||
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
|
||||
logger.error("FATAL: Could not find all required subscription tiers!")
|
||||
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
|
||||
logger.error(f" org_test_v01: {ORG_TEST_ID}")
|
||||
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
|
||||
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
|
||||
return
|
||||
|
||||
# =========================================================
|
||||
# STEP 1: VIP ASSIGNMENT
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
|
||||
|
||||
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
|
||||
stmt_vip1 = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE id = :org_id
|
||||
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_vip1,
|
||||
{
|
||||
"tier_id": PRIVATE_TEST_ID,
|
||||
"tier_name": "private_test_v01",
|
||||
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
|
||||
"org_id": 67
|
||||
}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
|
||||
else:
|
||||
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
|
||||
|
||||
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
|
||||
result = await db.execute(
|
||||
stmt_vip1,
|
||||
{
|
||||
"tier_id": ORG_TEST_ID,
|
||||
"tier_name": "org_test_v01",
|
||||
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
|
||||
"org_id": 1
|
||||
}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
|
||||
else:
|
||||
logger.warning(" ✗ Test Company (org_id=1) not found!")
|
||||
|
||||
# =========================================================
|
||||
# STEP 2: FREE TIER FALLBACK (Mass Update)
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
|
||||
|
||||
# Count how many orgs need updating
|
||||
stmt_count = text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
|
||||
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
|
||||
FROM fleet.organizations
|
||||
WHERE is_deleted = false
|
||||
""")
|
||||
result = await db.execute(stmt_count)
|
||||
counts = result.fetchone()
|
||||
ind_count = counts[0] or 0
|
||||
other_count = counts[1] or 0
|
||||
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
|
||||
|
||||
# 2a. Individual orgs → private_free_v1
|
||||
if ind_count > 0:
|
||||
stmt_ind = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE org_type = 'individual'
|
||||
AND subscription_tier_id IS NULL
|
||||
AND is_deleted = false
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_ind,
|
||||
{
|
||||
"tier_id": PRIVATE_FREE_ID,
|
||||
"tier_name": "private_free_v1",
|
||||
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
|
||||
}
|
||||
)
|
||||
logger.info(f" ✓ {result.rowcount} individual orgs → private_free_v1")
|
||||
|
||||
# 2b. Other orgs → corp_free_v1
|
||||
if other_count > 0:
|
||||
stmt_other = text("""
|
||||
UPDATE fleet.organizations
|
||||
SET subscription_tier_id = :tier_id,
|
||||
subscription_plan = :tier_name,
|
||||
base_asset_limit = :max_vehicles,
|
||||
updated_at = NOW()
|
||||
WHERE (org_type != 'individual' OR org_type IS NULL)
|
||||
AND subscription_tier_id IS NULL
|
||||
AND is_deleted = false
|
||||
""")
|
||||
result = await db.execute(
|
||||
stmt_other,
|
||||
{
|
||||
"tier_id": CORP_FREE_ID,
|
||||
"tier_name": "corp_free_v1",
|
||||
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
|
||||
}
|
||||
)
|
||||
logger.info(f" ✓ {result.rowcount} other orgs → corp_free_v1")
|
||||
|
||||
# =========================================================
|
||||
# STEP 3: VERIFICATION
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 3] Verification...")
|
||||
|
||||
# 3a. Check VIP orgs
|
||||
stmt_verify_vip = text("""
|
||||
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
|
||||
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||
FROM fleet.organizations o
|
||||
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||
WHERE o.id IN (1, 67)
|
||||
ORDER BY o.id
|
||||
""")
|
||||
result = await db.execute(stmt_verify_vip)
|
||||
rows = result.fetchall()
|
||||
logger.info(" VIP Assignments:")
|
||||
for row in rows:
|
||||
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
|
||||
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
|
||||
|
||||
# 3b. Summary of all assignments
|
||||
stmt_summary = text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
|
||||
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
|
||||
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
|
||||
FROM fleet.organizations
|
||||
""")
|
||||
result = await db.execute(stmt_summary)
|
||||
summary = result.fetchone()
|
||||
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
|
||||
|
||||
# 3c. Distribution by tier
|
||||
stmt_dist = text("""
|
||||
SELECT st.name AS tier_name, COUNT(*) AS org_count
|
||||
FROM fleet.organizations o
|
||||
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||
GROUP BY st.name
|
||||
ORDER BY COUNT(*) DESC
|
||||
""")
|
||||
result = await db.execute(stmt_dist)
|
||||
dist = result.fetchall()
|
||||
logger.info(" Distribution by tier:")
|
||||
for row in dist:
|
||||
logger.info(f" {row[0]}: {row[1]} orgs")
|
||||
|
||||
# =========================================================
|
||||
# STEP 4: LEGACY COLUMN AUDIT
|
||||
# =========================================================
|
||||
logger.info("\n[STEP 4] Legacy Column Audit...")
|
||||
|
||||
# Check Organization.subscription_plan - it's a legacy string column
|
||||
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
|
||||
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
|
||||
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
|
||||
|
||||
# Verify evidence.py uses JSON rules
|
||||
logger.info("\n Code Audit - evidence.py:")
|
||||
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
|
||||
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
|
||||
logger.info(" ✓ No legacy string-based quota logic found")
|
||||
|
||||
# Verify billing_engine.py
|
||||
logger.info("\n Code Audit - billing_engine.py:")
|
||||
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
|
||||
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
|
||||
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
|
||||
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
|
||||
|
||||
# =========================================================
|
||||
# COMMIT
|
||||
# =========================================================
|
||||
await db.commit()
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
|
||||
logger.info("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"\n❌ FATAL ERROR: {e}")
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -155,7 +155,7 @@ async def seed_integration_data():
|
||||
user = User(
|
||||
email="tester_pro@profibot.hu",
|
||||
hashed_password=get_password_hash("Tester123!"),
|
||||
role=UserRole.admin,
|
||||
role=UserRole.ADMIN,
|
||||
person_id=person.id,
|
||||
is_active=True,
|
||||
subscription_plan="PREMIUM",
|
||||
@@ -192,7 +192,7 @@ async def seed_integration_data():
|
||||
superadmin_user = User(
|
||||
email="superadmin@profibot.hu",
|
||||
hashed_password=get_password_hash("Superadmin123!"),
|
||||
role=UserRole.superadmin,
|
||||
role=UserRole.SUPERADMIN,
|
||||
person_id=superadmin_person.id,
|
||||
is_active=True,
|
||||
subscription_plan="ENTERPRISE",
|
||||
|
||||
210
backend/app/scripts/seed_org_roles.py
Normal file
210
backend/app/scripts/seed_org_roles.py
Normal file
@@ -0,0 +1,210 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
|
||||
"""
|
||||
🌱 RBAC Phase 1: Seed the fleet.org_roles table.
|
||||
|
||||
Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
|
||||
Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
|
||||
minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
|
||||
|
||||
Futtatás:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.organization import OrgRole
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||
logger = logging.getLogger("Seed-OrgRoles")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
ORG_ROLE_PERMISSIONS = {
|
||||
"OWNER": {
|
||||
"can_manage_organization": True,
|
||||
"can_manage_members": True,
|
||||
"can_manage_vehicles": True,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": True,
|
||||
"can_transfer_ownership": True,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": True,
|
||||
"can_remove_members": True,
|
||||
"can_edit_settings": True,
|
||||
},
|
||||
"ADMIN": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": True,
|
||||
"can_manage_vehicles": True,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": True,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": True,
|
||||
"can_remove_members": True,
|
||||
"can_edit_settings": True,
|
||||
},
|
||||
"ACCOUNTANT": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": True,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
"DRIVER": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": True,
|
||||
"can_approve_expense": False,
|
||||
"can_view_financials": False,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
"VIEWER": {
|
||||
"can_manage_organization": False,
|
||||
"can_manage_members": False,
|
||||
"can_manage_vehicles": False,
|
||||
"can_add_expense": False,
|
||||
"can_approve_expense": False,
|
||||
"can_view_financials": True,
|
||||
"can_manage_branches": False,
|
||||
"can_transfer_ownership": False,
|
||||
"can_delete_organization": False,
|
||||
"can_invite_members": False,
|
||||
"can_remove_members": False,
|
||||
"can_edit_settings": False,
|
||||
},
|
||||
}
|
||||
|
||||
ORG_ROLE_DESCRIPTIONS = {
|
||||
"OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
|
||||
"ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
|
||||
"ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
|
||||
"DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
|
||||
"VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
|
||||
}
|
||||
|
||||
ORG_ROLE_PRIORITIES = {
|
||||
"OWNER": 100,
|
||||
"ADMIN": 80,
|
||||
"ACCOUNTANT": 60,
|
||||
"DRIVER": 40,
|
||||
"VIEWER": 20,
|
||||
}
|
||||
|
||||
|
||||
async def seed_org_roles():
|
||||
"""Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
|
||||
|
||||
# Ellenőrizzük, hogy vannak-e már rekordok
|
||||
result = await db.execute(select(OrgRole))
|
||||
existing = result.scalars().all()
|
||||
|
||||
if existing:
|
||||
logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
|
||||
logger.info("📋 Meglévő szerepkörök:")
|
||||
for role in existing:
|
||||
logger.info(f" - {role.name_key} (priority: {role.priority})")
|
||||
|
||||
# Ellenőrizzük, hogy hiányzik-e valamelyik
|
||||
existing_keys = {r.name_key for r in existing}
|
||||
missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
|
||||
if missing:
|
||||
logger.info(f"➕ Hiányzó szerepkörök: {missing}")
|
||||
else:
|
||||
logger.info("✅ Minden alap szerepkör jelen van.")
|
||||
|
||||
logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
|
||||
|
||||
created_count = 0
|
||||
for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
|
||||
# Ellenőrizzük, hogy már létezik-e
|
||||
result = await db.execute(
|
||||
select(OrgRole).where(OrgRole.name_key == role_key)
|
||||
)
|
||||
existing_role = result.scalar_one_or_none()
|
||||
|
||||
if existing_role:
|
||||
logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
|
||||
continue
|
||||
|
||||
new_role = OrgRole(
|
||||
name_key=role_key,
|
||||
display_name=role_key.capitalize(),
|
||||
description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
|
||||
is_system=True,
|
||||
priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
|
||||
is_active=True,
|
||||
permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
|
||||
)
|
||||
db.add(new_role)
|
||||
created_count += 1
|
||||
logger.info(f" ✅ {role_key} létrehozva (priority: {new_role.priority})")
|
||||
|
||||
if created_count > 0:
|
||||
await db.commit()
|
||||
logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
|
||||
|
||||
# ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
|
||||
logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
|
||||
result = await db.execute(select(OrgRole))
|
||||
all_roles = result.scalars().all()
|
||||
updated_count = 0
|
||||
for role in all_roles:
|
||||
expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
|
||||
if role.permissions != expected_perms:
|
||||
role.permissions = expected_perms
|
||||
updated_count += 1
|
||||
logger.info(f" ✅ {role.name_key} permissions frissítve")
|
||||
|
||||
if updated_count > 0:
|
||||
await db.commit()
|
||||
logger.info(f"✅ {updated_count} szerepkör permissions mezője frissítve.")
|
||||
else:
|
||||
logger.info("ℹ️ Minden szerepkör permissions mezője naprakész.")
|
||||
|
||||
# Végeredmény kiírása
|
||||
result = await db.execute(
|
||||
select(OrgRole).order_by(OrgRole.priority.desc())
|
||||
)
|
||||
all_roles = result.scalars().all()
|
||||
logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
|
||||
for role in all_roles:
|
||||
logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
|
||||
logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
|
||||
logger.info("=" * 60)
|
||||
await seed_org_roles()
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Seed befejeződött.")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
78
backend/app/scripts/verify_migration.py
Normal file
78
backend/app/scripts/verify_migration.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the results of the Great Garage & Vehicle Migration."""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
|
||||
|
||||
async def verify():
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
print("=== VERIFICATION REPORT ===")
|
||||
print()
|
||||
|
||||
# 1. Users without personal garages
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM identity.users u
|
||||
JOIN identity.persons p ON p.id = u.person_id
|
||||
WHERE u.is_deleted = false AND u.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fleet.organizations o
|
||||
WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
|
||||
)
|
||||
""")
|
||||
print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
|
||||
|
||||
# 2. Total personal garages
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM fleet.organizations
|
||||
WHERE org_type = 'individual' AND is_deleted = false
|
||||
""")
|
||||
print(f"2. Total personal garages (INDIVIDUAL): {r}")
|
||||
|
||||
# 3. Users with NULL scope_id
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM identity.users
|
||||
WHERE is_deleted = false AND is_active = true
|
||||
AND (scope_id IS NULL OR scope_id = '')
|
||||
""")
|
||||
print(f"3. Users with NULL/empty scope_id: {r}")
|
||||
|
||||
# 4. Organization members with OWNER role
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
|
||||
""")
|
||||
print(f"4. Organization members with OWNER role: {r}")
|
||||
|
||||
# 5. Orphaned vehicles remaining
|
||||
r = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM vehicle.assets
|
||||
WHERE owner_person_id IS NOT NULL
|
||||
AND (current_organization_id IS NULL OR owner_org_id IS NULL)
|
||||
AND status != 'deleted'
|
||||
""")
|
||||
print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
|
||||
|
||||
# 6. Detail of all personal garages
|
||||
rows = await conn.fetch("""
|
||||
SELECT o.id, o.name, u.email, u.scope_id
|
||||
FROM fleet.organizations o
|
||||
JOIN identity.users u ON u.id = o.owner_id
|
||||
WHERE o.org_type = 'individual' AND o.is_deleted = false
|
||||
ORDER BY o.id
|
||||
""")
|
||||
print(f"\n6. Personal garages detail ({len(rows)} total):")
|
||||
for r in rows:
|
||||
print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
|
||||
|
||||
# 7. Count all vehicles and their org assignments
|
||||
r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
|
||||
r2 = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM vehicle.assets
|
||||
WHERE status != 'deleted' AND current_organization_id IS NOT NULL
|
||||
""")
|
||||
print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
|
||||
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(verify())
|
||||
Reference in New Issue
Block a user