Enhance Discord role management by adding hoist functionality
This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
This commit is contained in:
@@ -46,7 +46,8 @@ type RoleManager struct {
|
||||
}
|
||||
|
||||
type discordRoleResponse struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type discordHTTPError struct {
|
||||
@@ -181,10 +182,14 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registeredRole := existingRole(roles, application.DiscordRoleKindRegistered)
|
||||
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = w.ensureTeamsAboveRegistered(ctx, roles, registeredRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
desired, warnings, err := w.desiredAssignments(ctx, current, roles)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
@@ -234,7 +239,7 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin
|
||||
}
|
||||
names := rsvpRoleNames(w.locale, state.Event.Name)
|
||||
desiredRoles := []application.DiscordManagedRole{
|
||||
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered]},
|
||||
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered], Hoist: true},
|
||||
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]},
|
||||
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]},
|
||||
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]},
|
||||
@@ -281,7 +286,7 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste
|
||||
for _, team := range roster.Teams {
|
||||
teamName := truncate(strings.TrimSpace(team.Name), 100)
|
||||
desired = append(desired,
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName},
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName, Hoist: true},
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
|
||||
)
|
||||
}
|
||||
@@ -302,8 +307,8 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic
|
||||
desiredKeys[key] = true
|
||||
if saved, ok := existingByKey[key]; ok {
|
||||
role.DiscordRoleID = saved.DiscordRoleID
|
||||
if saved.RoleName != role.RoleName {
|
||||
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); err != nil {
|
||||
if saved.RoleName != role.RoleName || saved.Hoist != role.Hoist {
|
||||
if err := w.manager.UpdateRole(ctx, saved.DiscordRoleID, role.RoleName, role.Hoist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
|
||||
@@ -311,7 +316,7 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic
|
||||
}
|
||||
}
|
||||
} else {
|
||||
roleID, err := w.manager.CreateRole(ctx, role.RoleName)
|
||||
roleID, err := w.manager.CreateRole(ctx, role.RoleName, role.Hoist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -486,10 +491,26 @@ func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.Dis
|
||||
return w.store.DeleteDiscordManagedRole(ctx, role)
|
||||
}
|
||||
|
||||
func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) {
|
||||
func (w *RoleWorker) ensureTeamsAboveRegistered(ctx context.Context, roles []application.DiscordManagedRole, registered application.DiscordManagedRole) error {
|
||||
if registered.DiscordRoleID == "" {
|
||||
return nil
|
||||
}
|
||||
teamRoleIDs := make([]string, 0)
|
||||
for _, role := range roles {
|
||||
if role.Kind == application.DiscordRoleKindTeam {
|
||||
teamRoleIDs = append(teamRoleIDs, role.DiscordRoleID)
|
||||
}
|
||||
}
|
||||
if len(teamRoleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return w.manager.MoveRolesAbove(ctx, teamRoleIDs, registered.DiscordRoleID)
|
||||
}
|
||||
|
||||
func (m *RoleManager) CreateRole(ctx context.Context, name string, hoist bool) (string, error) {
|
||||
var response discordRoleResponse
|
||||
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
|
||||
"name": name, "permissions": "0", "hoist": false, "mentionable": false,
|
||||
"name": name, "permissions": "0", "hoist": hoist, "mentionable": false,
|
||||
}, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -499,8 +520,38 @@ func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, erro
|
||||
return response.ID, nil
|
||||
}
|
||||
|
||||
func (m *RoleManager) RenameRole(ctx context.Context, roleID, name string) error {
|
||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, nil)
|
||||
func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist bool) error {
|
||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]any{"name": name, "hoist": hoist}, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
|
||||
var roles []discordRoleResponse
|
||||
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil {
|
||||
return err
|
||||
}
|
||||
positions := make(map[string]int, len(roles))
|
||||
for _, role := range roles {
|
||||
positions[role.ID] = role.Position
|
||||
}
|
||||
anchorPosition, ok := positions[anchorRoleID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
needsMove := false
|
||||
for _, roleID := range roleIDs {
|
||||
if position, found := positions[roleID]; !found || position <= anchorPosition {
|
||||
needsMove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsMove {
|
||||
return nil
|
||||
}
|
||||
payload := make([]map[string]any, 0, len(roleIDs))
|
||||
for index, roleID := range roleIDs {
|
||||
payload = append(payload, map[string]any{"id": roleID, "position": anchorPosition + index + 1})
|
||||
}
|
||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
|
||||
@@ -575,6 +626,15 @@ func managedRoleKey(role application.DiscordManagedRole) string {
|
||||
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
|
||||
}
|
||||
|
||||
func existingRole(roles []application.DiscordManagedRole, kind string) application.DiscordManagedRole {
|
||||
for _, role := range roles {
|
||||
if role.Kind == kind {
|
||||
return role
|
||||
}
|
||||
}
|
||||
return application.DiscordManagedRole{}
|
||||
}
|
||||
|
||||
func roleKind(role domain.Role) string {
|
||||
switch role {
|
||||
case domain.Tank:
|
||||
|
||||
@@ -125,6 +125,8 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
|
||||
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
roleNames := make(map[string]string)
|
||||
roleHoists := make(map[string]bool)
|
||||
rolePositions := map[string]int{"role-rsvp": 1}
|
||||
methods := make([]string, 0)
|
||||
nextID := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
@@ -137,15 +139,38 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
nextID++
|
||||
id := "role-" + string(rune('0'+nextID))
|
||||
roleNames[id], _ = payload["name"].(string)
|
||||
roleHoists[id], _ = payload["hoist"].(bool)
|
||||
rolePositions[id] = 0
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(response).Encode(map[string]string{"id": id})
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
|
||||
roles := []map[string]any{{"id": "role-rsvp", "position": rolePositions["role-rsvp"]}}
|
||||
for id := range roleNames {
|
||||
roles = append(roles, map[string]any{"id": id, "position": rolePositions[id]})
|
||||
}
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(response).Encode(roles)
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodPatch {
|
||||
var payload map[string]string
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
parts := strings.Split(request.URL.Path, "/")
|
||||
roleNames[parts[len(parts)-1]] = payload["name"]
|
||||
roleID := parts[len(parts)-1]
|
||||
if roleID == "roles" {
|
||||
var payload []struct {
|
||||
ID string `json:"id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
for _, item := range payload {
|
||||
rolePositions[item.ID] = item.Position
|
||||
}
|
||||
} else {
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
roleNames[roleID], _ = payload["name"].(string)
|
||||
}
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
@@ -157,7 +182,7 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
store.active = []application.DiscordRoleRoster{item}
|
||||
rsvpRole := application.DiscordManagedRole{
|
||||
Scope: application.DiscordRoleScopeEvent, EventID: "event-1",
|
||||
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix",
|
||||
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Hoist: true,
|
||||
}
|
||||
store.roles[managedRoleKey(rsvpRole)] = rsvpRole
|
||||
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
|
||||
@@ -179,6 +204,14 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
|
||||
t.Fatalf("unexpected role names: %v", names)
|
||||
}
|
||||
for id, name := range roleNames {
|
||||
if roleHoists[id] != (name == "Alpha") {
|
||||
t.Fatalf("unexpected hoist for %s: %v", name, roleHoists[id])
|
||||
}
|
||||
if name == "Alpha" && rolePositions[id] <= rolePositions["role-rsvp"] {
|
||||
t.Fatalf("team role was not moved above registered role: team=%d registered=%d", rolePositions[id], rolePositions["role-rsvp"])
|
||||
}
|
||||
}
|
||||
if got := assignmentCount(store.assignments); got != 11 {
|
||||
t.Fatalf("expected 11 managed assignments, got %d", got)
|
||||
}
|
||||
@@ -191,8 +224,10 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(methods) != firstRequestCount {
|
||||
t.Fatalf("idempotent reconcile made %d extra Discord calls", len(methods)-firstRequestCount)
|
||||
for _, method := range methods[firstRequestCount:] {
|
||||
if !strings.HasPrefix(method, http.MethodGet+" ") {
|
||||
t.Fatalf("idempotent reconcile made a mutating Discord call: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
renamed := testDiscordRoster("Night Owls")
|
||||
@@ -236,6 +271,13 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
|
||||
patches := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPost {
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
name, _ := payload["name"].(string)
|
||||
hoist, _ := payload["hoist"].(bool)
|
||||
if hoist != strings.HasPrefix(name, "Зарегистрирован:") {
|
||||
t.Errorf("unexpected RSVP role hoist: name=%q hoist=%v", name, hoist)
|
||||
}
|
||||
nextID++
|
||||
_ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))})
|
||||
return
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *Store) RetryDiscordRoleSyncJob(ctx context.Context, jobID int64, messag
|
||||
}
|
||||
|
||||
func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]application.DiscordManagedRole, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name
|
||||
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name,hoist
|
||||
FROM discord_managed_roles
|
||||
WHERE scope='global' OR event_id=$1
|
||||
ORDER BY scope,event_id,team_id,kind`, eventID)
|
||||
@@ -89,7 +89,7 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
|
||||
roles := make([]application.DiscordManagedRole, 0)
|
||||
for rows.Next() {
|
||||
var role application.DiscordManagedRole
|
||||
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName); err != nil {
|
||||
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName, &role.Hoist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, role)
|
||||
@@ -98,11 +98,11 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name)
|
||||
VALUES($1,$2,$3,$4,$5,$6)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT(scope,event_id,team_id,kind) DO UPDATE
|
||||
SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,updated_at=now()`,
|
||||
role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName)
|
||||
SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,hoist=excluded.hoist,updated_at=now()`,
|
||||
role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName, role.Hoist)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ type DiscordManagedRole struct {
|
||||
Kind string
|
||||
DiscordRoleID string
|
||||
RoleName string
|
||||
Hoist bool
|
||||
}
|
||||
|
||||
type DiscordRoleSyncJob struct {
|
||||
|
||||
7
backend/migrations/013_discord_role_hoist.sql
Normal file
7
backend/migrations/013_discord_role_hoist.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE discord_managed_roles
|
||||
ADD COLUMN hoist boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +mixmaker Down
|
||||
ALTER TABLE discord_managed_roles
|
||||
DROP COLUMN IF EXISTS hoist;
|
||||
@@ -9,6 +9,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
||||
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
|
||||
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
|
||||
При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles.
|
||||
В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями.
|
||||
|
||||
## Подтверждённые требования
|
||||
|
||||
@@ -83,6 +84,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
||||
|
||||
## Ближайший следующий шаг
|
||||
|
||||
Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql` и `012_discord_rsvp_roles.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, анонс, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
|
||||
Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql`, `012_discord_rsvp_roles.sql` и `013_discord_role_hoist.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
|
||||
|
||||
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.
|
||||
|
||||
@@ -124,6 +124,8 @@ Application-слой управляет единым versioned workflow собы
|
||||
|
||||
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.
|
||||
|
||||
Discord hoist включён только для team roles и общей `registered`-роли. Team roles автоматически поднимаются выше `registered`, поэтому после формирования состава игрок отображается в группе команды, а ещё не распределённые участники — в общей группе регистрации. Captain, Tank/Damage/Support и конкретные RSVP status roles остаются служебными и не создают отдельные группы участников.
|
||||
|
||||
### Капитаны
|
||||
|
||||
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.
|
||||
|
||||
@@ -32,6 +32,8 @@ Team/captain roles принадлежат конкретному Event и уда
|
||||
|
||||
Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event.
|
||||
|
||||
В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку.
|
||||
|
||||
### Жизненный цикл события
|
||||
|
||||
Событие проходит серверно контролируемые состояния:
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
|
||||
- обновление анонса, напоминания, публикация составов и результатов;
|
||||
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
|
||||
- [x] отдельные member-list группы для команд и зарегистрированных участников;
|
||||
- создание и очистка временных командных голосовых каналов;
|
||||
- импорт данных и резервное копирование.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user