- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки. - Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок инициализации (показ admin-only после initAuthSession, currentUser). - GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool). Команды и роли - Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды в модалке, только команды с активным членством; API team_id в details/ update. - GET /api/v1/teams?membership=active для списка «своих» команд. - Форма роли: сегмент Team, панель выбора команды только при Team и не при с
382 lines
16 KiB
Python
382 lines
16 KiB
Python
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pathlib import Path
|
|
|
|
from app.core.config import get_settings
|
|
from app.db.pool import close_pool, get_pool, init_pool
|
|
from app.routers.auth import router as auth_router
|
|
from app.routers.admin_users import router as admin_users_router
|
|
from app.routers.domain import router as domain_router
|
|
from app.routers.profile import router as profile_router
|
|
from app.routers.teams import router as teams_router
|
|
from app.routers.ui import router as ui_router
|
|
from app.routers.ws import router as ws_router
|
|
from app.services.jsonlint_runtime import refresh_jsonlint_config_cache
|
|
from app.services.yamllint_runtime import refresh_yamllint_config_cache
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
await init_pool()
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await conn.execute("alter table users add column if not exists username text")
|
|
await conn.execute("alter table users add column if not exists role text not null default 'user'")
|
|
await conn.execute(
|
|
"""
|
|
update users
|
|
set username = split_part(email, '@', 1) || '_' || substr(id::text, 1, 8)
|
|
where username is null or username = ''
|
|
"""
|
|
)
|
|
await conn.execute("create unique index if not exists users_username_key on users(username)")
|
|
await conn.execute(
|
|
"""
|
|
with first_user as (
|
|
select id
|
|
from users
|
|
order by created_at asc
|
|
limit 1
|
|
)
|
|
update users
|
|
set role='admin'
|
|
where id in (select id from first_user)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
create table if not exists role_categories (
|
|
id uuid primary key default gen_random_uuid(),
|
|
owner_id uuid references users(id) on delete cascade,
|
|
name text not null,
|
|
created_at timestamptz not null default now(),
|
|
unique (owner_id, name)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute("alter table role_categories alter column owner_id drop not null")
|
|
await conn.execute("create table if not exists app_config (key text primary key, value jsonb not null default '{}'::jsonb, updated_at timestamptz not null default now())")
|
|
await conn.execute(
|
|
"""
|
|
create table if not exists role_files (
|
|
id uuid primary key default gen_random_uuid(),
|
|
role_id uuid not null references ansible_roles(id) on delete cascade,
|
|
path text not null,
|
|
content text not null default '',
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique (role_id, path)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1
|
|
from information_schema.tables
|
|
where table_schema='public' and table_name='ansible_roles'
|
|
) then
|
|
alter table ansible_roles add column if not exists category_id uuid;
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1 from information_schema.tables
|
|
where table_schema='public' and table_name='ansible_roles'
|
|
) then
|
|
-- Remove accidental starter-role duplicates before adding unique partial index.
|
|
delete from ansible_roles r
|
|
using (
|
|
select id
|
|
from (
|
|
select
|
|
id,
|
|
row_number() over (
|
|
partition by owner_id, name, source_ref
|
|
order by created_at asc, id asc
|
|
) as rn
|
|
from ansible_roles
|
|
where source_ref = 'starter'
|
|
) t
|
|
where t.rn > 1
|
|
) d
|
|
where r.id = d.id;
|
|
|
|
create unique index if not exists ux_ansible_roles_owner_name_starter
|
|
on ansible_roles (owner_id, name)
|
|
where source_ref = 'starter';
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if not exists (
|
|
select 1
|
|
from pg_constraint
|
|
where conname = 'ansible_roles_category_id_fkey'
|
|
) then
|
|
alter table ansible_roles
|
|
add constraint ansible_roles_category_id_fkey
|
|
foreign key (category_id) references role_categories(id) on delete set null;
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1 from information_schema.tables
|
|
where table_schema='public' and table_name='ansible_roles'
|
|
) then
|
|
alter table ansible_roles add column if not exists visibility text not null default 'personal';
|
|
alter table ansible_roles add column if not exists team_id uuid;
|
|
alter table ansible_roles add column if not exists forked_from_id uuid;
|
|
if not exists (
|
|
select 1 from pg_constraint where conname = 'ansible_roles_forked_from_id_fkey'
|
|
) then
|
|
alter table ansible_roles
|
|
add constraint ansible_roles_forked_from_id_fkey
|
|
foreign key (forked_from_id) references ansible_roles(id) on delete set null;
|
|
end if;
|
|
update ansible_roles set visibility = 'public' where visibility is null or visibility = '';
|
|
if not exists (
|
|
select 1 from pg_constraint where conname = 'ansible_roles_visibility_check'
|
|
) then
|
|
alter table ansible_roles
|
|
add constraint ansible_roles_visibility_check
|
|
check (visibility in ('public', 'team', 'personal'));
|
|
end if;
|
|
-- Remove auto-generated starter roles (no longer created for new users).
|
|
delete from ansible_roles where source_ref = 'starter';
|
|
alter table ansible_roles alter column visibility set default 'personal';
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1 from information_schema.tables
|
|
where table_schema='public' and table_name='ansible_roles'
|
|
) then
|
|
alter table ansible_roles
|
|
add column if not exists role_tags text[] not null default array[]::text[];
|
|
alter table ansible_roles
|
|
add column if not exists os_families text[] not null default array[]::text[];
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1 from information_schema.tables
|
|
where table_schema = 'public' and table_name = 'role_categories'
|
|
) then
|
|
-- Duplicate rows with the same normalized name were possible because UNIQUE(owner_id, name)
|
|
-- treats NULL owner_id as distinct per row. Point roles at the canonical category
|
|
-- (earliest created_at, then smallest id) and remove extras.
|
|
update ansible_roles ar
|
|
set category_id = canon.canon_id
|
|
from (
|
|
select c.id as dup_id,
|
|
(
|
|
select c2.id
|
|
from role_categories c2
|
|
where lower(trim(c2.name)) = lower(trim(c.name))
|
|
order by c2.created_at asc nulls last, c2.id asc
|
|
limit 1
|
|
) as canon_id
|
|
from role_categories c
|
|
) canon
|
|
where ar.category_id = canon.dup_id
|
|
and canon.canon_id is not null
|
|
and canon.dup_id <> canon.canon_id;
|
|
|
|
delete from role_categories d
|
|
where d.id in (
|
|
select c.id
|
|
from role_categories c
|
|
where c.id <> (
|
|
select c2.id
|
|
from role_categories c2
|
|
where lower(trim(c2.name)) = lower(trim(c.name))
|
|
order by c2.created_at asc nulls last, c2.id asc
|
|
limit 1
|
|
)
|
|
);
|
|
|
|
create unique index if not exists ux_role_categories_name_normalized
|
|
on role_categories (lower(trim(name)));
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
alter table users add column if not exists profile_bio text not null default '';
|
|
alter table users add column if not exists avatar_ext text;
|
|
alter table users add column if not exists avatar_updated_at timestamptz;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
alter table users add column if not exists is_active boolean not null default true;
|
|
alter table users add column if not exists deleted_at timestamptz;
|
|
alter table users add column if not exists updated_at timestamptz;
|
|
update users set updated_at = created_at where updated_at is null;
|
|
alter table users alter column updated_at set default now();
|
|
alter table users alter column updated_at set not null;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
alter table users add column if not exists ban_until timestamptz;
|
|
alter table users add column if not exists ban_reason text;
|
|
alter table users add column if not exists banned_by_id uuid;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if not exists (
|
|
select 1 from pg_constraint where conname = 'users_banned_by_id_fkey'
|
|
) then
|
|
alter table users
|
|
add constraint users_banned_by_id_fkey
|
|
foreign key (banned_by_id) references users(id) on delete set null;
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
alter table users add column if not exists is_founder boolean not null default false;
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users set is_founder = true
|
|
where id = (select id from users order by created_at asc limit 1);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users set role = 'root' where role = 'super_admin';
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users u
|
|
set role = 'root'
|
|
where u.id = (select id from users order by created_at asc limit 1)
|
|
and not exists (select 1 from users u2 where u2.role = 'root');
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
create table if not exists teams (
|
|
id uuid primary key default gen_random_uuid(),
|
|
name text not null,
|
|
description text not null default '',
|
|
created_by uuid not null references users(id) on delete restrict,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
create table if not exists team_memberships (
|
|
team_id uuid not null references teams(id) on delete cascade,
|
|
user_id uuid not null references users(id) on delete cascade,
|
|
team_role text not null default 'member',
|
|
status text not null default 'active',
|
|
invited_by uuid references users(id) on delete set null,
|
|
created_at timestamptz not null default now(),
|
|
primary key (team_id, user_id),
|
|
constraint team_memberships_team_role_check check (team_role in ('owner', 'member')),
|
|
constraint team_memberships_status_check check (status in ('active', 'pending_join', 'invited'))
|
|
);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
create index if not exists ix_team_memberships_user_id on team_memberships(user_id);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
do $$
|
|
begin
|
|
if exists (
|
|
select 1 from information_schema.columns
|
|
where table_schema = 'public' and table_name = 'ansible_roles' and column_name = 'team_id'
|
|
) and not exists (
|
|
select 1 from pg_constraint where conname = 'ansible_roles_team_id_fkey'
|
|
) then
|
|
alter table ansible_roles
|
|
add constraint ansible_roles_team_id_fkey
|
|
foreign key (team_id) references teams(id) on delete set null;
|
|
end if;
|
|
end $$;
|
|
"""
|
|
)
|
|
# Warm lint caches outside the migration connection so nested pool.acquire() cannot deadlock.
|
|
try:
|
|
await refresh_yamllint_config_cache()
|
|
await refresh_jsonlint_config_cache()
|
|
except Exception:
|
|
_logger.exception(
|
|
"Lint config cache warmup failed (app still starts; caches load on first use)."
|
|
)
|
|
yield
|
|
await close_pool()
|
|
|
|
|
|
settings = get_settings()
|
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
|
|
|
_path_static = Path(__file__).resolve().parent / "static"
|
|
_app_data = Path(settings.app_data_dir)
|
|
_app_data.mkdir(parents=True, exist_ok=True)
|
|
_avatar_dir = settings.avatar_storage_path
|
|
_avatar_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
app.mount("/static", StaticFiles(directory=str(_path_static)), name="static")
|
|
app.mount("/media/avatars", StaticFiles(directory=str(_avatar_dir)), name="avatars_media")
|
|
app.include_router(ui_router)
|
|
app.include_router(auth_router)
|
|
app.include_router(auth_router, prefix="/api/v1")
|
|
app.include_router(profile_router, prefix="/api/v1")
|
|
app.include_router(admin_users_router, prefix="/api/v1")
|
|
app.include_router(teams_router, prefix="/api/v1")
|
|
app.include_router(domain_router, prefix="/api/v1")
|
|
app.include_router(ws_router)
|
|
|
|
|
|
@app.get("/healthz", tags=["health"])
|
|
async def healthz() -> dict[str, str]:
|
|
return {"status": "ok"}
|