From 5560d3bb8c7861a065dfd321c898df4524351335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:39:34 +0300 Subject: [PATCH] =?UTF-8?q?WizardFileManager=20=C3=B6nce=20Json=20sonra=20?= =?UTF-8?q?DbMigrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/instructions/ai.instructions.md | 15 +- .github/instructions/lowcode.instructions.md | 137 +- README.md | 29 +- .../McpDevelopAppService.cs | 13 +- .../McpErrorCodes.cs | 1 + .../Tools/McpDevelopTools.cs | 6 +- .../ListForms/Wizard/WizardSeedFileDto.cs | 71 +- .../ListForms/Wizard/WizardSeedRowKeys.cs | 87 ++ .../ListForms/ListFormSeedDataSynchronizer.cs | 2 +- .../ListForms/ListFormWizardAppService.cs | 1187 +++++++++-------- .../ListForms/WizardExportCollector.cs | 53 +- .../ListForms/WizardRecordCleaner.cs | 247 ++-- .../ListForms/WizardSeedRowStore.cs | 227 ++++ .../ListForms/WizardSeedSynchronizer.cs | 8 + .../Migrations/WizardDataSeeder.cs | 545 ++------ .../Enums/WizardRecordTypeEnum.cs | 7 +- .../SeedJsonText.cs | 29 + .../WizardConsts.cs | 9 - .../Data/ListFormSeedDataFile.cs | 8 + .../Data/PlatformDbMigrationService.cs | 16 +- .../Data/SeedRowIds.cs | 42 + .../Data/WizardInstallationChecker.cs | 43 + claude.md | 28 +- .../host/data/App.Languages.LanguageText.json | 116 +- ui/src/components/shared/DbMigrateButton.tsx | 10 +- ui/src/proxy/admin/wizard/models.ts | 3 +- ui/src/services/UiEvalService.tsx | 6 +- ui/src/views/admin/listForm/wizard/Wizard.tsx | 43 +- .../listForm/wizard/WizardFileManager.tsx | 58 +- .../listForm/wizard/WizardImportDialog.tsx | 11 +- .../listForm/wizard/WizardStepDeploy.tsx | 4 +- 31 files changed, 1789 insertions(+), 1272 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedRowKeys.cs create mode 100644 api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs create mode 100644 api/src/Sozsoft.Platform.Domain.Shared/SeedJsonText.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Data/SeedRowIds.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Data/WizardInstallationChecker.cs diff --git a/.github/instructions/ai.instructions.md b/.github/instructions/ai.instructions.md index a7238e3a..8162f546 100644 --- a/.github/instructions/ai.instructions.md +++ b/.github/instructions/ai.instructions.md @@ -985,11 +985,16 @@ covered by `RollbackImport`, which only restores files and must be run before it `CompleteImport` runs in **dependency order and the order is fixed**: (a) the `.sql` scripts under `{sql|postgres}/{object,execute}` (`object` first) — a missing table is created here, otherwise the later steps would fail on it; (b) columns missing from existing tables are added; (c) `crud/*.json` -through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows; (e) `wizard/*.json` — the screens -are installed through the same path the wizard screen uses (`DeployWizardAsync`), reusing the file's -`ListForm`/`Fields` contract as-is instead of rebuilding it from the wizard answers. A screen that -already exists is left untouched, and each screen is installed in its own unit of work. No migrate -run is needed: the screen works as soon as the import finishes. +through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows, except the platform files that hold +the wizards' menu/permission/language rows (`WizardSeedRowStore.ListFormCodes`); (e) `wizard/*.json` +— the screens are **not** installed: like a deploy, the file is marked `Pending` and the next DB +Migrate installs it (`WizardDataSeeder`) from the file's `ListForm`/`Fields` contract as-is, even +when the target already has the screen. + +A wizard deploy (wizard screen, MCP `deploy_wizard`, edit) never writes to the database: it only +writes seed files — `wizard/{Name}.json` plus the menu, module, permission group, permission and +language rows in the platform `data/*.json` files. DB Migrate is the only installer, and it never +grants the permissions to a role (admin included); roles get them from the role screen. Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` / `execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries. diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index 0fa59781..d599201b 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -68,7 +68,7 @@ Tenant kapsamı isteniyorsa `host/` yerine `tenants/{tenantId}/`; iç düzen ayn | 1 | Tablo / view | `sql/object/…` | Ekranın bağlanacağı nesne önce var olmalı. MCP'de yeni tablo `create_table` ile kurulur (script ve dosya tasarımcıyla aynı); view MCP'den kurulamaz | | 2 | CRUD uçları (gerekiyorsa) | `crud/{Entity}.json` | Custom Component'in data source'ları buna bakar | | 3 | Custom Component (gerekiyorsa) | `data/App.DeveloperKit.CustomComponents/{Id}.json` | Wizard `Custom` yolunda bileşen adını arar | -| 4 | Wizard | `wizard/{Ad}.json` | Menü, yetki, dil anahtarı ve ListForm'u üretir. List ekranı **deploy** ile kurulur (MCP `deploy_wizard` ya da wizard ekranı, §0.10); dosyayı sunucu yazar. Elle yazılan List dosyası `ListForm` + `Fields` taşımak zorundadır, yoksa seeder atlar (§4.1) | +| 4 | Wizard | `wizard/{Ad}.json` + menü/yetki/dil satırları (`data/App.Menus.*`, `data/Abp.Identity.Permission*`, `data/App.Languages.LanguageText.json`) | Deploy (MCP `deploy_wizard` ya da wizard ekranı, §0.10) **yalnızca bu dosyaları yazar**; ekran, menü ve yetkiler DB Migrate ile kurulur (§4.0). Dosyaları sunucu yazar. Elle yazılan List dosyası `ListForm` + `Fields` taşımak zorundadır, yoksa seeder atlar (§4.1) | | 5 | Alt ekranlar (varsa) | ek `wizard/…` dosyaları | Ana wizard'dan **önceki** `SeededAt` damgasını alır | | 6 | Liste verisi (isteniyorsa) | `data/{ListFormCode}.json` | Ekran ve tablo hazır olmalı; `ListForm.SeedFilePath` bu yolu gösterir (§9.15) | | 7 | SEO alanları (public yol ürettiysen) | Yolu tanımlayan satırın kendisi: `data/App.Menus.Routes.json` ya da `data/App.DeveloperKit.CustomComponents/{Id}.json` | Yolu üreten dosya yazıldıktan sonra; ziyaretçiye açık hiçbir yol metasız kalmaz (§0.9) | @@ -503,13 +503,13 @@ ister ve kiracı kullanıcısına hiçbiri açılmaz. | `get_dev_guide` | Bu dosyanın (`lowcode`) ya da `reference`'ın başlık listesini / bir bölümünü döner | Bölüm numarayla istenir (`4.3`); aynı numarayı iki başlık taşımamalıdır | | `create_table` | Yeni SQL Server tablosu kurar, `sql/object/{Tablo}.sql` dosyasını yazar | Ek olarak `App.SqlQueryManager` ister; `Id`/`TenantId`/audit kolonlarını kendi ekler (`fullAudited`, `multiTenant` varsayılan açık); var olan tabloyu reddeder. View/SP/fonksiyon kurulamaz | | `list_wizards` · `get_wizard` | Wizard dosyalarını listeler (menü yolu, bağımlılar) · dosyayı diskteki hâliyle döner | Var olan menü kökünü/izin grubunu bulmak için önce `list_wizards` | -| `deploy_wizard` | Wizard cevabından ekranı, menüyü, yetkileri, dil anahtarlarını ve seed dosyasını üretir | Girdi §4.4'teki düz cevap ya da `get_wizard` çıktısıyla aynı dosya biçimi; enum'lar sayı. `replaceFileName` = `EditFileName` (§4.5). İstek demo veri taşıyorsa `SeedData: true` (§0.10.1) | +| `deploy_wizard` | Wizard cevabından ekranın wizard dosyasını ve menü/yetki/dil seed satırlarını yazar; veritabanına yazmaz (§4.0) | Girdi §4.4'teki düz cevap ya da `get_wizard` çıktısıyla aynı dosya biçimi; enum'lar sayı. `replaceFileName` = `EditFileName` (§4.5). İstek demo veri taşıyorsa `SeedData: true` (§0.10.1). Çıkıştaki `isPending: true` ekranın henüz kurulmadığını söyler — kullanıcı Wizard Manager'da DB Migrate çalıştırır | | `delete_wizard` | Wizard'ı ve yalnızca onun ürettiği kayıtları siler; `dropTables: true` ekranın tablosunu da düşürür | Bağımlı wizard varsa reddeder. Tablo kullanıcı açıkça onaylamadan düşürülmez; `App.SqlQueryManager` ister (§4.5) | | `start_dev_request` | `App.DevRequests` talebini üstlenir: `Ready to Start` durumundaysa `In Progress` durumuna alır, değilse reddeder | Ek olarak `App.DevRequests.Update` ister. Açıklamadaki görseller ayrı resim içeriği olarak gelir (§0.11) | | `get_dev_request` | Talebi durumuna dokunmadan okur (aynı metin + resim içerikleri) | Yarım kalan talebe dönmek için; `App.DevRequests` ister | | `append_dev_request_note` | Talep açıklamasının sonuna HTML ekler | Sonuç notu için; `Description`'ı `update_row` ile yazma (§0.11) | | `add_dev_request_subtasks` | `SubTasks` listesine tamamlanmış satırlar ekler | Atanan = bağlanan kullanıcı, bitiş = bugün (sunucu yazar); `SubTasks`'ı `update_row` ile yazma (§0.11) | -| `verify_wizard` | Deploy edilmiş ekranı veri yazmadan doğrular: menü, yetki tanımı, ekran sorgusu (List) ya da bileşen (Custom), seed kapanışı | `passed` yalnızca engelleyici bir kontrol kalırsa `false`; `seed_closure` engellemez ama rapora yazılır (§0.11) | +| `verify_wizard` | DB Migrate ile kurulmuş ekranı veri yazmadan doğrular (migrate bekleyen dosyada `install` kontrolü başarısız döner, diğerleri yapılmaz): menü, yetki tanımı, ekran sorgusu (List) ya da bileşen (Custom), seed kapanışı | `passed` yalnızca engelleyici bir kontrol kalırsa `false`; `seed_closure` engellemez ama rapora yazılır (§0.11) | | `list_components` · `get_component` · `save_component` · `delete_component` | Custom component okuma/yazma (ada göre upsert) | `save_component` visual designer bileşenini reddeder — yalnız Code modu (§7.1.1) | **Yetkiler.** MCP'nin iki yetkisi vardır: `.Invoke` (bağlantı + okuma) ve `.Write` (her @@ -526,7 +526,9 @@ eklenen dosya) bu satırlar ekranla birlikte seed edilir; migrate çalıştığ 1. `deploy_wizard` cevabına `SeedData: true` yaz. Ekranın `SeedFilePath`'i `data/{ListFormCode}.json` olur, `SeedSyncInsert/Update/Delete` açılır (§9.15). -2. Deploy'dan sonra her satırı `insert_row` ile ekle. Satır ekranın insert sözleşmesinden geçer +2. Deploy ekranı kurmaz; kullanıcıdan Wizard Manager'da **DB Migrate** çalıştırmasını iste. + `list_wizards`'ta dosyanın `isPending` değeri `false` olunca (ya da `verify_wizard` geçince) + her satırı `insert_row` ile ekle. Satır ekranın insert sözleşmesinden geçer ve `ListFormSeedDataSynchronizer` onu `configs/seeds/host/data/{ListFormCode}.json` dosyasına yazar (kiracı bağlamında `tenants/{TenantId}/data/`). 3. Migrate sırasında wizard seeder ekranı kurar, ardından `ListFormSeedDataApplier` dosyadaki @@ -768,7 +770,7 @@ Her editörün kendine özgü seçenekleri için §4.3.2. ## 4. Artefakt A — ListForm ekranı (Wizard seed dosyası) Konum: `configs/seeds/{kapsam}/wizard/{WizardName}.json` (dosya adında tarih yoktur) -Okuyan: `WizardDataSeeder` · Yazan: `ListFormWizardAppService` (deploy) ve `WizardSeedSynchronizer` +Okuyan: `WizardDataSeeder` (migrate) · Yazan: `ListFormWizardAppService` (deploy) ve `WizardSeedSynchronizer` (ListForm editöründen kayıt) · Okuma/yazma her zaman `WizardSeedFileDto.FromSeedJson` / `ToSeedJson` ile — düz `JsonSerializer` bloklu bölümleri düşürür. @@ -777,6 +779,46 @@ girdisidir — wizard ekranı ve MCP `deploy_wizard` bunu alır; *seed dosyası* çıktısıdır. List yolunda dosyayı deploy'a ürettir; `ListForm` bölümü olmayan List dosyası seeder tarafından uyarıyla **atlanır**. +### 4.0 Deploy dosya yazar, migrate kurar + +Deploy (wizard ekranı, MCP `deploy_wizard`, düzenleme dahil) **veritabanına hiçbir şey yazmaz ve +hiçbir şey silmez**. Tek çıktısı seed dosyalarıdır: + +| Dosya | İçerik | +| --- | --- | +| `wizard/{WizardName}.json` | Ekranın `ListForm`/`Fields` sözleşmesi, wizard cevapları, `InsertedRecords`, `Pending` | +| `data/App.Menus.Module.json`, `data/App.Menus.Menu.json` | Modül, üst menü, ekranın menüsü | +| `data/Abp.Identity.PermissionGroups.json`, `data/Abp.Identity.Permissions.json` | İzin grubu, okuma yetkisi ve alt yetkileri | +| `data/App.Languages.LanguageText.json` | Menü, başlık, açıklama, grup ve alan başlıklarının EN + TR metinleri (`Id` iş anahtarından v5 GUID, `SeedRowIds.LanguageText`) | + +Satırları `WizardSeedRowStore` yazar; anahtarlar `WizardSeedRowKeys`'ten gelir (export aynı +listeyle toplar). **Sahiplik:** bir satır ne veritabanında ne dosyada varsa ya da önceki sürüm ona +sahipse wizard'ındır (`InsertedRecords`) ve cevaplarla yazılır; başkasına ait satıra (var olan üst +menü, izin grubu, paylaşılan dil anahtarı) dokunulmaz — metni kendi ekranından değişir. + +Kurulum **DB Migrate**'tedir (Wizard Manager'daki buton, `POST /api/setup/migrate`). Sıra: +`WizardDataSeeder` ekrana özgü kayıtları kurar (veri kaynağı, ListForm, alanlar, iş akışı), ardından +`ListFormSeedDataApplier` menü/yetki/dil satırlarını ekler (eksik olanı ekler, var olana dokunmaz). +**Yetkiler hiçbir role otomatik atanmaz** — admin dahil; ekranı hangi rolün göreceği rol ekranından +seçilir. + +Deploy'dan migrate'e kadar wizard'ın hiçbir kaydı veritabanında yoktur ve bu beklenen durumdur. +Yeni modül, kök/üst menü, izin grubu, yetki ya da dil anahtarı veritabanında bulunmuyorsa +deploy onu dosyaya yazar, migrate `Order` sırasıyla (route → modül → menü, grup → yetki) ekler. +Bunları uyarı olarak raporlama, kullanıcıdan başka bir ekrandan eklemesini isteme. Deploy +sonrası kullanıcıya söylenecek tek adım **DB Migrate**'tir. + +**Bekleyen kurulum (`Pending`).** Deploy dosyaya `Pending` bölümü yazar; Wizard Manager dosyayı +"Migrate bekliyor" olarak gösterir (`WizardFileInfoDto.IsPending` = `Pending` dolu **ya da** ekran bu +veritabanında kurulu değil). Düzenleme önceki sürümün sahip olduğu kayıtları +`Pending.Replaced`'a, kodu değişen eski ekranı `Pending.ListFormCodes`'a ekler ve artık üretilmeyen +satırları dosyalardan çıkarır. Migrate bekleyen dosyada önce bu kayıtları ve wizard'ın güncel +kayıtlarını veritabanından kaldırır, ekranı yeniden kurar, `Pending`'i siler; kaldırılan satırları +applier dosyadaki güncel hâlleriyle geri ekler. "Yalnızca eksiği ekle" kuralı böylece düzenlenmiş +ekranı da güncel hâline getirir. Kaldırılan yetkinin rol atamaları yalnızca yetki kalıcı olarak +gidiyorsa silinir. `WizardSeedSynchronizer` bekleyen dosyaya yazmaz — dosya ekranın bir sonraki +sürümünü taşır. + ### 4.1 Dosya iskeleti ```jsonc @@ -859,7 +901,7 @@ saklanır. Dosya okunurken bu alanlar `ListForm`/`Fields` üzerinden geri doldur | `MenuCode` | Menü kaydının kodu; yetki adlarının kökü de budur. | | `MenuUrl` | List'te `/admin/list/{MenuCode}` hesaplanır; Custom'da bileşenin `RoutePath`'i yazılır. | | `CustomComponentName` | Yalnızca Custom yolunda; bağlanacak bileşenin adı. | -| `EditFileName` | Yalnızca **istek girdisi** (MCP'de `replaceFileName`): doluysa güncellemedir — `.Update` yetkisi ister, `SeededAt` korunur, eski dosya ve ürettiği kayıtlar `Redeploy` kipinde silinip ekran **cevaptan yeniden üretilir**; ListForm editöründe yapılmış, wizard'da karşılığı olmayan ayarlar kaybolur. Seed dosyasına yazılmaz. | +| `EditFileName` | Yalnızca **istek girdisi** (MCP'de `replaceFileName`): doluysa güncellemedir — `.Update` yetkisi ister, `SeededAt` korunur, dosyalar cevaptan yeniden yazılır ve ekran migrate'te **cevaptan yeniden kurulur** (§4.0); ListForm editöründe yapılmış, wizard'da karşılığı olmayan ayarlar kaybolur. Seed dosyasına yazılmaz. | **Menü** @@ -891,8 +933,8 @@ saklanır. Dosya okunurken bu alanlar `ListForm`/`Fields` üzerinden geri doldur | `Language.MenuParentEn` / `Tr` | Üst menü etiketi. | Üretilen yetkiler: `{MenuCode}` (okuma, kök) + List yolunda `.Create .Update .Delete .Export -.Import .Note`, Custom yolunda yalnızca `.Create .Update .Delete`. Deploy hepsini admin rolüne -grant eder; `WizardDataSeeder` grant yazmaz — migrate ile gelen ekranın rol yetkisi ayrıca verilir. +.Import .Note`, Custom yolunda yalnızca `.Create .Update .Delete`. Hiçbiri bir role otomatik +atanmaz (admin dahil); ekranı görecek roller yetkiyi rol ekranından alır. **Veri** @@ -1387,10 +1429,11 @@ bölümlerine düşer (§4.1). `SeededAt` ilk deploy anında sunucu tarafından ### 4.4.1 Deploy'un ve seeder'ın senin yerine ürettikleri — cevaba yazma Aşağıdakileri deploy wizard cevabından **kendisi** üretir (`ListFormWizardDto.BuildListForm` / -`BuildFields`; menü, yetki, dil ve veri kaynağı kayıtları `ListFormWizardAppService`). Ekrana ait -olanlar dosyanın `ListForm`/`Fields` bölümlerine düşer; `WizardDataSeeder` migrate'te ekranı -oradan olduğu gibi kurar (`GridOptionsEditDto.ApplyTo`), yeniden türetmez. Menü, yetki ve dil -kayıtlarını seeder `Wizard` bölümünden aynı kurallarla üretir: +`BuildFields`; menü, yetki ve dil satırları `ListFormWizardAppService` → `WizardSeedRowStore`). +Ekrana ait olanlar dosyanın `ListForm`/`Fields` bölümlerine düşer; `WizardDataSeeder` migrate'te +ekranı oradan olduğu gibi kurar (`GridOptionsEditDto.ApplyTo`), yeniden türetmez. Menü, yetki ve +dil kayıtları platform seed dosyalarına satır olarak yazılır, migrate `ListFormSeedDataApplier` +ile ekler (§4.0). Sıra değerleri veritabanı ile dosyadaki satırların birleşiminden hesaplanır: | Üretilen | Kural | | --- | --- | @@ -1399,7 +1442,7 @@ kayıtlarını seeder `Wizard` bölümünden aynı kurallarla üretir: | Yetkilerin dil anahtarları | Ortak sabitler: `App.Platform.Create/Update/Delete/Export/Import/Note` | | Üst menü | `MenuParentCode` yoksa yaratılır, `Order = max(kök Order) + 1` | | Menü sırası | `MenuOrder` 0/boş ise `max(kardeş Order) + 1` | -| `DataSource` | `DataSourceCode` yoksa yaratılır; bağlantı metninde `Server` geçiyorsa `Mssql`, aksi hâlde `Postgresql` | +| `DataSource` | `DataSourceCode` yoksa migrate yaratır; bağlantı metninde `Server` geçiyorsa `Mssql`, aksi hâlde `Postgresql` | | `LayoutJson` | Görünüm bayrakları + `DefaultLayout` | | `PermissionJson` / alan `PermissionJson` | `{MenuCode}` kökünden türetilir | | `EditingFormJson` | `Groups` içinden; anahtar alan ve `IncludeInEditingForm: false` olanlar hariç | @@ -1431,31 +1474,45 @@ kayıtlarını seeder `Wizard` bölümünden aynı kurallarla üretir: ### 4.5 Güncelleme, idempotency ve silme semantiği -**Seeder idempotenttir — bir ekran zaten varsa dosya atlanır** (yalnızca eksik dil metinleri -eklenir). "Zaten var" ölçütü yola göre değişir: +**Seeder idempotenttir — bir ekran zaten kuruluysa ve bekleyen kurulumu (`Pending`) yoksa dosya +atlanır.** "Kurulu" ölçütü yola göre değişir (Wizard Manager'daki durum da aynı karardır): | Yol | Kontrol | | --- | --- | | `List` | `ListForm` kaydında bu `ListFormCode` var mı | -| `Custom` + menülü | Bu `MenuCode` ile menü var mı | -| `Custom` + menüsüz | Bu `MenuCode` adında yetki var mı | +| `Custom` | Bu `MenuCode` adında yetki var mı | -Sonuç: **kurulu bir ekranı seed dosyasını düzenleyerek değiştiremezsin** (dosya yalnızca boş -veritabanında etkilidir). Değiştirmenin yolları: +Sonuç: **kurulu bir ekranı seed dosyasını elle düzenleyerek değiştiremezsin** (`Pending` yazan +deploy'dur). Değiştirmenin yolları: 1. **ListForm editörü** (`/admin/listform/edit/{kod}`) — alan ekleme, JSON kolonları, komutlar, görünümler. Her kayıt `WizardSeedSynchronizer` ile aynı dosyanın `ListForm`/`Fields`/`Wizard` bölümlerine geri yazılır; **ince ayar için tercih edilen yol** budur. 2. **`EditFileName` ile yeniden deploy** (wizard ekranı ya da MCP `replaceFileName`) — menü, - yetki grubu, veri kaynağı gibi wizard cevabına ait değişiklikler için. Ekran cevaptan yeniden - üretilir; editörde yapılmış, wizard'da karşılığı olmayan ayarlar kaybolur. + yetki grubu, veri kaynağı gibi wizard cevabına ait değişiklikler için. Dosyalar yeniden yazılır, + ekran DB Migrate'te cevaptan yeniden kurulur (§4.0); editörde yapılmış, wizard'da karşılığı + olmayan ayarlar kaybolur. 3. İlgili kayıtları elle sil, sonra seed'i yeniden çalıştır. > `List` yolunda seeder ekranı uygularken `ListForm`, `ListFormField` ve `ListFormWorkflow` > kayıtlarını **silip yeniden yazar**; idempotency kapısını geçen dosya için bu güvenlidir. -**Silme** (`WizardRecordCleaner`): ekran (`ListForm` + alanlar + iş akışı) ve `InsertedRecords` -içindekiler, **başka bir yerde kullanılmıyorsa** silinir. Sıra bağımlılık yönündedir — önce +**Silme** iki yoldur ve ikisinde de **başka bir yerde kullanılan kayıt/satır silinmez**: + +- **Migrate bekleyen wizard** (`IsPending`) — veritabanına dokunulmaz. Yalnızca wizard'ın json + dosyalarına eklediği satırlar (`InsertedRecords` + `Pending.Replaced`: menü, modül, yetki, izin + grubu, dil metni) çıkarılır (`WizardSeedRowStore.RemoveOwned`). Satır, seed dosyalarında ona + bağlanan başka bir satır (alt menü, alt yetki, menünün istediği yetki, gruptaki yetki, modülü + kullanan menü/yetki, anahtarı görünen ad olarak kullanan satır) ya da veritabanında onu kullanan + bir kayıt varsa (`WizardRecordCleaner.FindUsagesAsync`, aşağıdaki tablo) kalır ve raporda + kullananlarıyla görünür. +- **Kurulu wizard** — önce veritabanı kayıtları kendi işleminde silinir (`WizardRecordCleaner`); + işlem başarıyla tamamlanırsa silinen (ya da veritabanında bulunmayan) kayıtların json satırları + çıkarılır (`RemoveSeedRowsAsync`). Veritabanı adımı hata verirse dosyalara dokunulmaz. Migrate + yalnızca eksik kaydı ekler, silemez; bu yüzden silme veritabanına doğrudan yansır. + +Kurulu wizard'da ekran (`ListForm` + alanlar + iş akışı) ve `InsertedRecords` içindekiler, **başka +bir yerde kullanılmıyorsa** silinir. Sıra bağımlılık yönündedir — önce kullanan, sonra kullanılan — ve her kontrol o ana kadar kalan kayıtlara karşı yapılır: | Kayıt | Silinmez, çünkü kullanan var | @@ -1475,24 +1532,21 @@ olabilir. Üst menü zinciri yukarı doğru yürünür — alt menüsü kalmayan (`Url` boş) menü silinir, adresi olan ya da altında menü kalan kayıtta zincir durur. Wizard'ın `PermissionGroupName`'i içinde yetki kalmamışsa, silinen menülerin ve `MenuParentModuleId`'nin modülü yukarıdaki tabloda kullananı yoksa silinir; adlarının dil anahtarları da aynı kontrolden -geçer. Bu kaplar rapora yalnızca silindiklerinde girer. Düzenleme (redeploy) yolunda kaplara -dokunulmaz. +geçer. Bu kaplar rapora yalnızca silindiklerinde girer. -Silinen menü, modül, yetki ve yetki grubu satırları ekranların seed dosyalarından da çıkarılır -(`App.Menus.{Menu,Module}.json`, `Abp.Identity.Permission{s,Groups}.json`; -`IListFormSeedDataSynchronizer.RemoveRowsByFieldAsync`, iş anahtarıyla, `SeedSyncDelete`'e -bağlı). Wizard bu dosyalara yazmaz, ama kayıt menü/yetki ekranlarından düzenlendiğinde satır -senkronizasyonla girer; dosyada kalırsa migrate kaydı geri getirir. +Kurulu wizard'da silinen ya da veritabanında bulunmayan menü, modül, yetki, yetki grubu ve dil +metni satırları, veritabanı işlemi tamamlandıktan sonra seed dosyalarından da çıkarılır (`App.Menus.{Menu,Module}.json`, +`Abp.Identity.Permission{s,Groups}.json`, `App.Languages.LanguageText.json`; +`IListFormSeedDataSynchronizer.RemoveRowsByFieldAsync`, iş anahtarıyla, `SeedSyncDelete`'e bağlı); +dosyada kalırsa migrate kaydı geri getirir. Kullanımda olduğu için bırakılan kaydın satırı kalır. Bağlı wizard'ı olan (alt formu ya da component bağımlılığı wizard olarak duran) bir wizard silinemez; önce bağlı olanlar silinir. Kural listedeki gruplamayla aynı kaynaktandır (`WizardFileInfoDto.Dependencies`), sunucu `DeleteFile`'da reddeder, istemci butonu kapatır. Silme ucu (`DeleteFile`) her kaydın akıbetini (`Deleted` / `Kept` + kullananlar / `NotFound`) `WizardDeleteResultDto` olarak döner ve loglar; Wizard Manager bunu rapor olarak gösterir. -Düzenleme yolu (`EditFileName`) aynı sınıfı `Redeploy` kipinde çağırır: üst menü, yetki grubu, -modül, veri kaynağı ve admin dışındaki rollerin yetki atamaları korunur — hemen ardından yeniden -kullanılırlar; silinip üretilseler menü sırası ve rol atamaları kaybolurdu. Wizard'ın yarattığı -modül `InsertedRecords.ModuleIds` ile izlenir. +Düzenleme (`EditFileName`) bu sınıfı çağırmaz; önceki sürümün kayıtlarını migrate kaldırır (§4.0). +Wizard'ın yarattığı modül `InsertedRecords.ModuleIds` ile izlenir. Silme yolunda ekranın veri aynası (`ListForm.SeedFilePath` → `data/{ListFormCode}.json` ya da klasörü) ekranla birlikte silinir; ekransız uygulanamaz. @@ -1509,7 +1563,7 @@ diyaloğunda tabloyu seçerse (`WizardDeleteInput.DropTables`) kayıt temizliği (`{sql|postgres}/object/{Tablo}.sql`, `crud/{Tablo}.json`) silinir. Düşürme hatası (ör. yabancı anahtar) silmeyi durdurmaz: tablo ve scripti yerinde kalır, rapor `Failed` + mesaj gösterir. Tablo düşürmek `App.SqlQueryManager` yetkisi ister; wizard silme yetkisi tek başına yetmez. -Düzenleme (`Redeploy`) yolu tabloya hiç dokunmaz. +Düzenleme tabloya hiç dokunmaz. ### 4.6 Alt formlar ve widget'lar @@ -2803,11 +2857,12 @@ düşerdi; **(b)** hedefte eksik kolonlar eklenir; **(c)** `crud/*.json` → `IC **(d)** `data/*.json` → `ListFormSeedDataApplier` (tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle güncellenir, soft delete edilmiş satırın `IsDeleted`/`DeletionTime`/`DeleterId` izleri temizlenir). -**(e)** `wizard/*.json` → ekran kurulur. Kurulum wizard ekranıyla aynı yoldan geçer -(`ListFormWizardAppService.DeployWizardAsync`); dosyadaki `ListForm`/`Fields` sözleşmesi olduğu gibi -kullanılır, wizard cevaplarından yeniden türetilmez (türetmek tasarımcı değişikliklerini silerdi). -Hedefte zaten var olan ekrana dokunulmaz ve her ekran kendi UoW'unda kurulur. Bu adım rollback -kapsamında değildir. Migrate beklenmez; import bittiğinde ekran çalışır durumdadır. +Wizard'ın menü/yetki/dil satırlarını taşıyan platform dosyaları (`WizardSeedRowStore.ListFormCodes`) +bu adımda **uygulanmaz**. **(e)** `wizard/*.json` → ekran **kurulmaz**: dosyaya `Pending` yazılır, +Wizard Manager onu "Migrate bekliyor" gösterir ve ekran, menü ve yetkiler bir sonraki DB Migrate'te +gelir (§4.0) — hedefte kurulu olan ekran da dosyadaki hâliyle yeniden kurulur. Bu adım rollback +kapsamında değildir. Export zip'i wizard'ın platform satırlarını (`WizardSeedRowKeys.All`) ilgili +dosyalardan süzerek taşır; import onları hedef dosyayla anahtar üzerinden birleştirir. **Analiz doğrulama da yapar** (`WizardImportValidator`) — dosyalar yazılmadan önce. En az bir `Error` varken import başlatılamaz; sunucu oturuma `blocked` işaret dosyası bırakır, dolayısıyla @@ -4117,7 +4172,7 @@ Prompt ile üretim yaptıysan ek olarak: - [ ] Custom component visual modda ise `Props.visualDesigner` ile `Code` marker'ı aynı dokümanı taşıyor. - [ ] Menü ve route elle eklenmedi; wizard dosyasından geliyor. -- [ ] List ekranı deploy ile kuruldu (wizard ekranı / MCP `deploy_wizard`) ya da elle yazılan +- [ ] List ekranı deploy ile üretildi (wizard ekranı / MCP `deploy_wizard`) ya da elle yazılan dosya `ListForm` + `Fields` bölümlerini taşıyor (§4.1). - [ ] Kullanıcıya hangi dosyaların üretildiği ve DB Migrate gerektiği söylendi. - [ ] Keşif turu işletildi (§0.6): Tur 1 soruları soruldu, yol için gerekçe verildi ve tarif diff --git a/README.md b/README.md index a268ca38..4a7efdad 100644 --- a/README.md +++ b/README.md @@ -506,14 +506,20 @@ adım sayısından bağımsızdır. | İş akışı | `WizardStepWorkflow.tsx` | Onay/koşul grafiği | | Düzen adımları | `WizardStepTodoLayout` · `WizardStepTreeLayout` · `WizardStepGanttLayout` · `WizardStepSchedulerLayout` | Yalnızca ilgili görünüm açıksa görünür | | Bileşen | `WizardStepComponent.tsx` · `WizardStepCustomComponent.tsx` | Custom yolunda bağlanacak bileşen | -| Yayınlama | `WizardStepDeploy.tsx` | Özet, doğrulama ve seed dosyasının üretimi | +| Yayınlama | `WizardStepDeploy.tsx` | Özet, doğrulama ve seed dosyalarının üretimi; ardından Wizard Manager'a dönülür | Menü ağacı yardımcıları (`menuTree.ts`) adım bileşenlerinden ayrı bir modüldedir; böylece Wizard ve `SqlTableDesignerDialog` bu fonksiyonları kullanırken adım bileşenlerini pakete çekmez. **Seed dosyası ekranın kendisini taşır.** Dosya `Wizard` bölümünün yanında ekranın `GridOptionsEditDto` sözleşmesini (`ListForm`) ve alanların `ColumnFormatEditDto` sözleşmesini -(`Fields`) tutar; `WizardDataSeeder` ekranı bu iki bölümden kurar. Böylece Wizard ile ListForm +(`Fields`) tutar; `WizardDataSeeder` ekranı bu iki bölümden kurar. **Deploy veritabanına yazmaz:** +wizard dosyasının yanında menü, modül, izin grubu, yetki ve dil metinleri platform seed +dosyalarına (`data/App.Menus.{Menu,Module}.json`, `data/Abp.Identity.Permission{Groups,s}.json`, +`data/App.Languages.LanguageText.json`) satır olarak yazılır (`WizardSeedRowStore`) ve hepsi DB +Migrate ile kurulur. Deploy/import dosyaya `Pending` yazar; Wizard Manager dosyayı "Migrate +bekliyor" rozetiyle gösterir, migrate bitince liste ve menü yenilenir. Yetkiler hiçbir role +otomatik atanmaz. Böylece Wizard ile ListForm detayı aynı veri yapısını konuşur ve tasarımcıdaki değişiklikler wizard'a geri yazılabilir. `Wizard` bölümü yalnızca ekrandan üretilemeyen bilgileri (menü, yetki, dil metinleri, bileşen yolu, alan başlıklarının dil karşılıkları) taşır; `ListForm`/`Fields` içinde karşılığı olan alanlar @@ -652,13 +658,12 @@ Adımların altında yatan üç sunucu işlemi şunlardır: sayısı dialogda raporlanır. Bu adım `RollbackImport` kapsamında **değildir** — geri alma bu adımdan önce yapılır. -5. **Ekran tanımları** (`wizard/*.json`) — son adım: ekranın tablosu, CRUD ucu ve lookup verileri o - noktada hazırdır. Kurulum wizard ekranının kullandığı yolun aynısından geçer - (`DeployWizardAsync`), dosya ekranın üretilmiş `ListForm`/`Fields` sözleşmesini taşıdığı için - bunlar wizard cevaplarından **yeniden türetilmez** — tasarımcı üzerindeki her değişiklik olduğu - gibi kurulur. **Hedefte zaten var olan ekrana dokunulmaz.** Her ekran kendi işleminde kurulur: - bir dosyadaki hata yalnızca o ekranın kayıtlarını geri alır. İmport bittiğinde ekran çalışır - durumdadır, migrate beklemez; istemci ABP config'ini yeniler, böylece menü anında görünür. +5. **Ekran tanımları** (`wizard/*.json`) — ekran import'ta **kurulmaz**: dosyaya `Pending` yazılır, + Wizard Manager onu "Migrate bekliyor" gösterir ve ekran, deploy'daki gibi, DB Migrate'te + `WizardDataSeeder` tarafından kurulur (hedefte kurulu olsa da dosyadaki hâliyle). Dosya ekranın + üretilmiş `ListForm`/`Fields` sözleşmesini taşıdığı için bunlar wizard cevaplarından **yeniden + türetilmez**. Wizard'ın menü/yetki/dil satırlarını taşıyan platform dosyaları 4. adımda + uygulanmaz; onları da migrate ekler. **Analiz doğrulama da yapar** (`WizardImportValidator`). Zip'in hedef ortamda yarım bir kurulum bırakıp bırakmayacağı dosyalar yazılmadan **önce** denetlenir; bulgular `Error`/`Warning` olarak @@ -911,8 +916,8 @@ Sıra **kardeşler arasındadır**: her üst yetkinin altında 1'den başlar, gr kendi aralarında 1'den. `PlatformPermissionAppService` sıralama anahtarını kökten yaprağa üst yetki zincirinden kurar, böylece çocuk her zaman ebeveyninin altında kalır. Değerler seed dosyalarındaki `Order` alanında tutulur ve Permissions / Permission Groups ekranlarından düzenlenebilir. -Wizard (ve migrate'te `WizardDataSeeder`) yeni grubu ve grubun yeni kök yetkisini kardeşlerin en -büyük sırasının bir fazlasıyla, alt yetkileri Create/Update/Delete/Export/Import/Note dizilimiyle +Wizard deploy'u yeni grubu ve grubun yeni kök yetkisini (seed satırı olarak) veritabanı ile seed +dosyasındaki kardeşlerin en büyük sırasının bir fazlasıyla, alt yetkileri Create/Update/Delete/Export/Import/Note dizilimiyle 1'den yazar (`NextPermissionOrder`, `AbpPermissionsExtensions`). Aksiyon yetkisi olan alt gruplar (UI tarafındaki karşılıkları `constants/permission.constant.ts` @@ -1401,7 +1406,7 @@ liste süzgeci ve çağrı kapısı aynı kuralı kullanır. | `get_dev_guide` | Üretim sözleşmesi: `lowcode` / `reference` belgelerinin başlık listesi ya da istenen bölümü (numarayla, örn. `4.2`). Çok büyük bir üst bölüm yalnızca giriş metni + alt başlıklarıyla döner. | | `create_table` | Yeni tabloyu SQL Query Manager tablo tasarımcısıyla aynı script biçimiyle kurar (`McpTableCreator`): `Id` + `TenantId` + tam audit kolonları şablondan gelir, istemci yalnızca iş kolonlarını verir; tip ve varsayılan değer beyaz listeden geçer, istemci SQL yazamaz. Script `{kapsam}/sql/object/{Tablo}.sql` dosyasına yazılır. Yalnızca oluşturur — tablo varsa reddeder; yalnızca SQL Server. `.Write` + `App.SqlQueryManager` ister. | | `list_wizards` / `get_wizard` | Wizard File Manager dosyaları; `get_wizard` seed dosyasını diskteki biçimiyle döndürür. | -| `deploy_wizard` | Wizard'ı Wizard ekranıyla aynı yoldan (`IListFormWizardAppService.Create`) deploy eder. Girdi seed dosyası biçimi (`{"Wizard": …}`) ya da düz cevaplardır; ikisi de `WizardSeedFileDto.FromSeedJson`'dan geçer. `replaceFileName` doluysa güncellemedir (eski dosya ve ürettiği kayıtlar silinip yeniden üretilir). İstek demo veri taşıyorsa (prompt'taki satırlar, ekran görüntüsü) wizard `SeedData: true` ile deploy edilir — ekranın `SeedFilePath`'i `data/{ListFormCode}.json` olur ve `SeedSync*` bayrakları açılır — ardından satırlar `insert_row` ile eklenir; `ListFormSeedDataSynchronizer` onları `configs/seeds/host/data/` altına yazar, migrate ekranla birlikte geri yükler (`lowcode.instructions.md` §0.10.1). | +| `deploy_wizard` | Wizard'ı Wizard ekranıyla aynı yoldan (`IListFormWizardAppService.Create`) deploy eder. Girdi seed dosyası biçimi (`{"Wizard": …}`) ya da düz cevaplardır; ikisi de `WizardSeedFileDto.FromSeedJson`'dan geçer. Wizard ekranı gibi yalnızca seed dosyalarını yazar; ekran DB Migrate ile kurulur ve çıkıştaki `isPending` o zamana kadar `true` kalır (`verify_wizard` bekleyen dosyada `install` kontrolünde durur). `replaceFileName` doluysa güncellemedir (dosyalar yeniden yazılır, önceki kayıtları migrate kaldırır). İstek demo veri taşıyorsa (prompt'taki satırlar, ekran görüntüsü) wizard `SeedData: true` ile deploy edilir — ekranın `SeedFilePath`'i `data/{ListFormCode}.json` olur ve `SeedSync*` bayrakları açılır — migrate sonrasında satırlar `insert_row` ile eklenir; `ListFormSeedDataSynchronizer` onları `configs/seeds/host/data/` altına yazar, migrate ekranla birlikte geri yükler (`lowcode.instructions.md` §0.10.1). | | `delete_wizard` | Wizard Manager'daki silmeyle aynı: `InsertedRecords` + ekranın veri aynası, bağımlı wizard varsa reddedilir. `dropTables: true` ekranın tablosunu (yalnızca tasarımcı/`create_table` ile kurulmuş ve başka yerde kullanılmayan) CRUD endpoint'leri, `sql/object` ve `crud` dosyalarıyla düşürür; model bunu kullanıcı onayı olmadan göndermez (`WizardTableCleaner`, `lowcode.instructions.md` §4.5). | | `verify_wizard` | Deploy edilmiş ekranı veri yazmadan doğrular: menü kaydı ve URL'si, menünün istediği yetki tanımı (`AbpPermissions`), List yolunda ekran sorgusu (`GetSelectAsync`, tek satır, çağıranın yetkisiyle), Custom yolunda bileşen (etkin, rota = menü URL'si) ve seed kapanışı (`ExportAnalysis` uyarıları; engellemez). `App.DevRequests` geliştirme döngüsünün test adımıdır (`lowcode.instructions.md` §0.11). | | `start_dev_request` | Geliştirme talebini Id ya da tam başlıkla üstlenir: yalnızca `Ready to Start` durumundakini `In Progress` durumuna alır, diğerlerini reddeder; talebin metnini döndürür. Açıklamaya yapıştırılmış base64 görseller (`data:image/png|jpeg|gif|webp`) metinden ayıklanır (`McpDevRequestImages`), yerlerinde `[image-N]` kalır ve her biri ayrı MCP resim içeriği (`ImageContentBlock`) olarak döner — model görseli resim olarak görür, görsel hiçbir yere yüklenmez. `.Write` + `App.DevRequests.Update` ister. | diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs index 4b2370c4..225b921a 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -57,6 +57,7 @@ public class McpDevelopAppService( private static class VerifyChecks { public const string WizardFile = "wizard_file"; + public const string Install = "install"; public const string Menu = "menu"; public const string Permission = "permission"; public const string Select = "select"; @@ -219,6 +220,16 @@ public class McpDevelopAppService( result.MenuUrl = wizard.MenuUrl; result.Checks.Add(new McpVerifyCheckDto { Name = VerifyChecks.WizardFile, Passed = true }); + // Deploy yalnizca seed dosyalarini yazar; menu, yetki ve ekran DB Migrate'ten sonra vardir. + // Kurulmamis bir wizard'in geri kalan kontrolleri anlamsiz hatalar uretirdi. + var file = (await wizardAppService.GetFiles()) + .FirstOrDefault(f => string.Equals(f.FileName, fileName, StringComparison.OrdinalIgnoreCase)); + if (file?.IsPending == true) + { + result.Checks.Add(Failed(VerifyChecks.Install, L[McpErrorCodes.VerifyWizardPending, fileName])); + return result; + } + var permissionName = listFormCode; if (wizard.CreateMenu) { diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpErrorCodes.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpErrorCodes.cs index 271b5bca..d2b3b75b 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpErrorCodes.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain.Shared/McpErrorCodes.cs @@ -36,6 +36,7 @@ public static class McpErrorCodes public const string ComponentCodeRequired = Prefix + "ComponentCodeRequired"; public const string ComponentNotFound = Prefix + "ComponentNotFound"; public const string VerifyWizardFileInvalid = Prefix + "VerifyWizardFileInvalid"; + public const string VerifyWizardPending = Prefix + "VerifyWizardPending"; public const string VerifyMenuMissing = Prefix + "VerifyMenuMissing"; public const string VerifyMenuDisabled = Prefix + "VerifyMenuDisabled"; public const string VerifyMenuUrlMismatch = Prefix + "VerifyMenuUrlMismatch"; diff --git a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs index aacf9e4b..855ad883 100644 --- a/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs +++ b/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.HttpApi/Tools/McpDevelopTools.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel; using System.Text; using System.Text.Json; @@ -64,7 +64,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService) => await developAppService.GetWizardAsync(fileName); [McpServerTool(Name = McpToolNames.DeployWizard, Destructive = true, Idempotent = false)] - [Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari, menusu ve seed dosyasi birlikte uretilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve deploy'dan sonra her satiri insert_row ile ekle: satirlar seed klasorundeki data/{ListFormCode}.json dosyasina yazilir ve migrate ile ekranla birlikte geri yuklenir; SeedData olmadan eklenen satir yalnizca bu veritabaninda kalir. replaceFileName verilirse o wizard ve urettigi kayitlar silinip yeniden uretilir; ekran wizard cevaplarindan yeniden turetildigi icin tasarimcida yapilmis ve wizard'da karsiligi olmayan ayarlar kaybolur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")] + [Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme.Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz; DB Migrate calistiktan sonra (isPending false, verify_wizard gecer) her satiri insert_row ile ekle: satirlar seed klasorundeki data/{ListFormCode}.json dosyasina yazilir ve migrate ile ekranla birlikte geri yuklenir; SeedData olmadan eklenen satir yalnizca bu veritabaninda kalir. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir; ekran wizard cevaplarindan yeniden turetildigi icin tasarimcida yapilmis ve wizard'da karsiligi olmayan ayarlar kaybolur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")] public async Task DeployWizardAsync( [Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi) ya da duz wizard cevaplari. Enum alanlari sayi olarak yazilir.")] string wizard, @@ -82,7 +82,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService) => await developAppService.DeleteWizardAsync(fileName, dropTables); [McpServerTool(Name = McpToolNames.VerifyWizard, ReadOnly = true)] - [Description("Deploy edilmis bir wizard'i veri yazmadan dogrular: menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. deploy_wizard'dan sonra ve bir gelistirme talebini Staging durumuna almadan once cagir.")] + [Description("Deploy edilip DB Migrate ile kurulmus bir wizard'i veri yazmadan dogrular (migrate bekleyen wizard'da install kontrolu basarisiz doner ve diger kontroller yapilmaz): menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. deploy_wizard'dan sonra ve bir gelistirme talebini Staging durumuna almadan once cagir.")] public async Task VerifyWizardAsync( [Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")] string fileName) diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs index f8a7a1c9..cdb2ad64 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs @@ -34,11 +34,18 @@ public class WizardSeedFileDto public bool IsCreatedField { get; set; } /// - /// Bu Wizard çalışırken gerçekten YENİ oluşturulan kayıtlar. - /// Daha önce var olan kayıtlar buraya eklenmez, silme işleminde sadece bu liste kullanılır. + /// Wizard'in sahip oldugu kayitlar: deploy sirasinda ne veritabaninda ne seed dosyalarinda + /// bulunan, satiri ilk kez bu wizard'in yazdigi kayitlar. Paylasilan kayitlar buraya girmez; + /// silme ve duzenleme yalnizca bu listeye dokunur. /// public WizardInsertedRecordsDto InsertedRecords { get; set; } = new(); + /// + /// Doluysa dosya henuz veritabanina uygulanmamistir: deploy yalnizca dosyalari yazar, kurulumu + /// bir sonraki migrate yapar (WizardDataSeeder) ve bu bolumu temizler. + /// + public WizardPendingDto Pending { get; set; } + /// /// Dosyaya yazmadan once ortama ozgu alanlari temizler. Kayit kimlikleri ve audit damgalari /// veritabanina aittir; dosyada tasinirlarsa her ortamda anlamsiz fark uretirler. EditType ise @@ -161,7 +168,13 @@ public class WizardSeedFileDto private const string MenuSection = "Menu"; private const string LanguageSection = "Language"; - private static readonly JsonSerializerOptions SeedWriteOptions = new() { WriteIndented = true }; + // Varsayilan kodlayici Turkce harfleri de kacirir ("Kullanıcı"); dosya elle okunur ve + // diff'lenir, bu yuzden veri dosyalariyla ayni kodlayici kullanilir. + private static readonly JsonSerializerOptions SeedWriteOptions = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; private static readonly JsonSerializerOptions SeedReadOptions = new() { PropertyNameCaseInsensitive = true }; /// @@ -202,6 +215,11 @@ public class WizardSeedFileDto } } + // Bekleyen kurulum yoksa bolum dosyada hic yer almaz; kurulu bir wizard'in dosyasi + // migrate sonrasinda deploy oncesiyle birebir ayni kalir. + if (Pending == null && node is JsonObject root) + root.Remove(nameof(Pending)); + if (node?[nameof(Wizard)] is JsonObject wizardNode) { // Baglanti cumlesi yalnizca yeni bir veri kaynagi tanimlanirken doludur; bos degeri @@ -230,7 +248,7 @@ public class WizardSeedFileDto node[nameof(Wizard)] = Reorder(wizardNode, WizardKeyOrder); } - return node?.ToJsonString(SeedWriteOptions) ?? string.Empty; + return SeedJsonText.RestoreSurrogatePairs(node?.ToJsonString(SeedWriteOptions) ?? string.Empty); } /// @@ -365,12 +383,51 @@ public class WizardInsertedRecordsDto /// Bu Wizard tarafından oluşturulan yeni Module kayıtları. public List ModuleIds { get; set; } = []; + /// Iki listenin birlesimi; ayni kayit bir kez yer alir. + public WizardInsertedRecordsDto Union(WizardInsertedRecordsDto other) + { + static List Merge(List a, List b) => + [.. (a ?? []).Concat(b ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)]; + + return new WizardInsertedRecordsDto + { + LanguageKeys = Merge(LanguageKeys, other?.LanguageKeys), + PermissionGroupNames = Merge(PermissionGroupNames, other?.PermissionGroupNames), + PermissionNames = Merge(PermissionNames, other?.PermissionNames), + MenuCodes = Merge(MenuCodes, other?.MenuCodes), + DataSourceCodes = Merge(DataSourceCodes, other?.DataSourceCodes), + ModuleIds = Merge(ModuleIds, other?.ModuleIds), + }; + } + [JsonIgnore] public bool HasAny => LanguageKeys.Count > 0 || PermissionGroupNames.Count > 0 || PermissionNames.Count > 0 || MenuCodes.Count > 0 || DataSourceCodes.Count > 0 || ModuleIds.Count > 0; } +/// +/// Deploy edilmis ama henuz migrate edilmemis bir wizard'in bekleyen kurulumu. +/// +/// Duzenleme veritabanina dokunmadigi icin onceki surumun kurdugu kayitlar migrate'e kadar yerinde +/// durur. onceki surumlerin sahip oldugu kayitlardir; migrate bunlari (ve +/// wizard'in guncel kayitlarini) veritabanindan kaldirir, ardindan seed dosyalarindaki guncel +/// satirlar eklenir. Boylece ad ya da kod degistiren bir duzenlemede eski kayit artik kalmaz, +/// metni degisen kayit yeni metniyle gelir. +/// +/// +public class WizardPendingDto +{ + /// Ilk bekleyen deploy zamani; migrate edilmeden yapilan duzenlemeler degistirmez. + public DateTime Since { get; set; } + + /// Onceki surumlerin sahip oldugu kayitlar. + public WizardInsertedRecordsDto Replaced { get; set; } = new(); + + /// Onceki surumlerin kurdugu ekran kodlari; kod degistiyse eski ekran kaldirilir. + public List ListFormCodes { get; set; } = []; +} + /// Kaydi kullanan (silinmesine engel olan) taraf. public class WizardRecordRefDto { @@ -456,6 +513,12 @@ public class WizardFileInfoDto public string CreatedAt { get; set; } public bool HasInsertedRecords { get; set; } + /// + /// Dosya veritabanina henuz uygulanmadi: deploy edildi ya da duzenlendi ama migrate calismadi, + /// veya ekran bu veritabaninda hic kurulmadi. Kurulum DB Migrate ile yapilir. + /// + public bool IsPending { get; set; } + /// Wizard'in List mi Custom Component mi urettigi; listede rozet olarak gosterilir. public WizardComponentKindEnum ComponentKind { get; set; } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedRowKeys.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedRowKeys.cs new file mode 100644 index 00000000..d6ff0cc1 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedRowKeys.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Bir wizard'in platform seed dosyalarindaki (menu, modul, izin grubu, yetki, dil metni) +/// satirlarinin anahtarlari. Anahtarlar yalnizca wizard cevaplarindan turer; deploy satirlari bu +/// anahtarlarla yazar, migrate admin rolunun yetkilerini ve export arsivin satirlarini ayni listeden +/// bulur. Kuralin ikinci bir kopyasi yazilmaz. +/// +public static class WizardSeedRowKeys +{ + /// Okuma yetkisinin altindaki alt yetkiler: (sonek, dil anahtari), siralari dizilimdir. + public static (string Suffix, string LangKey)[] ChildPermissions(ListFormWizardDto wizard) + { + (string, string)[] crud = + [ + ("Create", WizardConsts.LangKeyCreate), + ("Update", WizardConsts.LangKeyUpdate), + ("Delete", WizardConsts.LangKeyDelete), + ]; + + // Export/Import/Note yalnizca ListForm ekosisteminin sundugu islemlerdir. + return wizard.ComponentKind == WizardComponentKindEnum.Custom + ? crud + : [.. crud, ("Export", WizardConsts.LangKeyExport), ("Import", WizardConsts.LangKeyImport), ("Note", WizardConsts.LangKeyNote)]; + } + + /// Menuye eklenmeyen wizard'da izin grubu sorulmaz; bos geldiginde uygulama grubuna duser. + public static string PermissionGroupName(ListFormWizardDto wizard) => + string.IsNullOrWhiteSpace(wizard.PermissionGroupName) ? PlatformConsts.AppName : wizard.PermissionGroupName.Trim(); + + /// Okuma yetkisi ve alt yetkileri; ilk oge okuma yetkisidir. + public static List PermissionNames(ListFormWizardDto wizard) + { + var code = wizard.ResolveMenuCode(); + return [code, .. ChildPermissions(wizard).Select(p => $"{code}.{p.Suffix}")]; + } + + /// + /// Wizard'in dokundugu butun satirlarin anahtarlari: kendi kayitlari ve baglandigi kaplar (ust + /// menu, modul, izin grubu). Kaplar paylasilan kayitlardir; baska bir ortama tasinirken hedefte + /// olmayabilecekleri icin export'a girerler, ama sahipligi InsertedRecords belirler. + /// + public static WizardInsertedRecordsDto All(ListFormWizardDto wizard) + { + var code = wizard.ResolveMenuCode(); + var group = PermissionGroupName(wizard); + var isList = wizard.ComponentKind != WizardComponentKindEnum.Custom; + + var menus = new List { code }; + var languageKeys = new List { code, group }; + var modules = new List(); + + if (wizard.CreateMenu && !string.IsNullOrWhiteSpace(wizard.MenuParentCode)) + { + menus.Add(wizard.MenuParentCode.Trim()); + languageKeys.Add(wizard.MenuParentCode.Trim()); + } + + if (!string.IsNullOrWhiteSpace(wizard.MenuParentModuleId)) + modules.Add(wizard.MenuParentModuleId.Trim()); + + if (isList) + { + var listFormCode = wizard.ResolveListFormCode(); + languageKeys.Add($"{listFormCode}.Title"); + languageKeys.Add($"{listFormCode}.Desc"); + languageKeys.AddRange((wizard.Groups ?? []) + .SelectMany(g => g.Items ?? []) + .Select(i => i.CaptionName) + .Where(k => !string.IsNullOrWhiteSpace(k))); + } + + return new WizardInsertedRecordsDto + { + MenuCodes = wizard.CreateMenu ? menus : [], + ModuleIds = wizard.CreateMenu ? modules : [], + PermissionGroupNames = [group], + PermissionNames = PermissionNames(wizard), + LanguageKeys = [.. languageKeys.Distinct(StringComparer.OrdinalIgnoreCase)], + }; + } +} diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs index 33e001ba..165e8c79 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSeedDataSynchronizer.cs @@ -457,6 +457,6 @@ public class ListFormSeedDataSynchronizer( private static async Task WriteSeedFileAsync(string filePath, ListFormSeedDataFileDto seedFile) { seedFile.GeneratedAt = DateTime.UtcNow; - await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(seedFile, ListFormSeedDataFileDto.JsonOptions)); + await File.WriteAllTextAsync(filePath, ListFormSeedDataFileDto.Serialize(seedFile)); } } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index df8a7387..276f898c 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -18,9 +18,9 @@ using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.DynamicData; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; -using Sozsoft.Platform.Extensions; using Volo.Abp; using Volo.Abp.Content; +using Volo.Abp.Data; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.PermissionManagement; @@ -41,16 +41,13 @@ namespace Sozsoft.Platform.ListForms; [Authorize(AppCodes.Listforms.Wizard)] public class ListFormWizardAppService( IRepository repoListForm, - IRepository repoListFormField, IRepository repoDataSource, - IRepository repoListFormWorkflow, IRepository repoLangKey, IRepository repoLangText, IRepository repoPerm, IRepository repoPermGroup, IRepository repoMenu, IRepository repoModule, - IPermissionGrantRepository permissionGrantRepository, IConfiguration configuration, LanguageTextAppService languageTextAppService, IDynamicDataManager dynamicDataManager, @@ -58,20 +55,18 @@ public class ListFormWizardAppService( ICrudEndpointSeedApplier crudEndpointSeedApplier, WizardRecordCleaner recordCleaner, WizardTableCleaner tableCleaner, + WizardInstallationChecker installationChecker, ILogger logger ) : PlatformAppService(), IListFormWizardAppService { private readonly IRepository repoListForm = repoListForm; - private readonly IRepository repoListFormField = repoListFormField; private readonly IRepository repoDataSource = repoDataSource; - private readonly IRepository repoListFormWorkflow = repoListFormWorkflow; private readonly IRepository repoLangKey = repoLangKey; private readonly IRepository repoLangText = repoLangText; private readonly IRepository repoPerm = repoPerm; private readonly IRepository repoPermGroup = repoPermGroup; private readonly IRepository repoMenu = repoMenu; private readonly IRepository repoModule = repoModule; - private readonly IPermissionGrantRepository permissionGrantRepository = permissionGrantRepository; private readonly IConfiguration _configuration = configuration; private readonly LanguageTextAppService _languageTextAppService = languageTextAppService; private readonly IDynamicDataManager _dynamicDataManager = dynamicDataManager; @@ -79,19 +74,26 @@ public class ListFormWizardAppService( private readonly ICrudEndpointSeedApplier _crudEndpointSeedApplier = crudEndpointSeedApplier; private readonly WizardRecordCleaner _recordCleaner = recordCleaner; private readonly WizardTableCleaner _tableCleaner = tableCleaner; + private readonly WizardInstallationChecker _installationChecker = installationChecker; private readonly ILogger logger = logger; private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage; private static readonly JsonSerializerOptions SeedJsonReadOptions = new() { PropertyNameCaseInsensitive = true }; - [UnitOfWork] + /// + /// Wizard'i deploy eder: ekranin butun kayitlari seed dosyalarina yazilir, veritabanina hicbir + /// sey yazilmaz ve hicbir sey silinmez. Kurulumu bir sonraki DB Migrate yapar — wizard dosyasi + /// o zamana kadar bekleyen kurulum () tasir. + /// + /// Duzenleme de ayni yoldur: onceki surumun kurdugu kayitlar migrate'e kadar yerinde kalir, + /// yalnizca dosyalar yeni cevaplarla yeniden yazilir. Ayrimi istemci degil sunucu yapar; boylece + /// yetki dogru politikaya baglanir. + /// + /// public async Task Create(ListFormWizardDto input) { Check.NotNull(input, nameof(input)); - // Duzenleme, eski dosyanin (ve urettigi kayitlarin) silinip yeniden uretilmesidir. - // Ayrimi istemci degil sunucu yapar; boylece yetki dogru politikaya baglanir ve - // silme ile yeniden uretme arasinda yarim kalmis bir durum olusmaz. var editFileName = input.EditFileName?.Trim(); var isEdit = !string.IsNullOrWhiteSpace(editFileName); @@ -99,12 +101,7 @@ public class ListFormWizardAppService( ? AppCodes.Listforms.Wizards.Update : AppCodes.Listforms.Wizards.Create); - if (isEdit) - { - // Ilk deploy zamani wizard'in kimligidir; duzenleme onu degistirmez. - input.SeededAt = await ReadSeededAtAsync(editFileName!); - await DeleteFileInternalAsync(editFileName!, WizardDeleteMode.Redeploy); - } + var previous = isEdit ? await GetFile(editFileName!) : null; var wizardName = input.WizardName?.Trim(); if (string.IsNullOrWhiteSpace(wizardName)) @@ -116,140 +113,62 @@ public class ListFormWizardAppService( input.ListFormCode = listFormCode; input.MenuCode = code; - // Ad ve kod catismalari, kayitlar uretilmeye baslamadan once reddedilir; aksi halde - // yarim kalan bir deploy mevcut menu/permission kayitlarini bozabilir. - ValidateWizardName(wizardName); - await ValidateWizardUniquenessAsync(wizardName, code, input.MenuParentCode); + // Ilk deploy zamani wizard'in kimligidir; duzenleme onu degistirmez. + if (previous?.Wizard != null) + input.SeededAt = previous.Wizard.SeededAt; - await DeployWizardAsync(input, null); + var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot()); + + // Ad ve kod catismalari, dosyalar yazilmaya baslamadan once reddedilir; aksi halde yarim + // kalan bir deploy mevcut menu/permission satirlarini bozabilir. + ValidateWizardName(wizardName); + await ValidateWizardUniquenessAsync(wizardName, code, input.MenuParentCode, editFileName, previous, rows); + + await DeployWizardAsync(input, previous, editFileName, rows); } /// - /// Wizard cevaplarindan ekranin tum kayitlarini uretir: dil anahtarlari, izin grubu ve - /// izinler, admin rolunun izin atamalari, modul ve menu, veri kaynagi, ListForm, alanlar ve - /// is akisi — ardindan seed dosyasini yazar. + /// Wizard cevaplarindan ekranin butun satirlarini seed dosyalarina yazar: dil metinleri, izin + /// grubu ve yetkiler, modul ve menu (); ardindan ekranin ListForm + /// sozlesmesini tasiyan wizard dosyasini yazar. Veritabani yalnizca okunur — bir kaydin zaten var + /// olup olmadigi ve yeni kaydin sirasi oradan anlasilir. /// - /// yalnizca import yolunda doludur ve ekranin zaten uretilmis - /// sozlesmesini tasir. Bu durumda ListForm ve alanlar wizard cevaplarindan yeniden - /// turetilmez; tasarimci uzerinde yapilmis her degisiklik (kolon genisligi, editor secenegi, - /// gorunum bloklari) oldugu gibi kurulur. Yeniden turetmek bu inceligi sessizce silerdi. - /// - /// - /// Cagiran, ad ve kod catismalarini bu metottan once reddetmis olmalidir; burada var - /// olan bir ekranin uzerine yazilip yazilmadigi denetlenmez. + /// Sahiplik: bir satir, ne veritabaninda ne dosyada varsa ya da onceki surum ona sahipse bu + /// wizard'indir ve cevaplarla yeniden yazilir. Paylasilan (baskasina ait) bir satira dokunulmaz; + /// metni ilgili ekrandan degistirilir. Onceki surumun sahip olup bu surumun uretmedigi satirlar + /// dosyalardan cikarilir; veritabanindaki karsiliklarini migrate kaldirir. /// /// - private async Task DeployWizardAsync(ListFormWizardDto input, WizardSeedFileDto source) + private async Task DeployWizardAsync(ListFormWizardDto input, WizardSeedFileDto? previous, string? editFileName, WizardSeedRowStore rows) { var code = input.ResolveMenuCode(); var listFormCode = input.ResolveListFormCode(); - input.ListFormCode = listFormCode; - input.MenuCode = code; // Yol ayrımı: Custom seçildiğinde ListForm ekosistemi (veri kaynağı, ListForm, // alanlar, workflow) hiç üretilmez; menü doğrudan CustomComponent rotasına bağlanır. var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom; var customRoutePath = NormalizeCustomRoutePath(input, isCustomComponent); - var titleLangKey = $"{listFormCode}.Title"; - var nameLangKey = code; - var descLangKey = $"{listFormCode}.Desc"; - - // Eklenen kayıtları takip et (silme işleminde kullanılır) + var owned = previous?.InsertedRecords ?? new WizardInsertedRecordsDto(); var inserted = new WizardInsertedRecordsDto(); //Dil - Language Keys - await CreateLangKey(nameLangKey, input.MenuEn, input.MenuTr, inserted); + await WriteLanguageRowsAsync(rows, code, input.MenuEn, input.MenuTr, owned, inserted); // Title/Desc anahtarları yalnızca ListForm tarafından tüketilir; Custom yolunda // öksüz kayıt bırakmamak için üretilmez. if (!isCustomComponent) { - await CreateLangKey(titleLangKey, input.TitleEn, input.TitleTr, inserted); - await CreateLangKey(descLangKey, input.DescEn, input.DescTr, inserted); + await WriteLanguageRowsAsync(rows, $"{listFormCode}.Title", input.TitleEn, input.TitleTr, owned, inserted); + await WriteLanguageRowsAsync(rows, $"{listFormCode}.Desc", input.DescEn, input.DescTr, owned, inserted); } //Permission Group - // Menuye eklenmeyen wizard'da izin grubu sorulmaz; bos geldiginde varsayilan gruba dusar. - var groupName = string.IsNullOrWhiteSpace(input.PermissionGroupName) - ? PlatformConsts.AppName - : input.PermissionGroupName.Trim(); + var groupName = WizardSeedRowKeys.PermissionGroupName(input); input.PermissionGroupName = groupName; - await ApplyPermissionGroupAsync(input, groupName, inserted); + await WritePermissionGroupAsync(rows, input, groupName, owned, inserted); - // Permission'ları tek seferde kontrol et ve oluştur - var existingPerms = await repoPerm.GetListAsync(a => a.GroupName == groupName); - - // Yetkiler menuyle ayni modul filtresine tabidir; modul bos kalirsa rol ekraninda - // modul secimiyle filtrelenen listede gorunmez. - var permissionModules = input.MenuParentModuleId?.Trim(); - - var permRead = existingPerms.FirstOrDefault(a => a.Name == code); - if (permRead == null) - { - var permReadRecord = new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both); - permReadRecord.SetModules(permissionModules); - permReadRecord.SetPermissionOrder(existingPerms.Where(p => string.IsNullOrWhiteSpace(p.ParentName)).NextPermissionOrder()); - - permRead = await repoPerm.InsertAsync(permReadRecord, autoSave: false); - inserted.PermissionNames.Add(permRead.Name); - } - - // Read altındaki alt yetkiler: (suffix, dil anahtarı). CRUD üçlüsü her iki - // yolda da üretilir; Custom component de Form üzerinden kayıt - // ekler, günceller ve siler, ve tasarımcı butonları bu yetkilere bağlar. - var crudPermissions = new[] - { - ("Create", WizardConsts.LangKeyCreate), - ("Update", WizardConsts.LangKeyUpdate), - ("Delete", WizardConsts.LangKeyDelete), - }; - - // Export/Import/Note yalnızca ListForm ekosisteminin sunduğu işlemlerdir; - // Custom yolunda karşılığı olmadığı için üretilmez. - var listOnlyPermissions = new[] - { - ("Export", WizardConsts.LangKeyExport), - ("Import", WizardConsts.LangKeyImport), - ("Note", WizardConsts.LangKeyNote), - }; - - var childPermissions = isCustomComponent - ? crudPermissions - : [.. crudPermissions, .. listOnlyPermissions]; - - var permissionNames = new List { permRead.Name }; - // Alt yetkilerin sirasi kardesler arasindadir ve listedeki dizilimi izler (Create = 1, ...). - var childOrder = 0; - foreach (var (suffix, langKey) in childPermissions) - { - var permissionName = $"{code}.{suffix}"; - permissionNames.Add(permissionName); - childOrder++; - - if (existingPerms.Any(a => a.Name == permissionName)) - continue; - - var childPermission = new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both); - childPermission.SetModules(permissionModules); - childPermission.SetPermissionOrder(childOrder); - - await repoPerm.InsertAsync(childPermission, autoSave: false); - inserted.PermissionNames.Add(permissionName); - } - - // Permission Grants - Bulk Insert (only missing ones) - var existingGrants = await permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName); - var existingGrantNames = existingGrants.Select(g => g.Name).ToHashSet(); - - var grantsToInsert = permissionNames - .Where(name => !existingGrantNames.Contains(name)) - .Select(name => new PermissionGrant(GuidGenerator.Create(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName)) - .ToList(); - - if (grantsToInsert.Count > 0) - { - await permissionGrantRepository.InsertManyAsync(grantsToInsert, autoSave: false); - } + //Permissions + await WritePermissionRowsAsync(rows, input, groupName, owned, inserted); // Menunun acacagi adres: Custom yolunda CustomComponent'in kendi rotasi (bu rota // mapCustomComponentRoutes tarafindan zaten kayitlidir), aksi halde ListForm adresi. @@ -257,266 +176,404 @@ public class ListFormWizardAppService( // ListForm'un adresi degismedigi icin ayni sekilde hesaplanir. input.MenuUrl = isCustomComponent ? customRoutePath : WizardConsts.MenuUrl(code); - // Menusuz wizard: menu (ve ust menu) kaydi hic uretilmez, geri kalan her sey aynidir. + // Menusuz wizard: menu (ve ust menu) satiri hic uretilmez, geri kalan her sey aynidir. if (input.CreateMenu) - { - //Menu Parent - await EnsureModuleAsync(input.MenuParentModuleId, inserted); + await WriteMenuRowsAsync(rows, input, code, owned, inserted); - var menuQueryable = await repoMenu.GetQueryableAsync(); - var menuParent = await AsyncExecuter.FirstOrDefaultAsync(menuQueryable.Where(a => a.Code == input.MenuParentCode)); - if (menuParent == null) - { - var maxRootOrder = menuQueryable.Where(a => a.ParentCode == null || a.ParentCode == "").Select(a => (int?)a.Order).Max() ?? 0; - var menuParentIcon = !string.IsNullOrWhiteSpace(input.MenuParentIcon) - ? input.MenuParentIcon - : !string.IsNullOrWhiteSpace(input.MenuIcon) - ? input.MenuIcon - : WizardConsts.MenuIcon; - await CreateLangKey(input.MenuParentCode, input.MenuParentEn, input.MenuParentTr, inserted); - menuParent = await repoMenu.InsertAsync(new Menu - { - Code = input.MenuParentCode, - DisplayName = input.MenuParentCode, - IsDisabled = false, - ModuleId = input.MenuParentModuleId?.Trim(), - Icon = menuParentIcon, - Order = maxRootOrder + 1, - }, autoSave: false); - inserted.MenuCodes.Add(input.MenuParentCode); - } - else if (string.IsNullOrWhiteSpace(menuParent.ModuleId) && - !string.IsNullOrWhiteSpace(input.MenuParentModuleId)) - { - menuParent.ModuleId = input.MenuParentModuleId.Trim(); - await repoMenu.UpdateAsync(menuParent, autoSave: false); - } - - //Menu - var maxChildOrder = menuQueryable.Where(a => a.ParentCode == menuParent.Code).Select(a => (int?)a.Order).Max() ?? 0; - var menuOrder = input.MenuOrder > 0 ? input.MenuOrder : maxChildOrder + 1; - input.MenuOrder = menuOrder; - var menuUrl = input.MenuUrl; - var existingMenu = await AsyncExecuter.FirstOrDefaultAsync(menuQueryable.Where(a => a.Code == code)); - if (existingMenu == null) - { - await repoMenu.InsertAsync(new Menu - { - Code = code, - DisplayName = nameLangKey, - IsDisabled = false, - ParentCode = menuParent.Code, - Icon = input.MenuIcon ?? WizardConsts.MenuIcon, - Target = null, - ElementId = null, - CssClass = null, - Url = menuUrl, - RequiredPermissionName = permRead.Name, - Order = menuOrder, - ModuleId = menuParent.ModuleId, - }, autoSave: false); - inserted.MenuCodes.Add(code); - } - else if (existingMenu.Order != menuOrder || - existingMenu.Url != menuUrl || - string.IsNullOrWhiteSpace(existingMenu.ModuleId)) - { - existingMenu.Order = menuOrder; - existingMenu.Url = menuUrl; - existingMenu.ModuleId ??= menuParent.ModuleId; - await repoMenu.UpdateAsync(existingMenu, autoSave: false); - } - } + GridOptionsEditDto? listFormDto = null; + List fieldDtos = []; + var isDeleted = false; + var isCreated = false; // Custom yolunda ListForm ekosistemi üretilmez: veri kaynağı, ListForm, alanlar, // subform/widget/workflow kayıtlarının hiçbirine ihtiyaç yoktur. - if (isCustomComponent) + if (!isCustomComponent) { - await _languageTextAppService.ClearRedisCacheAsync(); - await SaveWizardSeedFileAsync(input, null, [], false, false, inserted); - return; - } - - //DataSource kodu ile iligli kod blogu - var dataSourceQueryable = await repoDataSource.GetQueryableAsync(); - var dataSource = await AsyncExecuter.FirstOrDefaultAsync(dataSourceQueryable.Where(a => a.Code == input.DataSourceCode)); - if (dataSource is null) - { - if (string.IsNullOrWhiteSpace(input.DataSourceConnectionString)) - throw new UserFriendlyException($"'{input.DataSourceCode}' veri kaynağı bulunamadı. Yeni veri kaynağı için bağlantı cümlesi zorunludur."); - - await repoDataSource.InsertAsync(new DataSource + // Yeni veri kaynagini migrate, wizard dosyasindaki baglanti cumlesinden kurar. + if (!await repoDataSource.AnyAsync(a => a.Code == input.DataSourceCode)) { - Code = input.DataSourceCode, - DataSourceType = input.DataSourceConnectionString.Contains("Server", StringComparison.OrdinalIgnoreCase) - ? DataSourceTypeEnum.Mssql - : DataSourceTypeEnum.Postgresql, - ConnectionString = input.DataSourceConnectionString - }, autoSave: false); - inserted.DataSourceCodes.Add(input.DataSourceCode); - } + if (string.IsNullOrWhiteSpace(input.DataSourceConnectionString)) + throw new UserFriendlyException($"'{input.DataSourceCode}' veri kaynağı bulunamadı. Yeni veri kaynağı için bağlantı cümlesi zorunludur."); - //ListForm ve alanlari - varsa sil, yeniden uret - var existingListForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == input.ListFormCode); - if (existingListForm != null) - { - await repoListForm.DeleteAsync(existingListForm, autoSave: true); - } + inserted.DataSourceCodes.Add(input.DataSourceCode); + } + else if (owned.DataSourceCodes.Contains(input.DataSourceCode, StringComparer.OrdinalIgnoreCase)) + { + // Onceki surumun tanimlayip migrate'in kurdugu veri kaynagi sahipligini korur. + inserted.DataSourceCodes.Add(input.DataSourceCode); + } - var existingListFormFields = await repoListFormField.GetListAsync(a => a.ListFormCode == input.ListFormCode); - if (existingListFormFields.Count > 0) - { - await repoListFormField.DeleteManyAsync(existingListFormFields, autoSave: true); - } - - var existingWorkflowCriteria = await repoListFormWorkflow.GetListAsync(a => a.ListFormCode == input.ListFormCode); - if (existingWorkflowCriteria.Count > 0) - { - await repoListFormWorkflow.DeleteManyAsync(existingWorkflowCriteria, autoSave: true); - } - - // Import yolunda ekranin sozlesmesi dosyada hazirdir ve oldugu gibi kurulur; wizard - // yolunda tek fabrikadan uretilir, boylece seed dosyasina yazilan da veritabanina yazilan - // da ayni nesne olur. Tablo kolonlari yalnizca uretim gerektiginde sorgulanir. - bool isDeleted; - bool isCreated; - GridOptionsEditDto listFormDto; - List fieldDtos; - - if (source?.ListForm != null) - { - isDeleted = source.IsDeletedField; - isCreated = source.IsCreatedField; - listFormDto = source.ListForm; - fieldDtos = source.Fields ?? []; - } - else - { + // Ekranin sozlesmesi tek fabrikadan uretilir; migrate ekrani bu dosyadan kurar. var tableColumns = await GetTableColumnNamesAsync(input.DataSourceCode, input.SelectCommandType, input.SelectCommand); isDeleted = tableColumns.Contains("IsDeleted"); isCreated = tableColumns.Contains("CreatorId"); listFormDto = input.BuildListForm(isDeleted, isCreated); fieldDtos = input.BuildFields(); + + foreach (var item in input.Groups.SelectMany(g => g.Items)) + await WriteLanguageRowsAsync(rows, item.CaptionName, item.EnglishCaption, item.TurkishCaption, owned, inserted); } - var workflow = listFormDto.WorkflowDto ?? input.WorkflowDto; + RemoveReleasedRows(rows, owned, inserted); + await rows.SaveAsync(); - var listForm = new ListForm(); - listFormDto.ApplyTo(listForm); - await repoListForm.InsertAsync(listForm, autoSave: true); + var pending = BuildPending(previous, listFormCode); + await SaveWizardSeedFileAsync(input, listFormDto, fieldDtos, isDeleted, isCreated, inserted, pending); - foreach (var fieldDto in fieldDtos) + // Ad degisen duzenlemede eski dosya kalmaz; migrate onun kayitlarini yeni dosyanin + // bekleyen kurulumundan (Replaced) bilir. + if (!string.IsNullOrWhiteSpace(editFileName) && + !string.Equals(editFileName, ResolveWizardSeedFileName(input.WizardName), StringComparison.OrdinalIgnoreCase)) { - var field = new ListFormField(); - fieldDto.ApplyTo(field); - await repoListFormField.InsertAsync(field, autoSave: true); + File.Delete(ResolveWizardSeedFilePath(editFileName)); } - - foreach (var item in input.Groups.SelectMany(g => g.Items)) - { - await CreateLangKey(item.CaptionName, item.EnglishCaption, item.TurkishCaption, inserted); - } - - foreach (var criteria in workflow.Criteria ?? []) - { - if (string.IsNullOrWhiteSpace(criteria.Id)) - { - continue; - } - - await repoListFormWorkflow.InsertAsync(new ListFormWorkflow(criteria.Id) - { - ListFormCode = input.ListFormCode, - Kind = criteria.Kind, - Title = criteria.Title, - CompareColumn = criteria.CompareColumn, - CompareOperator = criteria.CompareOperator, - CompareValue = criteria.CompareValue, - Approver = criteria.Approver, - NextOnStart = criteria.NextOnStart, - NextOnTrue = criteria.NextOnTrue, - NextOnFalse = criteria.NextOnFalse, - NextOnApprove = criteria.NextOnApprove, - NextOnReject = criteria.NextOnReject, - PositionX = criteria.PositionX, - PositionY = criteria.PositionY, - CompareOutcomesJson = JsonSerializer.Serialize(criteria.CompareOutcomes ?? []), - }, autoSave: true); - } - - // Clear Redis Cache - await _languageTextAppService.ClearRedisCacheAsync(); - - // Wizard konfigürasyonunu seed dosyasına kaydet - await SaveWizardSeedFileAsync(input, listFormDto, fieldDtos, isDeleted, isCreated, inserted); } + /// + /// Bekleyen kurulum: onceki surumun sahip oldugu kayitlar ve kurdugu ekran migrate'te yenilenmek + /// uzere eklenir. Migrate edilmeden ust uste yapilan duzenlemeler ayni listeyi buyutur; ilk + /// bekleyen deploy zamani korunur. + /// + private WizardPendingDto BuildPending(WizardSeedFileDto? previous, string listFormCode) + { + var pending = previous?.Pending ?? new WizardPendingDto { Since = Clock.Now }; + + if (previous != null) + { + pending.Replaced = (pending.Replaced ?? new WizardInsertedRecordsDto()).Union(previous.InsertedRecords); + + var previousListFormCode = previous.Wizard?.ComponentKind == WizardComponentKindEnum.Custom + ? null + : previous.Wizard?.ResolveListFormCode(); + + if (!string.IsNullOrWhiteSpace(previousListFormCode) && + !string.Equals(previousListFormCode, listFormCode, StringComparison.OrdinalIgnoreCase) && + !pending.ListFormCodes.Contains(previousListFormCode, StringComparer.OrdinalIgnoreCase)) + { + pending.ListFormCodes.Add(previousListFormCode); + } + } + + return pending; + } + + /// + /// Onceki surumun sahip olup bu surumun artik uretmedigi satirlari dosyalardan cikarir; + /// veritabanindaki karsiliklarini migrate kaldirir. + /// + private static void RemoveReleasedRows(WizardSeedRowStore rows, WizardInsertedRecordsDto owned, WizardInsertedRecordsDto current) + { + static List Released(List before, List now) => + [.. before.Where(x => !now.Contains(x, StringComparer.OrdinalIgnoreCase))]; + + rows.RemoveOwned(new WizardInsertedRecordsDto + { + MenuCodes = Released(owned.MenuCodes, current.MenuCodes), + PermissionNames = Released(owned.PermissionNames, current.PermissionNames), + PermissionGroupNames = Released(owned.PermissionGroupNames, current.PermissionGroupNames), + ModuleIds = Released(owned.ModuleIds, current.ModuleIds), + LanguageKeys = Released(owned.LanguageKeys, current.LanguageKeys), + }); + } + + /// + /// Bir dil anahtarinin iki dilli metin satirlarini yazar. Anahtar wizard'a aitse (yeni ya da + /// onceki surumun) metinler cevaplarla yazilir; baska bir kayda ait var olan anahtara dokunulmaz. + /// Satirin kimligi is anahtarindan turer (). + /// + private async Task WriteLanguageRowsAsync( + WizardSeedRowStore rows, + string? key, + string? textEn, + string? textTr, + WizardInsertedRecordsDto owned, + WizardInsertedRecordsDto inserted) + { + if (string.IsNullOrWhiteSpace(key)) + return; + + var res = PlatformConsts.AppName; + var isOwned = owned.LanguageKeys.Contains(key, StringComparer.OrdinalIgnoreCase) + || inserted.LanguageKeys.Contains(key, StringComparer.OrdinalIgnoreCase); + + if (!isOwned && + (rows.Contains(WizardSeedRowStore.LanguageTextFile, nameof(LanguageText.Key), key) || + await repoLangKey.AnyAsync(a => a.ResourceName == res && a.Key == key))) + { + return; + } + + foreach (var (culture, value) in new[] { (cultureNameDefault, textEn), (LanguageCodes.Tr, textTr) }) + { + rows.Upsert(WizardSeedRowStore.LanguageTextFile, new Dictionary + { + ["Id"] = SeedRowIds.LanguageText(res, key, culture), + [nameof(LanguageText.CultureName)] = culture, + [nameof(LanguageText.ResourceName)] = res, + [nameof(LanguageText.Key)] = key, + [nameof(LanguageText.Value)] = string.IsNullOrWhiteSpace(value) ? key : value, + }, nameof(LanguageText.CultureName), nameof(LanguageText.ResourceName), nameof(LanguageText.Key)); + } + + if (!inserted.LanguageKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + inserted.LanguageKeys.Add(key); + } + + /// + /// Izin grubunu ve iki dilli gorunen adini hazirlar. + /// + /// Grup yeniyse (ya da onceki surumun ise) satiri ve grup adiyla ayni olan dil anahtarini yazar; + /// gorunen ad girilmediyse grup adina duser. Grup baskasina aitse dokunulmaz, gorunen adi var + /// olan metinden okunur. Her durumda uzerindeki gorunen adlar + /// doldurulur; seed dosyasi mevcut bir grup secildiginde de iki dilli gorunen adi tasimalidir. + /// + /// + private async Task WritePermissionGroupAsync( + WizardSeedRowStore rows, + ListFormWizardDto input, + string groupName, + WizardInsertedRecordsDto owned, + WizardInsertedRecordsDto inserted) + { + var displayEn = input.PermissionGroupEn?.Trim(); + var displayTr = input.PermissionGroupTr?.Trim(); + + var isOwned = owned.PermissionGroupNames.Contains(groupName, StringComparer.OrdinalIgnoreCase); + var fileRow = rows.Find(WizardSeedRowStore.PermissionGroupFile, "Name", groupName); + var exists = fileRow != null || await repoPermGroup.AnyAsync(a => a.Name == groupName); + + if (isOwned || !exists) + { + var order = fileRow != null + ? WizardSeedRowStore.Int(fileRow, PlatformConsts.Permissions.Order) + : (await repoPermGroup.GetListAsync()).Select(g => g.GetProperty(PlatformConsts.Permissions.Order)) + .Concat(rows.Rows(WizardSeedRowStore.PermissionGroupFile).Select(r => WizardSeedRowStore.Int(r, PlatformConsts.Permissions.Order))) + .DefaultIfEmpty(0).Max() + 1; + + rows.Upsert(WizardSeedRowStore.PermissionGroupFile, new Dictionary + { + ["Name"] = groupName, + ["DisplayName"] = groupName, + [PlatformConsts.Permissions.Order] = order, + }, "Name"); + inserted.PermissionGroupNames.Add(groupName); + + // Grup adi ust menu koduyla ayniysa anahtar menu adiyla ortaktir; gorunen ad girilmediyse + // menu metinlerine duser. + if (string.Equals(groupName, input.MenuParentCode, StringComparison.OrdinalIgnoreCase)) + { + displayEn = string.IsNullOrWhiteSpace(displayEn) ? input.MenuParentEn?.Trim() : displayEn; + displayTr = string.IsNullOrWhiteSpace(displayTr) ? input.MenuParentTr?.Trim() : displayTr; + } + + displayEn = string.IsNullOrWhiteSpace(displayEn) ? groupName : displayEn; + displayTr = string.IsNullOrWhiteSpace(displayTr) ? groupName : displayTr; + await WriteLanguageRowsAsync(rows, groupName, displayEn, displayTr, owned, inserted); + } + else + { + var (existingEn, existingTr) = await GetLangTextsAsync(rows, groupName); + displayEn = string.IsNullOrWhiteSpace(displayEn) ? (existingEn ?? groupName) : displayEn; + displayTr = string.IsNullOrWhiteSpace(displayTr) ? (existingTr ?? groupName) : displayTr; + } + + input.PermissionGroupEn = displayEn; + input.PermissionGroupTr = displayTr; + } + + /// + /// Okuma yetkisi ve alt yetkileri. Alt yetkilerin sirasi kardesler arasindadir ve listedeki + /// dizilimi izler (Create = 1, ...); okuma yetkisi grubun kok yetkilerinin sonuna eklenir. + /// Yetkiler menuyle ayni modul filtresine tabidir; modul bos kalirsa rol ekraninda modul + /// secimiyle filtrelenen listede gorunmez. + /// + private async Task WritePermissionRowsAsync( + WizardSeedRowStore rows, + ListFormWizardDto input, + string groupName, + WizardInsertedRecordsDto owned, + WizardInsertedRecordsDto inserted) + { + var code = input.ResolveMenuCode(); + var modules = input.MenuParentModuleId?.Trim(); + var dbPermissions = await repoPerm.GetListAsync(a => a.GroupName == groupName); + + bool IsOwnable(string name) => + owned.PermissionNames.Contains(name, StringComparer.OrdinalIgnoreCase) || + (!rows.Contains(WizardSeedRowStore.PermissionFile, "Name", name) && !dbPermissions.Any(p => p.Name == name)); + + void Write(string name, string? parentName, string displayName, int order) + { + rows.Upsert(WizardSeedRowStore.PermissionFile, new Dictionary + { + ["GroupName"] = groupName, + ["Name"] = name, + ["ParentName"] = parentName!, + ["DisplayName"] = displayName, + [PlatformConsts.Permissions.Order] = order, + ["IsEnabled"] = true, + ["MultiTenancySide"] = (int)MultiTenancySides.Both, + [PlatformConsts.Permissions.Modules] = string.IsNullOrWhiteSpace(modules) ? null! : modules, + }, "Name"); + inserted.PermissionNames.Add(name); + } + + if (IsOwnable(code)) + { + var existingRow = rows.Find(WizardSeedRowStore.PermissionFile, "Name", code); + var order = existingRow != null + ? WizardSeedRowStore.Int(existingRow, PlatformConsts.Permissions.Order) + : dbPermissions.Where(p => string.IsNullOrWhiteSpace(p.ParentName)).Select(p => p.GetProperty(PlatformConsts.Permissions.Order)) + .Concat(rows.Rows(WizardSeedRowStore.PermissionFile) + .Where(r => string.Equals(WizardSeedRowStore.Text(r, "GroupName"), groupName, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(WizardSeedRowStore.Text(r, "ParentName"))) + .Select(r => WizardSeedRowStore.Int(r, PlatformConsts.Permissions.Order))) + .DefaultIfEmpty(0).Max() + 1; + + Write(code, null, code, order); + } + + var childOrder = 0; + foreach (var (suffix, langKey) in WizardSeedRowKeys.ChildPermissions(input)) + { + var name = $"{code}.{suffix}"; + childOrder++; + + if (IsOwnable(name)) + Write(name, code, langKey, childOrder); + } + } + + /// + /// Modul, ust menu ve ekranin menusu. Menu agaci veritabani ile dosyanin birlesimidir: migrate + /// edilmemis bir wizard'in menusu yalnizca dosyada durur ama sirada yer tutar. Ust menu yoksa kok + /// menulerin sonuna eklenir; ekranin menusu verilen sirayla, sira verilmediyse kardeslerinin + /// sonuna yazilir. + /// + private async Task WriteMenuRowsAsync( + WizardSeedRowStore rows, + ListFormWizardDto input, + string code, + WizardInsertedRecordsDto owned, + WizardInsertedRecordsDto inserted) + { + static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + var moduleId = input.MenuParentModuleId?.Trim(); + if (!string.IsNullOrWhiteSpace(moduleId) && + (owned.ModuleIds.Contains(moduleId, StringComparer.OrdinalIgnoreCase) || + (!rows.Contains(WizardSeedRowStore.ModuleFile, "Id", moduleId) && !await repoModule.AnyAsync(a => a.Id == moduleId)))) + { + rows.Upsert(WizardSeedRowStore.ModuleFile, new Dictionary { ["Id"] = moduleId, ["Name"] = moduleId }, "Id"); + inserted.ModuleIds.Add(moduleId); + } + + var dbMenus = await repoMenu.GetListAsync(); + var fileMenus = rows.Rows(WizardSeedRowStore.MenuFile); + + var parentCode = input.MenuParentCode?.Trim(); + var parentOwned = owned.MenuCodes.Contains(parentCode ?? string.Empty, StringComparer.OrdinalIgnoreCase); + var parentRow = rows.Find(WizardSeedRowStore.MenuFile, nameof(Menu.Code), parentCode); + var dbParent = dbMenus.FirstOrDefault(m => Same(m.Code, parentCode)); + + string? parentModuleId; + if (parentOwned || (parentRow == null && dbParent == null)) + { + var maxRootOrder = dbMenus.Where(m => string.IsNullOrEmpty(m.ParentCode)).Select(m => m.Order) + .Concat(fileMenus.Where(r => string.IsNullOrEmpty(WizardSeedRowStore.Text(r, nameof(Menu.ParentCode)))) + .Select(r => WizardSeedRowStore.Int(r, nameof(Menu.Order)))) + .DefaultIfEmpty(0).Max(); + + var menuParentIcon = !string.IsNullOrWhiteSpace(input.MenuParentIcon) + ? input.MenuParentIcon + : !string.IsNullOrWhiteSpace(input.MenuIcon) + ? input.MenuIcon + : WizardConsts.MenuIcon; + + parentModuleId = string.IsNullOrWhiteSpace(moduleId) ? null : moduleId; + rows.Upsert(WizardSeedRowStore.MenuFile, MenuRow( + parentCode!, null, parentCode!, + parentRow != null ? WizardSeedRowStore.Int(parentRow, nameof(Menu.Order)) : maxRootOrder + 1, + null, menuParentIcon, null, parentModuleId), nameof(Menu.Code)); + inserted.MenuCodes.Add(parentCode!); + + await WriteLanguageRowsAsync(rows, parentCode, input.MenuParentEn, input.MenuParentTr, owned, inserted); + } + else + { + parentModuleId = dbParent?.ModuleId ?? WizardSeedRowStore.Text(parentRow, nameof(Menu.ModuleId)); + } + + var maxChildOrder = dbMenus.Where(m => Same(m.ParentCode, parentCode) && !Same(m.Code, code)).Select(m => m.Order) + .Concat(fileMenus + .Where(r => Same(WizardSeedRowStore.Text(r, nameof(Menu.ParentCode)), parentCode) && + !Same(WizardSeedRowStore.Text(r, nameof(Menu.Code)), code)) + .Select(r => WizardSeedRowStore.Int(r, nameof(Menu.Order)))) + .DefaultIfEmpty(0).Max(); + + input.MenuOrder = input.MenuOrder > 0 ? input.MenuOrder : maxChildOrder + 1; + + rows.Upsert(WizardSeedRowStore.MenuFile, MenuRow( + code, parentCode, code, input.MenuOrder, input.MenuUrl, input.MenuIcon ?? WizardConsts.MenuIcon, + code, string.IsNullOrWhiteSpace(parentModuleId) ? null : parentModuleId), nameof(Menu.Code)); + inserted.MenuCodes.Add(code); + } + + private static Dictionary MenuRow( + string code, string? parentCode, string displayName, int order, string? url, string? icon, string? requiredPermissionName, string? moduleId) => new() + { + [nameof(Menu.ParentCode)] = parentCode!, + [nameof(Menu.Code)] = code, + [nameof(Menu.DisplayName)] = displayName, + [nameof(Menu.Order)] = order, + [nameof(Menu.Url)] = url!, + [nameof(Menu.Icon)] = icon!, + [nameof(Menu.RequiredPermissionName)] = requiredPermissionName!, + [nameof(Menu.IsDisabled)] = false, + [nameof(Menu.ModuleId)] = moduleId!, + }; + /// /// Wizard konfigürasyonunu JSON dosyası olarak kaydeder. - /// Dosya, seeder'lar ve File Manager ile ortak olan seed klasörünün wizard dizinine yazılır. - /// Veritabanı silinip yeniden oluşturulduğunda WizardDataSeeder bu dosyaları okuyarak konfigürasyonu geri yükler. + /// Dosya, seeder'lar ve File Manager ile ortak olan seed klasörünün wizard dizinine yazılır; + /// ekrani WizardDataSeeder bu dosyadan kurar. /// /// Dosya yalnizca wizard'in cevaplarini degil, urettigi ekranin ListForm sozlesmesini de tasir; /// tasarimci uzerinde yapilan degisiklikler ayni bolumlere geri yazildigi icin dosya her zaman - /// ekranin guncel halidir. + /// ekranin guncel halidir. Deploy'un tek ciktisi dosyalar oldugu icin yazma hatasi yutulmaz. /// /// private async Task SaveWizardSeedFileAsync( ListFormWizardDto input, - GridOptionsEditDto listForm, + GridOptionsEditDto? listForm, List fields, bool isDeletedField, bool isCreatedField, - WizardInsertedRecordsDto inserted) + WizardInsertedRecordsDto inserted, + WizardPendingDto pending) { - try + var outputPath = ResolveWizardSeedOutputPath(); + Directory.CreateDirectory(outputPath); + + // Duzenlemede ilk deploy zamani korunur; seeder dosyalari bu damgaya gore siralar. + if (input.SeededAt == default) + input.SeededAt = Clock.Now; + + var seedData = new WizardSeedFileDto { - var outputPath = ResolveWizardSeedOutputPath(); - Directory.CreateDirectory(outputPath); + Wizard = input, + ListForm = listForm!, + Fields = fields ?? [], + IsDeletedField = isDeletedField, + IsCreatedField = isCreatedField, + InsertedRecords = inserted, + Pending = pending, + }; - // Duzenlemede ilk deploy zamani korunur; seeder dosyalari bu damgaya gore siralar. - if (input.SeededAt == default) - input.SeededAt = Clock.Now; + var filePath = Path.Combine(outputPath, ResolveWizardSeedFileName(input.WizardName)); + await File.WriteAllTextAsync(filePath, seedData.ToSeedJson()); - var seedData = new WizardSeedFileDto - { - Wizard = input, - ListForm = listForm, - Fields = fields ?? [], - IsDeletedField = isDeletedField, - IsCreatedField = isCreatedField, - InsertedRecords = inserted - }; - - var filePath = Path.Combine(outputPath, ResolveWizardSeedFileName(input.WizardName)); - await File.WriteAllTextAsync(filePath, seedData.ToSeedJson()); - - logger.LogInformation("Seed file saved: {FilePath}", filePath); - } - catch (Exception ex) - { - // Dosya kaydetme hatası wizard işlemini engellemez - logger.LogError(ex, "Seed file could not be saved: {Message}", ex.Message); - } - } - - /// - /// Duzenlenen dosyanin ilk deploy zamanini okur; dosya okunamazsa varsayilan deger doner ve - /// kayit sirasinda yeni damga atilir. - /// - private async Task ReadSeededAtAsync(string fileName) - { - try - { - var json = await File.ReadAllTextAsync(ResolveWizardSeedFilePath(fileName)); - var seed = WizardSeedFileDto.FromSeedJson(json); - return seed?.Wizard?.SeededAt ?? default; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Wizard seed file could not be read for SeededAt: {FileName}", fileName); - return default; - } + logger.LogInformation("Wizard seed file saved, waiting for DB Migrate: {FilePath}", filePath); } /// @@ -596,48 +653,44 @@ public class ListFormWizardAppService( /// public async Task> GetFiles() { - var outputPath = ResolveWizardSeedOutputPath(); + var files = await ReadSeedFilesAsync(); var result = new List(); - - if (!Directory.Exists(outputPath)) + if (files.Count == 0) return result; - var seeds = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seeds = files + .Where(f => f.Seed?.Wizard != null) + .ToDictionary(f => f.FileName, f => f.Seed!, StringComparer.OrdinalIgnoreCase); + var menus = (await repoMenu.GetListAsync()) .Where(m => !string.IsNullOrWhiteSpace(m.Code)) .GroupBy(m => m.Code, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); - foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName)) - { - var fileName = Path.GetFileName(file); - try - { - var json = await File.ReadAllTextAsync(file); - var seed = WizardSeedFileDto.FromSeedJson(json); - if (seed?.Wizard != null) - seeds[fileName] = seed; + // Kurulum durumu dosya basina sorulmaz; butun ekran/menu kodlari iki sorguda okunur. + var wizards = seeds.Values.Select(s => s.Wizard).ToList(); + var installed = await _installationChecker.GetInstalledAsync( + [.. wizards.Where(w => w.ComponentKind != WizardComponentKindEnum.Custom).Select(w => w.ResolveListFormCode())], + [.. wizards.Where(w => w.ComponentKind == WizardComponentKindEnum.Custom).Select(w => w.ResolveMenuCode())]); - result.Add(new WizardFileInfoDto - { - FileName = fileName, - WizardName = seed?.Wizard?.WizardName ?? fileName, - DefaultLayout = seed?.Wizard?.DefaultLayout ?? string.Empty, - MenuIcon = seed?.Wizard?.MenuIcon ?? string.Empty, - ListFormCode = seed?.Wizard?.ListFormCode ?? string.Empty, - ComponentKind = seed?.Wizard?.ComponentKind ?? WizardComponentKindEnum.List, - CustomComponentName = seed?.Wizard?.CustomComponentName ?? string.Empty, - MenuUrl = seed?.Wizard?.MenuUrl ?? string.Empty, - CreatedAt = seed?.Wizard?.SeededAt.ToString("s") ?? string.Empty, - MenuPath = seed?.Wizard != null ? ResolveMenuPath(seed.Wizard, menus) : [], - HasInsertedRecords = seed?.InsertedRecords?.HasAny == true - }); - } - catch (Exception ex) + foreach (var (fileName, seed) in files) + { + var wizard = seed?.Wizard; + result.Add(new WizardFileInfoDto { - logger.LogWarning(ex, "Wizard seed file could not be parsed: {FileName}", fileName); - result.Add(new WizardFileInfoDto { FileName = fileName }); - } + FileName = fileName, + WizardName = wizard?.WizardName ?? fileName, + DefaultLayout = wizard?.DefaultLayout ?? string.Empty, + MenuIcon = wizard?.MenuIcon ?? string.Empty, + ListFormCode = wizard?.ListFormCode ?? string.Empty, + ComponentKind = wizard?.ComponentKind ?? WizardComponentKindEnum.List, + CustomComponentName = wizard?.CustomComponentName ?? string.Empty, + MenuUrl = wizard?.MenuUrl ?? string.Empty, + CreatedAt = wizard?.SeededAt.ToString("s") ?? string.Empty, + MenuPath = wizard != null ? ResolveMenuPath(wizard, menus) : [], + HasInsertedRecords = seed?.InsertedRecords?.HasAny == true, + IsPending = wizard != null && (seed!.Pending != null || !installed.Contains(InstallationKey(wizard))), + }); } await FillWizardDependenciesAsync(result, seeds); @@ -648,6 +701,44 @@ public class ListFormWizardAppService( .ThenBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)]; } + /// + /// Kapsamdaki wizard dosyalarini okur. Okunamayan dosya listede kalir (Seed null) ve loglanir; + /// Wizard Manager onu adiyla gosterir. + /// + private async Task> ReadSeedFilesAsync() + { + var outputPath = ResolveWizardSeedOutputPath(); + var files = new List<(string, WizardSeedFileDto?)>(); + + if (!Directory.Exists(outputPath)) + return files; + + foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName)) + { + var fileName = Path.GetFileName(file); + try + { + files.Add((fileName, WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(file)))); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Wizard seed file could not be parsed: {FileName}", fileName); + files.Add((fileName, null)); + } + } + + return files; + } + + /// Kurulum kararinin anahtari: List yolunda ekran kodu, Custom yolunda menu kodu. + private static string InstallationKey(ListFormWizardDto wizard) => + wizard.ComponentKind == WizardComponentKindEnum.Custom ? wizard.ResolveMenuCode() : wizard.ResolveListFormCode(); + + /// Dosya veritabanina henuz uygulanmadi mi; Wizard Manager'daki durumla ayni karar. + private async Task IsPendingAsync(WizardSeedFileDto seed) => + seed.Pending != null || !await _installationChecker.IsInstalledAsync( + seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom, seed.Wizard.ResolveMenuCode(), seed.Wizard.ResolveListFormCode()); + /// /// Ekranin ust menu zinciri, kokten baslayarak. Menu tablosu esastir — ekran sonradan baska bir /// menuye tasinmis olabilir; menu kaydi hic yoksa wizard'in ust menu cevabina duser. @@ -795,7 +886,7 @@ public class ListFormWizardAppService( await AuthorizationService.CheckAsync(AppCodes.SqlQueryManagers.Default); await EnsureNoDependentWizardsAsync(input.FileName); - return await DeleteFileInternalAsync(input.FileName, WizardDeleteMode.Delete, dropTables); + return await DeleteFileInternalAsync(input.FileName, dropTables); } /// @@ -836,11 +927,18 @@ public class ListFormWizardAppService( } /// - /// Seed dosyasini ve wizard'in urettigi, baska yerde kullanilmayan kayitlari siler; her kaydin - /// akibeti rapora ve loga yazilir (). Yetki kontrolu cagiran - /// uca aittir: silme yolunda Delete, duzenleme yolunda Update politikasi kullanilir. + /// Wizard dosyasini ve wizard'in urettigi kayitlari siler; her kaydin akibeti rapora ve loga + /// yazilir. Iki yol vardir: + /// + /// Migrate bekleyen wizard'in kayitlari yalnizca seed dosyalarindadir: veritabanina + /// dokunulmaz, wizard'in json dosyalarina ekledigi menu/modul/yetki/izin grubu/dil satirlari + /// cikarilir. + /// Kurulu wizard'da once veritabanindaki kayitlar kendi isleminde silinir + /// (); islem basariyla tamamlanirsa silinen kayitlarin json + /// satirlari cikarilir. Veritabani adimi basarisizsa dosyalara dokunulmaz. + /// /// - private async Task DeleteFileInternalAsync(string fileName, WizardDeleteMode mode, List? dropTables = null) + private async Task DeleteFileInternalAsync(string fileName, List? dropTables = null) { var filePath = ResolveWizardSeedFilePath(fileName); @@ -854,16 +952,30 @@ public class ListFormWizardAppService( }; // Tablolar ekran silinmeden once analiz edilir: kullanim kontrolu ekranin kendisini disarida - // birakir ve baglanti, veri kaynagi kaydi temizlenmeden once cozulur. Duzenleme yolunda - // tabloya hic dokunulmaz. - var tables = mode == WizardDeleteMode.Delete && seed != null + // birakir ve baglanti, veri kaynagi kaydi temizlenmeden once cozulur. + var tables = seed != null ? await _tableCleaner.AnalyzeAsync(seed) : []; var connections = await _tableCleaner.ResolveConnectionsAsync( tables.Where(t => dropTables?.Contains(t.TableName, StringComparer.OrdinalIgnoreCase) == true)); - if (seed != null) - result.Items.AddRange(await _recordCleaner.CleanAsync(seed, mode)); + var isPending = seed?.Wizard != null && await IsPendingAsync(seed); + if (isPending) + { + result.Items.AddRange(await RemovePendingSeedRowsAsync(seed!)); + } + else if (seed != null) + { + List items; + using (var uow = UnitOfWorkManager.Begin(new AbpUnitOfWorkOptions(isTransactional: true), requiresNew: true)) + { + items = await _recordCleaner.CleanAsync(seed); + await uow.CompleteAsync(); + } + + result.Items.AddRange(items); + await _recordCleaner.RemoveSeedRowsAsync(items); + } if (tables.Count > 0) await _tableCleaner.DropAsync(tables, dropTables ?? [], connections, result.Items); @@ -875,12 +987,60 @@ public class ListFormWizardAppService( Name = $"{SeedPathResolver.WizardFolder}/{fileName}", Status = WizardDeleteStatusEnum.Deleted, }); - logger.LogInformation("Wizard seed file deleted ({Mode}): {FilePath}", mode, filePath); + logger.LogInformation("Wizard seed file deleted: {FilePath}", filePath); + + // Bekleyen wizard'da veritabani degismedigi icin onbellekte gecersiz kalan bir sey yoktur. + if (!isPending) + await _languageTextAppService.ClearRedisCacheAsync(); - await _languageTextAppService.ClearRedisCacheAsync(); return result; } + /// + /// Migrate edilmemis wizard'in satirlarini seed dosyalarindan cikarir; veritabanina dokunmaz. + /// Baska bir yerde kullanilan satir kalir: seed dosyalarinda ona baglanan bir satir varsa + /// () ya da veritabaninda onu kullanan bir kayit + /// varsa (). Kalan satir raporda kullananlariyla + /// gorunur. + /// + private async Task> RemovePendingSeedRowsAsync(WizardSeedFileDto seed) + { + var rows = await WizardSeedRowStore.LoadAsync(ResolveScopeRoot()); + var owned = (seed.InsertedRecords ?? new WizardInsertedRecordsDto()).Union(seed.Pending?.Replaced); + + var usages = new Dictionary<(WizardRecordTypeEnum, string), List>(); + foreach (var (type, names) in new[] + { + (WizardRecordTypeEnum.Menu, owned.MenuCodes), + (WizardRecordTypeEnum.Permission, owned.PermissionNames), + (WizardRecordTypeEnum.PermissionGroup, owned.PermissionGroupNames), + (WizardRecordTypeEnum.Module, owned.ModuleIds), + (WizardRecordTypeEnum.LanguageKey, owned.LanguageKeys), + }) + { + foreach (var name in names) + { + var usedBy = await _recordCleaner.FindUsagesAsync(type, name); + if (usedBy.Count > 0) + usages[(type, name.ToLowerInvariant())] = usedBy; + } + } + + var outcome = rows.RemoveOwned(owned, (type, name) => usages.ContainsKey((type, name.ToLowerInvariant()))); + await rows.SaveAsync(); + + return [.. outcome.Select(r => r.Removed + ? new WizardDeleteLogItemDto { RecordType = r.Type, Name = r.Name, Status = WizardDeleteStatusEnum.Deleted } + : new WizardDeleteLogItemDto + { + RecordType = r.Type, + Name = r.Name, + Status = WizardDeleteStatusEnum.Kept, + Reason = WizardDeleteReasonEnum.InUse, + UsedBy = usages.GetValueOrDefault((r.Type, r.Name.ToLowerInvariant())) ?? [], + })]; + } + /// /// Kullanıcıdan gelen dosya adını doğrular ve seed dizini içinde kaldığını garanti eder. /// Sadece düz dosya adı kabul edilir; dizin ayracı, üst dizin ifadesi ve `.json` dışındaki @@ -933,15 +1093,18 @@ public class ListFormWizardAppService( /// /// En kritik durum menu kodunun secilen ust menunun koduyla ayni olmasidir: bu durumda wizard /// ust menuyu kendi kaydiyla ezer ve menu agacinda ust menu kaybolur. Ayni sekilde zaten var - /// olan bir menu koduna ya da daha once kaydedilmis bir Wizard adina izin verilmez; ikisi de - /// mevcut kayitlarin uzerine yazilmasi anlamina gelir. - /// - /// - /// Duzenleme akisi once cagirdigi icin (seed dosyasi ve wizard'in - /// olusturdugu menu kaydi silinir) ayni ad ile yeniden deploy edilebilir. + /// olan bir menu koduna (veritabaninda ya da henuz migrate edilmemis seed satirlarinda) ya da daha + /// once kaydedilmis bir Wizard adina izin verilmez; ikisi de mevcut kayitlarin uzerine yazilmasi + /// anlamina gelir. Duzenlemede duzenlenen dosya ve onun sahip oldugu menu bu kontrolun disindadir. /// /// - private async Task ValidateWizardUniquenessAsync(string wizardName, string code, string menuParentCode) + private async Task ValidateWizardUniquenessAsync( + string wizardName, + string code, + string menuParentCode, + string? editFileName, + WizardSeedFileDto? previous, + WizardSeedRowStore rows) { if (string.Equals(code, menuParentCode?.Trim(), StringComparison.OrdinalIgnoreCase)) { @@ -949,23 +1112,34 @@ public class ListFormWizardAppService( $"Menü kodu ('{code}') üst menü kodu ile aynı olamaz. Farklı bir Wizard adı ya da menü kodu seçin; aksi halde üst menü bu kaydın üzerine yazılır."); } - var existingFiles = await GetFiles(); - var duplicateFile = existingFiles.FirstOrDefault(f => - string.Equals(f.WizardName?.Trim(), wizardName, StringComparison.OrdinalIgnoreCase)); + var duplicateFile = (await ReadSeedFilesAsync()) + .Where(f => !string.Equals(f.FileName, editFileName, StringComparison.OrdinalIgnoreCase) && + string.Equals((f.Seed?.Wizard?.WizardName ?? f.FileName).Trim(), wizardName, StringComparison.OrdinalIgnoreCase)) + .Select(f => f.FileName) + .FirstOrDefault(); if (duplicateFile != null) { throw new UserFriendlyException( - $"'{wizardName}' adında bir Wizard zaten var ({duplicateFile.FileName}). Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin."); + $"'{wizardName}' adında bir Wizard zaten var ({duplicateFile}). Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin."); } - var menuQueryable = await repoMenu.GetQueryableAsync(); - if (await AsyncExecuter.AnyAsync(menuQueryable.Where(a => a.Code == code))) + if (previous?.InsertedRecords?.MenuCodes.Contains(code, StringComparer.OrdinalIgnoreCase) == true) + return; + + if (rows.Contains(WizardSeedRowStore.MenuFile, nameof(Menu.Code), code) || + await repoMenu.AnyAsync(a => a.Code == code)) { throw new UserFriendlyException( $"'{code}' kodlu bir menü zaten var. Farklı bir Wizard adı ya da menü kodu seçin."); } } + /// Aktif kapsamin seed klasoru (host ya da tenants/{tenantId}). + private string ResolveScopeRoot() + { + return Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id)); + } + /// /// Wizard seed dosyalarinin yazilacagi wizard dizinini dondurur; kapsam aktif tenant'i izler /// (host baglaminda host/wizard, tenant baglaminda tenants/{tenantId}/wizard). @@ -1558,14 +1732,16 @@ public class ListFormWizardAppService( /// CRUD endpoint tanimlari — tablolar hazir olduktan sonra yazilir. /// Veri satirlari — tabloda olmayan satir eklenir, var olan dosyadaki degerlerle /// guncellenir ve soft delete izleri temizlenir; aksi halde kayit tabloda durur ama ekranin - /// varsayilan suzgeci onu gizlemeye devam ederdi. + /// varsayilan suzgeci onu gizlemeye devam ederdi. Wizard'in menu/yetki/dil satirlarini tasiyan + /// platform dosyalari () burada uygulanmaz: + /// ekranin kaydi deploy'daki gibi migrate'e kalir, yoksa menu ekrandan once gelirdi. /// /// Hedefle zaten ayni olan dosyalar da uygulanir: yazacak bir sey yoktur ama veritabaninda /// karsiligi eksik ya da silinmis olabilir. Migrate/seed akisindan farki budur — orada dosya /// bayat olabilir ve canli kayit ezilmez; import ise kullanicinin bilincli getirdigi icerigi tasir. /// - /// Ekran tanimlari (wizard/) bu adimda uygulanmaz; onlari bir sonraki migrate/seed - /// calismasinda WizardDataSeeder kurar ve sonucta bekleyen ekran olarak raporlanir. + /// Ekran tanimlari (wizard/) bu adimda uygulanmaz; dosyalar bekleyen kurulum olarak + /// isaretlenir ve bir sonraki DB Migrate calismasinda WizardDataSeeder onlari kurar. /// /// /// Uygulama basarisiz olsa bile oturum kapatilir; dosyalar hedefe yazilmis durumdadir ve hata @@ -1617,7 +1793,10 @@ public class ListFormWizardAppService( } // 4) Veri satirlari. - var dataFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json"); + var wizardRowFiles = WizardSeedRowStore.ListFormCodes.Select(SeedPathResolver.GetDataFilePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + var dataFiles = (await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json")) + .Where(path => !wizardRowFiles.Contains(path)) + .ToList(); foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true)) { result.AppliedData.Add(new WizardImportSeedApplyResultDto @@ -1630,90 +1809,39 @@ public class ListFormWizardAppService( }); } - // 5) Ekran tanimlari. En sonda calisir: ListForm'un dayandigi tablo, CRUD ucu ve lookup - // verileri bu noktada hazirdir. Zaten kurulu bir ekran yeniden uretilmez — tekrar kurmak - // tasarimci uzerinde yapilmis degisiklikleri silerdi. + // 5) Ekran tanimlari migrate'e kalir: dosya bekleyen kurulum olarak isaretlenir; hedefte + // ekran kurulu olsa bile migrate onu dosyadaki haliyle yeniden kurar. var wizardFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.WizardFolder + "/", ".json"); foreach (var relativePath in wizardFiles) - result.AppliedWizards.Add(await ApplyImportedWizardAsync(scopeRoot, relativePath)); - - if (result.AppliedWizards.Exists(x => x.Error == null)) - await _languageTextAppService.ClearRedisCacheAsync(); + result.AppliedWizards.Add(await MarkImportedWizardPendingAsync(scopeRoot, relativePath)); DeleteImportSession(sessionPath); return result; } /// - /// Import edilen bir wizard dosyasindaki ekrani kurar. - /// - /// Kurulum wizard ekraninin kullandigi DeployWizardAsync ile ayni yoldan gecer; ikinci - /// bir uretim kopyasi yoktur. Dosya ekranin uretilmis sozlesmesini tasidigi icin ListForm ve - /// alanlar yeniden turetilmez, oldugu gibi kurulur. - /// - /// - /// Ekran hedefte zaten varsa dokunulmaz: import var olan bir ekrani ezmez, yeniden - /// uretmek tasarimci uzerindeki degisiklikleri silerdi. Custom yolunda ListForm uretilmedigi - /// icin ayni karar menu (menusuz wizard'da okuma izni) uzerinden verilir. - /// + /// Import edilen wizard dosyasina bekleyen kurulum () yazar. + /// Deploy'daki kuralin aynisidir: import veritabanina ekran kurmaz, Wizard Manager dosyayi + /// "migrate bekliyor" olarak gosterir ve kurulumu DB Migrate yapar. /// - private async Task ApplyImportedWizardAsync(string scopeRoot, string relativePath) + private async Task MarkImportedWizardPendingAsync(string scopeRoot, string relativePath) { var step = new WizardImportStepResultDto { Path = relativePath }; try { var fullPath = ResolveScopeFilePath(scopeRoot, relativePath); - if (fullPath == null) - { - step.Error = "Dosya kapsam klasöründe bulunamadı."; - return step; - } - - var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(fullPath)); + var seed = fullPath == null ? null : WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(fullPath)); if (seed?.Wizard == null || string.IsNullOrWhiteSpace(seed.Wizard.WizardName)) { step.Error = "Geçerli bir wizard dosyası değil."; return step; } - var input = seed.Wizard; - var code = input.ResolveMenuCode(); + seed.Pending ??= new WizardPendingDto { Since = Clock.Now }; + await File.WriteAllTextAsync(fullPath!, seed.ToSeedJson()); - if (input.ComponentKind == WizardComponentKindEnum.Custom) - { - if (seed.ListForm != null) - { - step.Error = "Custom wizard dosyasında ListForm bölümü var; dosya tutarsız."; - return step; - } - } - else if (seed.ListForm == null) - { - step.Error = "ListForm bölümü eksik, ekran kurulamaz."; - return step; - } - - var alreadyInstalled = input.ComponentKind == WizardComponentKindEnum.Custom - ? input.CreateMenu - ? await repoMenu.AnyAsync(a => a.Code == code) - : await repoPerm.AnyAsync(a => a.Name == code) - : await repoListForm.AnyAsync(a => a.ListFormCode == input.ResolveListFormCode()); - - if (alreadyInstalled) - { - step.Detail = $"'{input.WizardName}' zaten kurulu; dokunulmadı."; - return step; - } - - // Her ekran kendi isleminde kurulur: bir dosyadaki hata yalnizca o ekranin - // kayitlarini geri alir, once kurulmus ekranlari ve import'un geri kalanini bozmaz. - // Ortak islemde birakilsaydi yarim kurulmus bir ekran kapanista yine de yazilirdi. - using var uow = UnitOfWorkManager.Begin(new AbpUnitOfWorkOptions(), requiresNew: true); - await DeployWizardAsync(input, seed); - await uow.CompleteAsync(); - - step.Detail = $"'{input.WizardName}' ekranı kuruldu."; + step.Detail = $"'{seed.Wizard.WizardName}' DB Migrate ile kurulacak."; } catch (Exception ex) { @@ -2039,14 +2167,14 @@ public class ListFormWizardAppService( string keyFieldName, List> rows) { - return JsonSerializer.Serialize( + return SeedJsonText.RestoreSurrogatePairs(JsonSerializer.Serialize( new ListFormSeedDataScopeDto { ListFormCode = source.ListFormCode, KeyFieldName = keyFieldName, Rows = rows, }, - ListFormSeedDataFileDto.JsonOptions); + ListFormSeedDataFileDto.JsonOptions)); } /// @@ -2097,7 +2225,7 @@ public class ListFormWizardAppService( existingFile.Rows = merged; existingFile.GeneratedAt = Clock.Now.ToUniversalTime(); - return ImportEncoding.GetBytes(JsonSerializer.Serialize(existingFile, ListFormSeedDataFileDto.JsonOptions)); + return ImportEncoding.GetBytes(ListFormSeedDataFileDto.Serialize(existingFile)); } catch (Exception ex) { @@ -2295,118 +2423,21 @@ public class ListFormWizardAppService( } /// - /// Izin grubunu ve iki dilli gorunen adini hazirlar. - /// - /// Grup yeniyse kaydi ve grup adiyla ayni olan dil anahtarini uretir; gorunen ad - /// girilmediyse grup adina duser. Grup zaten varsa veritabani metinlerine dokunulmaz, - /// yalnizca kullanici acikca farkli bir metin girdiyse guncellenir. Her durumda - /// uzerindeki gorunen adlar doldurulur; seed dosyasi mevcut bir - /// grup secildiginde de iki dilli gorunen adi tasimalidir. - /// + /// Bir dil anahtarinin Ingilizce/Turkce metinleri; veritabaninda yoksa henuz migrate edilmemis + /// seed satirlarindan okunur. Hicbirinde yoksa null. /// - private async Task ApplyPermissionGroupAsync(ListFormWizardDto input, string groupName, WizardInsertedRecordsDto inserted) - { - var displayEn = input.PermissionGroupEn?.Trim(); - var displayTr = input.PermissionGroupTr?.Trim(); - - if (!await repoPermGroup.AnyAsync(a => a.Name == groupName)) - { - var group = new PermissionGroupDefinitionRecord(GuidGenerator.Create(), groupName, groupName); - group.SetPermissionOrder((await repoPermGroup.GetListAsync()).NextPermissionOrder()); - await repoPermGroup.InsertAsync(group, autoSave: false); - - // Grup adi ust menu koduyla ayniysa anahtar menu adiyla ortaktir; metin varsa uzerine - // yazmak menu adini bozar, ust menu daha once baska bir wizard'la olustuysa metin hic - // uretilmemis olabilir - o durumda menu metinleriyle tamamlanir. - if (string.Equals(groupName, input.MenuParentCode, StringComparison.OrdinalIgnoreCase)) - { - displayEn = string.IsNullOrWhiteSpace(displayEn) ? input.MenuParentEn?.Trim() : displayEn; - displayTr = string.IsNullOrWhiteSpace(displayTr) ? input.MenuParentTr?.Trim() : displayTr; - - var (menuEn, menuTr) = await GetLangTextsAsync(groupName); - if (string.IsNullOrWhiteSpace(menuEn) || string.IsNullOrWhiteSpace(menuTr)) - await CreateLangKey(groupName, displayEn ?? groupName, displayTr ?? groupName, inserted); - } - else - { - displayEn = string.IsNullOrWhiteSpace(displayEn) ? groupName : displayEn; - displayTr = string.IsNullOrWhiteSpace(displayTr) ? groupName : displayTr; - await CreateLangKey(groupName, displayEn, displayTr, inserted); - } - - inserted.PermissionGroupNames.Add(groupName); - } - else - { - var (existingEn, existingTr) = await GetLangTextsAsync(groupName); - - // Mevcut grubun metinleri yalnizca kullanici bilerek degistirdiginde guncellenir. - var changed = (!string.IsNullOrWhiteSpace(displayEn) && displayEn != existingEn) - || (!string.IsNullOrWhiteSpace(displayTr) && displayTr != existingTr); - - displayEn = string.IsNullOrWhiteSpace(displayEn) ? (existingEn ?? groupName) : displayEn; - displayTr = string.IsNullOrWhiteSpace(displayTr) ? (existingTr ?? groupName) : displayTr; - - if (changed) - await CreateLangKey(groupName, displayEn, displayTr, inserted); - } - - input.PermissionGroupEn = string.IsNullOrWhiteSpace(displayEn) ? groupName : displayEn; - input.PermissionGroupTr = string.IsNullOrWhiteSpace(displayTr) ? groupName : displayTr; - } - - /// Bir dil anahtarinin Ingilizce/Turkce metinlerini dondurur; yoksa null. - private async Task<(string? En, string? Tr)> GetLangTextsAsync(string key) + private async Task<(string? En, string? Tr)> GetLangTextsAsync(WizardSeedRowStore rows, string key) { var res = PlatformConsts.AppName; var texts = await repoLangText.GetListAsync(a => a.ResourceName == res && a.Key == key); - return (texts.FirstOrDefault(a => a.CultureName == cultureNameDefault)?.Value, - texts.FirstOrDefault(a => a.CultureName == LanguageCodes.Tr)?.Value); - } - /// - /// Wizard'da secilen modul kaydi yoksa olusturur; menunun ModuleId yabanci anahtari - /// tanimsiz bir module bagli kalmasin diye menu yazilmadan once cagrilir. - /// - private async Task EnsureModuleAsync(string moduleId, WizardInsertedRecordsDto inserted) - { - var cleaned = moduleId?.Trim(); - if (string.IsNullOrWhiteSpace(cleaned)) - { - return; - } + string? Read(string culture) => + texts.FirstOrDefault(a => a.CultureName == culture)?.Value + ?? WizardSeedRowStore.Text(rows.Rows(WizardSeedRowStore.LanguageTextFile).FirstOrDefault(r => + string.Equals(WizardSeedRowStore.Text(r, nameof(LanguageText.Key)), key, StringComparison.OrdinalIgnoreCase) && + string.Equals(WizardSeedRowStore.Text(r, nameof(LanguageText.CultureName)), culture, StringComparison.OrdinalIgnoreCase)), + nameof(LanguageText.Value)); - if (await repoModule.AnyAsync(a => a.Id == cleaned)) - { - return; - } - - await repoModule.InsertAsync(new Module(cleaned) { Name = cleaned }, autoSave: false); - inserted.ModuleIds.Add(cleaned); - logger.LogInformation("Module '{ModuleId}' wizard tarafindan olusturuldu.", cleaned); - } - - private async Task CreateLangKey(string key, string textEn, string textTr, WizardInsertedRecordsDto inserted = null) - { - var res = PlatformConsts.AppName; - - var existing = await repoLangKey.FirstOrDefaultAsync(a => a.ResourceName == res && a.Key == key); - if (existing == null) - { - existing = await repoLangKey.InsertAsync(new LanguageKey { ResourceName = res, Key = key }, autoSave: true); - inserted?.LanguageKeys.Add(key); - } - - var existingTexts = await repoLangText.GetListAsync(a => a.ResourceName == res && a.Key == existing.Key); - - var existingEn = existingTexts.FirstOrDefault(a => a.CultureName == cultureNameDefault); - if (existingEn != null) await repoLangText.DeleteAsync(existingEn, autoSave: true); - await repoLangText.InsertAsync(new LanguageText { ResourceName = res, Key = existing.Key, CultureName = cultureNameDefault, Value = textEn }, autoSave: true); - - var existingTr = existingTexts.FirstOrDefault(a => a.CultureName == LanguageCodes.Tr); - if (existingTr != null) await repoLangText.DeleteAsync(existingTr, autoSave: true); - await repoLangText.InsertAsync(new LanguageText { ResourceName = res, Key = existing.Key, CultureName = LanguageCodes.Tr, Value = textTr }, autoSave: true); - - return existing; + return (Read(cultureNameDefault), Read(LanguageCodes.Tr)); } } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs index 3b159298..7d2c3ad0 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs @@ -54,6 +54,9 @@ public sealed class WizardExportCollector(string scopeRoot) private readonly HashSet customComponents = new(StringComparer.OrdinalIgnoreCase); private readonly List warnings = []; + /// Toplanan wizard'larin platform seed dosyalarindaki (menu, yetki, dil) satir anahtarlari. + private WizardInsertedRecordsDto platformRows = new(); + /// ListFormCode -> wizard seed dosyasi; alt form cozumu icin bir kez kurulur. private Dictionary? wizardFilesByListFormCode; @@ -84,6 +87,7 @@ public sealed class WizardExportCollector(string scopeRoot) } AddDiskFile($"{SeedPathResolver.WizardFolder}/{System.IO.Path.GetFileName(filePath)}", reason); + platformRows = platformRows.Union(WizardSeedRowKeys.All(seed.Wizard)); if (seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom) await CollectCustomAsync(seed.Wizard.CustomComponentName, seed.Wizard.WizardName); @@ -92,6 +96,7 @@ public sealed class WizardExportCollector(string scopeRoot) } await MaterializeCustomComponentsAsync(); + await MaterializePlatformRowsAsync(); return [.. items.Values.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)]; } @@ -272,12 +277,58 @@ public sealed class WizardExportCollector(string scopeRoot) Rows = rows, }; - var content = JsonSerializer.Serialize(filtered, ListFormSeedDataFileDto.JsonOptions); + var content = ListFormSeedDataFileDto.Serialize(filtered); items[relativePath] = new WizardExportItem(relativePath, "Custom component tanımı", null, content); } } + /// + /// Wizard'in menu, modul, izin grubu, yetki ve dil metni satirlari paylasilan platform seed + /// dosyalarindadir; ekran bu satirlar olmadan hedefte menusuz ve yetkisiz kurulur. Her dosya + /// yalnizca toplanan wizard'larin satirlarina indirgenerek arsive girer; import satirlari hedef + /// dosyayla anahtar uzerinden birlestirir, migrate eksik olanlari ekler. + /// + private async Task MaterializePlatformRowsAsync() + { + (string ListFormCode, string Field, List Keys)[] targets = + [ + (WizardSeedRowStore.ModuleFile, "Id", platformRows.ModuleIds), + (WizardSeedRowStore.MenuFile, "Code", platformRows.MenuCodes), + (WizardSeedRowStore.PermissionGroupFile, "Name", platformRows.PermissionGroupNames), + (WizardSeedRowStore.PermissionFile, "Name", platformRows.PermissionNames), + (WizardSeedRowStore.LanguageTextFile, "Key", platformRows.LanguageKeys), + ]; + + foreach (var (listFormCode, field, keys) in targets) + { + var relativePath = SeedPathResolver.GetDataFilePath(listFormCode); + var fullPath = ResolveScopeFilePath(relativePath); + if (fullPath == null || keys.Count == 0) + continue; + + var seedFile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(fullPath), ListFormSeedDataFileDto.JsonOptions); + + var wanted = keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var rows = seedFile?.Rows?.Where(x => wanted.Contains(GetRowText(x, field) ?? string.Empty)).ToList() ?? []; + if (seedFile == null || rows.Count == 0) + continue; + + var filtered = new ListFormSeedDataFileDto + { + ListFormCode = seedFile.ListFormCode, + KeyFieldName = seedFile.KeyFieldName, + GeneratedAt = seedFile.GeneratedAt, + Order = seedFile.Order, + Rows = rows, + }; + + items[relativePath] = new WizardExportItem( + relativePath, "Menü, yetki ve dil satırları", null, ListFormSeedDataFileDto.Serialize(filtered)); + } + } + /// /// Component adi -> dogrudan bagimliliklari. Wizard listesi gruplamayi export'un izledigi /// zincirle ayni kaynaktan kurar; boylece listede birlikte gorunenler arsive de birlikte girer. diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs index f2c197c9..6f5d49a2 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardRecordCleaner.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -28,17 +28,16 @@ namespace Sozsoft.Platform.ListForms; /// ekranin kullandigi kayit birakilir ve kullananlar rapora yazilir. /// /// -/// duzenleme yoludur: kayitlar hemen ardindan ayni -/// cevaplardan yeniden uretilir. Bu yolda paylasilan kaplar (ust menu, izin grubu, modul, veri -/// kaynagi) ve diger rollerin yetki atamalari korunur; silinip yeniden uretilseler menu sirasi ve -/// rol atamalari kaybolurdu. +/// Wizard'in baglandigi kaplar da (ust menu zinciri, modul, izin grubu) bosaldiysa silinir: kap +/// bir baska wizard'la olusmus ve o wizard silinirken bu wizard'in menusu yuzunden birakilmis +/// olabilir; sahibi kalmayan kap artik olarak kalirdi. Bu kaplar yalnizca silindiklerinde rapora +/// girer. /// /// -/// Silme yolunda wizard'in baglandigi kaplar da (ust menu zinciri, modul, izin grubu) bosaldiysa -/// silinir: kap bir baska wizard'la olusmus ve o wizard silinirken bu wizard'in menusu yuzunden -/// birakilmis olabilir; sahibi kalmayan kap yeniden deploy'da artik olarak kalirdi. Bu kaplar -/// yalnizca silindiklerinde rapora girer. Silinen menu, modul, yetki ve izin grubu satirlari ekranlarin -/// seed dosyalarindan da cikarilir; aksi halde migrate onlari geri getirirdi. +/// Yalnizca kurulu (migrate edilmis) wizard icin kullanilir; migrate bekleyen wizard'in kayitlari +/// yalnizca seed dosyalarindadir. Veritabani temizligi () ile seed satirlarinin +/// cikarilmasi () ayri adimlardir: satirlar ancak veritabani islemi +/// basariyla tamamlandiktan sonra cikarilir, aksi halde dosya ile veritabani ayrisirdi. /// /// public class WizardRecordCleaner( @@ -65,20 +64,24 @@ public class WizardRecordCleaner( /// Raporda bir kaydi kullananlarin en fazla bu kadari listelenir. private const int MaxUsedBy = 20; - public async Task> CleanAsync(WizardSeedFileDto seed, WizardDeleteMode mode) + public async Task> CleanAsync(WizardSeedFileDto seed) { var report = new List(); var wizard = seed.Wizard; - var ins = seed.InsertedRecords ?? new WizardInsertedRecordsDto(); + var ins = (seed.InsertedRecords ?? new WizardInsertedRecordsDto()).Union(seed.Pending?.Replaced); // Custom yolunda ListForm uretilmez; ayni kodu tasiyan bir List wizard'inin ekrani silinmesin. if (wizard?.ComponentKind != WizardComponentKindEnum.Custom && !string.IsNullOrWhiteSpace(wizard?.ListFormCode)) - await CleanListFormAsync(wizard.ListFormCode, mode, report); + await CleanListFormAsync(wizard.ListFormCode, report); - var deletedMenus = await CleanMenusAsync(ins.MenuCodes, wizard?.MenuParentCode, mode, report); + // Migrate edilmeden kodu degistirilen ekranin onceki surumu hala kuruludur. + foreach (var listFormCode in seed.Pending?.ListFormCodes ?? []) + await CleanListFormAsync(listFormCode, report); + + var deletedMenus = await CleanMenusAsync(ins.MenuCodes, report); var adoptedLanguageKeys = new List(); - if (mode == WizardDeleteMode.Delete && wizard?.CreateMenu == true) + if (wizard?.CreateMenu == true) { var parents = deletedMenus.Select(m => m.ParentCode).Append(wizard.MenuParentCode); var emptyParents = await CleanEmptyParentMenusAsync(parents, deletedMenus, report); @@ -86,33 +89,28 @@ public class WizardRecordCleaner( adoptedLanguageKeys.AddRange(emptyParents.Select(m => m.DisplayName)); } - await CleanPermissionsAsync(ins.PermissionNames, mode, report); + await CleanPermissionsAsync(ins.PermissionNames, report); - await CleanPermissionGroupsAsync(ins.PermissionGroupNames, mode, report, reportKept: true); - await CleanModulesAsync(ins.ModuleIds, mode, report, reportKept: true); + await CleanPermissionGroupsAsync(ins.PermissionGroupNames, report, reportKept: true); + await CleanModulesAsync(ins.ModuleIds, report, reportKept: true); - if (mode == WizardDeleteMode.Delete) - { - var groups = await CleanPermissionGroupsAsync( - Adopted([wizard?.PermissionGroupName], ins.PermissionGroupNames), mode, report, reportKept: false); - adoptedLanguageKeys.AddRange(groups); + var groups = await CleanPermissionGroupsAsync( + Adopted([wizard?.PermissionGroupName], ins.PermissionGroupNames), report, reportKept: false); + adoptedLanguageKeys.AddRange(groups); - await CleanModulesAsync( - Adopted([.. deletedMenus.Select(m => m.ModuleId), wizard?.MenuParentModuleId], ins.ModuleIds), - mode, report, reportKept: false); - } + await CleanModulesAsync( + Adopted([.. deletedMenus.Select(m => m.ModuleId), wizard?.MenuParentModuleId], ins.ModuleIds), + report, reportKept: false); - await CleanDataSourcesAsync(ins.DataSourceCodes, mode, report); + await CleanDataSourcesAsync(ins.DataSourceCodes, report); await CleanLanguageKeysAsync(ins.LanguageKeys, report, reportKept: true); await CleanLanguageKeysAsync(Adopted(adoptedLanguageKeys, ins.LanguageKeys), report, reportKept: false); - await RemoveSeedRowsAsync(report); - foreach (var item in report) { logger.LogInformation( - "Wizard '{WizardName}' {Mode}: {RecordType} '{Name}' -> {Status} {Reason} {UsedBy}", - wizard?.WizardName, mode, item.RecordType, item.Name, item.Status, item.Reason, + "Wizard '{WizardName}' delete: {RecordType} '{Name}' -> {Status} {Reason} {UsedBy}", + wizard?.WizardName, item.RecordType, item.Name, item.Status, item.Reason, string.Join(", ", item.UsedBy.Select(u => $"{u.RecordType}:{u.Name}"))); } @@ -123,10 +121,9 @@ public class WizardRecordCleaner( /// /// Ekran baska bir ekranin alt formuysa silinmez; alanlari ve is akisi da ekranla birlikte - /// kalir, aksi halde ust ekranin sekmesi bozulurdu. Duzenlemede deploy ekrani zaten yeniden - /// kurar, bu kontrol yapilmaz. + /// kalir, aksi halde ust ekranin sekmesi bozulurdu. /// - private async Task CleanListFormAsync(string listFormCode, WizardDeleteMode mode, List report) + private async Task CleanListFormAsync(string listFormCode, List report) { var listForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); var fields = await repoListFormField.GetListAsync(a => a.ListFormCode == listFormCode); @@ -136,7 +133,7 @@ public class WizardRecordCleaner( { Add(report, WizardRecordTypeEnum.ListForm, listFormCode, WizardDeleteStatusEnum.NotFound); } - else if (mode == WizardDeleteMode.Delete) + else { var quoted = Quote(listFormCode); var parents = await repoListForm.GetListAsync(a => @@ -171,15 +168,13 @@ public class WizardRecordCleaner( { await repoListForm.DeleteAsync(listForm, autoSave: true); Add(report, WizardRecordTypeEnum.ListForm, listFormCode, WizardDeleteStatusEnum.Deleted); - - if (mode == WizardDeleteMode.Delete) - DeleteSeedDataMirror(listForm.SeedFilePath, report); + DeleteSeedDataMirror(listForm.SeedFilePath, report); } } /// /// Ekranin veri aynasi (ListForm.SeedFilePath) ekransiz uygulanamaz; ekranla birlikte - /// gider. Duzenlemede ekran ayni yolla yeniden kurulacagi icin dokunulmaz. + /// gider. /// private void DeleteSeedDataMirror(string? seedFilePath, List report) { @@ -222,17 +217,10 @@ public class WizardRecordCleaner( /// Altinda baska menu kalan menu silinmez. Wizard'in kendi menusu ust menuden once silinir; /// ust menunun bos kalip kalmadigi ancak o zaman bilinir. /// - private async Task> CleanMenusAsync(List codes, string? menuParentCode, WizardDeleteMode mode, List report) + private async Task> CleanMenusAsync(List codes, List report) { var deleted = new List(); - var pending = new List(); - foreach (var code in codes.Distinct(StringComparer.OrdinalIgnoreCase)) - { - if (mode == WizardDeleteMode.Redeploy && string.Equals(code, menuParentCode, StringComparison.OrdinalIgnoreCase)) - AddRedeploy(report, WizardRecordTypeEnum.Menu, code); - else - pending.Add(code); - } + var pending = codes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); // Ust menu, alt menusu silindikten sonra bosalir; ilerleme durana kadar tekrar denenir. var progressed = true; @@ -306,11 +294,9 @@ public class WizardRecordCleaner( /// /// Kalan bir menu, ekran, alan, widget ya da alt yetki bir yetkiyi kullaniyorsa yetki silinmez. - /// Alt yetkiler ust yetkiden once silinir. Silinen yetkinin atamalari da silinir: silme - /// yolunda tum saglayicilarinki, duzenlemede yalnizca admin rolununki — diger rollerin - /// atamalari yeniden uretilen ayni adli yetkiye bagli kalir. + /// Alt yetkiler ust yetkiden once silinir. Silinen yetkinin butun atamalari da silinir. /// - private async Task CleanPermissionsAsync(List names, WizardDeleteMode mode, List report) + private async Task CleanPermissionsAsync(List names, List report) { var pending = names.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var blockers = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -337,7 +323,7 @@ public class WizardRecordCleaner( continue; } - await DeletePermissionGrantsAsync(name, mode, report); + await DeletePermissionGrantsAsync(name, report); await repoPerm.DeleteAsync(permission, autoSave: true); Add(report, WizardRecordTypeEnum.Permission, name, WizardDeleteStatusEnum.Deleted); pending.Remove(name); @@ -369,12 +355,9 @@ public class WizardRecordCleaner( return usedBy; } - private async Task DeletePermissionGrantsAsync(string name, WizardDeleteMode mode, List report) + private async Task DeletePermissionGrantsAsync(string name, List report) { - var grants = mode == WizardDeleteMode.Delete - ? await repoPermGrant.GetListAsync(a => a.Name == name) - : await repoPermGrant.GetListAsync(a => - a.Name == name && a.ProviderName == "R" && a.ProviderKey == PlatformConsts.AbpIdentity.User.AdminRoleName); + var grants = await repoPermGrant.GetListAsync(a => a.Name == name); if (grants.Count == 0) return; @@ -390,17 +373,11 @@ public class WizardRecordCleaner( /// kapaliyken kayit wizard'in degil, yalnizca baglandigi kaptir: /// bulunamamasi ya da kullanimda kalmasi rapora yazilmaz. Silinen grup adlarini dondurur. /// - private async Task> CleanPermissionGroupsAsync(List names, WizardDeleteMode mode, List report, bool reportKept) + private async Task> CleanPermissionGroupsAsync(List names, List report, bool reportKept) { var deleted = new List(); foreach (var name in names.Distinct(StringComparer.OrdinalIgnoreCase)) { - if (mode == WizardDeleteMode.Redeploy) - { - AddRedeploy(report, WizardRecordTypeEnum.PermissionGroup, name); - continue; - } - var group = await repoPermGroup.FirstOrDefaultAsync(a => a.Name == name); if (group == null) { @@ -432,22 +409,13 @@ public class WizardRecordCleaner( /// Modul; menuler, yetkilerin modul listesi ve kiracilarin modul listesi tarafindan kullanilir. /// izin gruplarindakiyle aynidir. /// - private async Task CleanModulesAsync(List ids, WizardDeleteMode mode, List report, bool reportKept) + private async Task CleanModulesAsync(List ids, List report, bool reportKept) { if (ids.Count == 0) return; - List? permissions = null; - List? tenants = null; - foreach (var id in ids.Distinct(StringComparer.OrdinalIgnoreCase)) { - if (mode == WizardDeleteMode.Redeploy) - { - AddRedeploy(report, WizardRecordTypeEnum.Module, id); - continue; - } - var module = await repoModule.FindAsync(id); if (module == null) { @@ -456,19 +424,7 @@ public class WizardRecordCleaner( continue; } - // Modul listeleri ekstra ozellikte metin olarak durur; sorguyla suzulemez, bellekte bakilir. - permissions ??= await repoPerm.GetListAsync(); - tenants ??= await tenantRepository.GetListAsync(); - - var usedBy = new List(); - usedBy.AddRange((await repoMenu.GetListAsync(a => a.ModuleId == id)) - .Select(m => Ref(WizardRecordTypeEnum.Menu, m.Code))); - usedBy.AddRange(permissions - .Where(p => p.GetModules().Contains(id, StringComparer.OrdinalIgnoreCase)) - .Select(p => Ref(WizardRecordTypeEnum.Permission, p.Name))); - usedBy.AddRange(tenants - .Where(t => t.GetModules().Contains(id, StringComparer.OrdinalIgnoreCase)) - .Select(t => Ref(WizardRecordTypeEnum.Tenant, t.Name))); + var usedBy = await FindModuleUsagesAsync(id); if (usedBy.Count > 0) { @@ -482,16 +438,26 @@ public class WizardRecordCleaner( } } - private async Task CleanDataSourcesAsync(List codes, WizardDeleteMode mode, List report) + private async Task> FindModuleUsagesAsync(string id) + { + // Modul listeleri ekstra ozellikte metin olarak durur; sorguyla suzulemez, bellekte bakilir. + var usedBy = new List(); + usedBy.AddRange((await repoMenu.GetListAsync(a => a.ModuleId == id)) + .Select(m => Ref(WizardRecordTypeEnum.Menu, m.Code))); + usedBy.AddRange((await repoPerm.GetListAsync()) + .Where(p => p.GetModules().Contains(id, StringComparer.OrdinalIgnoreCase)) + .Select(p => Ref(WizardRecordTypeEnum.Permission, p.Name))); + usedBy.AddRange((await tenantRepository.GetListAsync()) + .Where(t => t.GetModules().Contains(id, StringComparer.OrdinalIgnoreCase)) + .Select(t => Ref(WizardRecordTypeEnum.Tenant, t.Name))); + + return usedBy; + } + + private async Task CleanDataSourcesAsync(List codes, List report) { foreach (var code in codes.Distinct(StringComparer.OrdinalIgnoreCase)) { - if (mode == WizardDeleteMode.Redeploy) - { - AddRedeploy(report, WizardRecordTypeEnum.DataSource, code); - continue; - } - var dataSource = await repoDataSource.FirstOrDefaultAsync(a => a.Code == code); if (dataSource == null) { @@ -539,17 +505,7 @@ public class WizardRecordCleaner( continue; } - var usedBy = new List(); - usedBy.AddRange((await repoMenu.GetListAsync(a => a.DisplayName == key)) - .Select(m => Ref(WizardRecordTypeEnum.Menu, m.Code))); - usedBy.AddRange((await repoPerm.GetListAsync(a => a.DisplayName == key)) - .Select(p => Ref(WizardRecordTypeEnum.Permission, p.Name))); - usedBy.AddRange((await repoPermGroup.GetListAsync(a => a.DisplayName == key)) - .Select(g => Ref(WizardRecordTypeEnum.PermissionGroup, g.Name))); - usedBy.AddRange((await repoListForm.GetListAsync(a => a.Title == key || a.Description == key)) - .Select(l => Ref(WizardRecordTypeEnum.ListForm, l.ListFormCode))); - usedBy.AddRange((await repoListFormField.GetListAsync(a => a.CaptionName == key)) - .Select(f => Ref(WizardRecordTypeEnum.ListFormField, FieldName(f)))); + var usedBy = await FindLanguageKeyUsagesAsync(key); if (usedBy.Count > 0) { @@ -567,22 +523,58 @@ public class WizardRecordCleaner( } } + private async Task> FindLanguageKeyUsagesAsync(string key) + { + var usedBy = new List(); + usedBy.AddRange((await repoMenu.GetListAsync(a => a.DisplayName == key)) + .Select(m => Ref(WizardRecordTypeEnum.Menu, m.Code))); + usedBy.AddRange((await repoPerm.GetListAsync(a => a.DisplayName == key)) + .Select(p => Ref(WizardRecordTypeEnum.Permission, p.Name))); + usedBy.AddRange((await repoPermGroup.GetListAsync(a => a.DisplayName == key)) + .Select(g => Ref(WizardRecordTypeEnum.PermissionGroup, g.Name))); + usedBy.AddRange((await repoListForm.GetListAsync(a => a.Title == key || a.Description == key)) + .Select(l => Ref(WizardRecordTypeEnum.ListForm, l.ListFormCode))); + usedBy.AddRange((await repoListFormField.GetListAsync(a => a.CaptionName == key)) + .Select(f => Ref(WizardRecordTypeEnum.ListFormField, FieldName(f)))); + + return usedBy; + } + + /// + /// Bir kaydi veritabaninda kullanan kayitlar; silme kurallariyla aynidir. Migrate edilmemis + /// wizard'in satiri seed dosyasindan cikarilirken de bu kontrolden gecer: kayit veritabaninda + /// baska bir yerden kullaniliyorsa satiri kalir. + /// + public async Task> FindUsagesAsync(WizardRecordTypeEnum type, string name) => type switch + { + WizardRecordTypeEnum.Menu => [.. (await repoMenu.GetListAsync(a => a.ParentCode == name)) + .Select(m => Ref(WizardRecordTypeEnum.Menu, m.Code))], + WizardRecordTypeEnum.Permission => await FindPermissionUsagesAsync(name), + WizardRecordTypeEnum.PermissionGroup => [.. (await repoPerm.GetListAsync(a => a.GroupName == name)) + .Select(p => Ref(WizardRecordTypeEnum.Permission, p.Name))], + WizardRecordTypeEnum.Module => await FindModuleUsagesAsync(name), + WizardRecordTypeEnum.LanguageKey => await FindLanguageKeyUsagesAsync(name), + _ => [], + }; + // ── Seed dosyalari ───────────────────────────────────────────────────────────── /// - /// Silinen kayitlarin ekran seed dosyalarindaki satirlari. Wizard bu dosyalara yazmaz; satir, - /// kayit menu/modul/yetki ekranlarindan duzenlendiginde senkronizasyonla girer ve kayit silindikten - /// sonra dosyada kalirsa migrate onu geri getirir. + /// Kaydin seed dosyalarindaki satirlari. Wizard menu/yetki/dil kayitlarini bu dosyalara yazar ve + /// migrate onlari oradan kurar; silinen kaydin satiri dosyada kalirsa migrate onu geri getirir. + /// Veritabaninda bulunmayan kayit henuz migrate edilmemistir ve yalnizca dosyada yasar; o da + /// cikarilir. Kullanimda oldugu icin birakilan kaydin satirina dokunulmaz. /// - private async Task RemoveSeedRowsAsync(List report) + public async Task RemoveSeedRowsAsync(List report) { - List Deleted(WizardRecordTypeEnum type) => - [.. report.Where(i => i.RecordType == type && i.Status == WizardDeleteStatusEnum.Deleted).Select(i => i.Name)]; + List Removed(WizardRecordTypeEnum type) => + [.. report.Where(i => i.RecordType == type && i.Status is WizardDeleteStatusEnum.Deleted or WizardDeleteStatusEnum.NotFound).Select(i => i.Name)]; - await seedDataSynchronizer.RemoveRowsByFieldAsync(PlatformConsts.AppCodes.Menus.Menu, nameof(Menu.Code), Deleted(WizardRecordTypeEnum.Menu)); - await seedDataSynchronizer.RemoveRowsByFieldAsync(PlatformConsts.AppCodes.Menus.Module, nameof(Module.Id), Deleted(WizardRecordTypeEnum.Module)); - await seedDataSynchronizer.RemoveRowsByFieldAsync(SeedConsts.AbpIdentity.Permissions.Default, nameof(PermissionDefinitionRecord.Name), Deleted(WizardRecordTypeEnum.Permission)); - await seedDataSynchronizer.RemoveRowsByFieldAsync(SeedConsts.AbpIdentity.PermissionGroups.Default, nameof(PermissionGroupDefinitionRecord.Name), Deleted(WizardRecordTypeEnum.PermissionGroup)); + await seedDataSynchronizer.RemoveRowsByFieldAsync(PlatformConsts.AppCodes.Menus.Menu, nameof(Menu.Code), Removed(WizardRecordTypeEnum.Menu)); + await seedDataSynchronizer.RemoveRowsByFieldAsync(PlatformConsts.AppCodes.Menus.Module, nameof(Module.Id), Removed(WizardRecordTypeEnum.Module)); + await seedDataSynchronizer.RemoveRowsByFieldAsync(SeedConsts.AbpIdentity.Permissions.Default, nameof(PermissionDefinitionRecord.Name), Removed(WizardRecordTypeEnum.Permission)); + await seedDataSynchronizer.RemoveRowsByFieldAsync(SeedConsts.AbpIdentity.PermissionGroups.Default, nameof(PermissionGroupDefinitionRecord.Name), Removed(WizardRecordTypeEnum.PermissionGroup)); + await seedDataSynchronizer.RemoveRowsByFieldAsync(PlatformConsts.AppCodes.Languages.LanguageText, nameof(LanguageText.Key), Removed(WizardRecordTypeEnum.LanguageKey)); } /// Wizard'in kendi urettikleri disinda kalan, bos olmayan adaylar. @@ -618,25 +610,4 @@ public class WizardRecordCleaner( .Take(MaxUsedBy)], }); } - - private static void AddRedeploy(List report, WizardRecordTypeEnum type, string name) - { - report.Add(new WizardDeleteLogItemDto - { - RecordType = type, - Name = name, - Status = WizardDeleteStatusEnum.Kept, - Reason = WizardDeleteReasonEnum.Redeploy, - }); - } -} - -/// Wizard kayitlarinin hangi amacla silindigi. -public enum WizardDeleteMode -{ - /// Wizard Manager'dan silme: kullanilmayan her kayit gider. - Delete, - - /// Duzenleme: kayitlar hemen ardindan yeniden uretilir, paylasilan kaplar korunur. - Redeploy, } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs new file mode 100644 index 00000000..4ca220fe --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedRowStore.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Sozsoft.Platform.Data.Seeds; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Wizard'in yazdigi platform seed dosyalari: menu, modul, izin grubu, yetki ve dil metni. +/// +/// Deploy veritabanina yazmaz; ekranin menu/yetki/dil kayitlari bu dosyalara satir olarak girer ve +/// migrate onlari ListFormSeedDataApplier ile ekler. Dosyalar bir kez okunur, bellekte +/// degistirilir ve yalnizca degisenler yazilir. +/// +/// +public sealed class WizardSeedRowStore +{ + /// Dosyanin ekran kodu, satir anahtari ve dosya yoksa yazilacak uygulama sirasi. + private sealed record FileSpec(string ListFormCode, string KeyFieldName, int Order); + + public static readonly string MenuFile = PlatformConsts.AppCodes.Menus.Menu; + public static readonly string ModuleFile = PlatformConsts.AppCodes.Menus.Module; + public static readonly string PermissionGroupFile = SeedConsts.AbpIdentity.PermissionGroups.Default; + public static readonly string PermissionFile = SeedConsts.AbpIdentity.Permissions.Default; + public static readonly string LanguageTextFile = PlatformConsts.AppCodes.Languages.LanguageText; + + // Sira, kapsam klasorundeki mevcut dosyalarla aynidir: once tanim, sonra ona referans veren satir. + private static readonly FileSpec[] Specs = + [ + new(ModuleFile, "Id", 2), + new(MenuFile, "Code", 3), + new(PermissionGroupFile, "Name", 1), + new(PermissionFile, "Name", 2), + new(LanguageTextFile, "Id", 2), + ]; + + /// Wizard'in satir yazdigi ekran kodlari; import bu dosyalari veritabanina uygulamaz. + public static IReadOnlyList ListFormCodes { get; } = [.. Specs.Select(s => s.ListFormCode)]; + + private readonly Dictionary files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet dirty = new(StringComparer.OrdinalIgnoreCase); + + private WizardSeedRowStore() + { + } + + /// Kapsam klasorundeki dosyalari okur; olmayan dosya bos olarak baslar ve ilk yazimda olusur. + public static async Task LoadAsync(string scopeRoot) + { + var store = new WizardSeedRowStore(); + + foreach (var spec in Specs) + { + var path = Path.Combine(scopeRoot, SeedPathResolver.GetDataFilePath(spec.ListFormCode).Replace('/', Path.DirectorySeparatorChar)); + + var file = File.Exists(path) + ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) + : null; + + file ??= new ListFormSeedDataFileDto { ListFormCode = spec.ListFormCode, KeyFieldName = spec.KeyFieldName, Order = spec.Order }; + file.Rows ??= []; + + store.files[spec.ListFormCode] = (path, file); + } + + return store; + } + + public IReadOnlyList> Rows(string listFormCode) => files[listFormCode].File.Rows; + + public Dictionary? Find(string listFormCode, string field, string? value) => + string.IsNullOrWhiteSpace(value) + ? null + : Rows(listFormCode).FirstOrDefault(row => Matches(row, field, value)); + + public bool Contains(string listFormCode, string field, string? value) => Find(listFormCode, field, value) != null; + + /// + /// Satiri alanlarinin degerleriyle eslesen satirin yerine yazar, + /// yoksa sona ekler. Var olan satirin bu listede olmayan alanlari (kimlik gibi) korunur; dil + /// metni is anahtariyla eslesir, dosyadaki kimligi degismez. + /// + public void Upsert(string listFormCode, Dictionary row, params string[] matchFields) + { + var file = files[listFormCode].File; + var index = file.Rows.FindIndex(existing => matchFields.All(f => Matches(existing, f, Text(row, f)))); + + if (index < 0) + { + file.Rows.Add(row); + } + else + { + var merged = new Dictionary(file.Rows[index]); + foreach (var item in row) + { + // Satirin anahtari is anahtarindan ayriysa (dil metninin kimligi) dosyadaki deger kalir. + if (string.Equals(item.Key, file.KeyFieldName, StringComparison.OrdinalIgnoreCase) + && !matchFields.Contains(item.Key, StringComparer.OrdinalIgnoreCase) + && merged.ContainsKey(item.Key)) + { + continue; + } + + merged[item.Key] = item.Value; + } + + file.Rows[index] = merged; + } + + dirty.Add(listFormCode); + } + + /// + /// degeri icinde olan satirlari cikarir; + /// true donen satir birakilir. Cikarilan satirlarin alan degerlerini + /// dondurur. + /// + public List Remove(string listFormCode, string field, IEnumerable values, Func, bool>? keep = null) + { + var wanted = values.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var rows = files[listFormCode].File.Rows; + var removed = rows.Where(row => wanted.Contains(Text(row, field) ?? string.Empty) && keep?.Invoke(row) != true).ToList(); + + if (removed.Count == 0) + return []; + + rows.RemoveAll(removed.Contains); + dirty.Add(listFormCode); + + return [.. removed.Select(row => Text(row, field)!).Distinct(StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// Wizard'in sahip oldugu satirlari cikarir; baska bir yerde kullanilan satir kalir. Kullanim iki + /// yerden okunur: seed dosyalarinda listede olmayan bir satirin ona baglanmasi (alt menu, alt yetki, + /// menunun istedigi yetki, izin grubundaki yetki, modulu kullanan menu/yetki, dil anahtarini gorunen + /// ad olarak kullanan menu/yetki/grup) ve ile cagiranin verdigi + /// dis kullanim (veritabani). Siralama bagimlilik yonundedir — once kullanan, sonra kullanilan — + /// ve her kontrol o ana kadar kalan satirlara karsi yapilir. + /// + public List<(WizardRecordTypeEnum Type, string Name, bool Removed)> RemoveOwned( + WizardInsertedRecordsDto records, + Func? usedElsewhere = null) + { + static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + static bool In(List list, string? value) => list.Contains(value ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + var result = new List<(WizardRecordTypeEnum, string, bool)>(); + + void Apply(WizardRecordTypeEnum type, string listFormCode, string field, List names, Func, bool> usedInFiles) + { + var wanted = names.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var removed = Remove(listFormCode, field, wanted, row => + usedInFiles(row) || usedElsewhere?.Invoke(type, Text(row, field) ?? string.Empty) == true); + + foreach (var name in wanted.Where(n => Contains(listFormCode, field, n))) + result.Add((type, name, false)); + + result.AddRange(removed.Select(n => (type, n, true))); + } + + var menuCodes = records.MenuCodes ?? []; + var permissionNames = records.PermissionNames ?? []; + var groupNames = records.PermissionGroupNames ?? []; + + Apply(WizardRecordTypeEnum.Menu, MenuFile, "Code", menuCodes, menu => + Rows(MenuFile).Any(child => Same(Text(child, "ParentCode"), Text(menu, "Code")) && !In(menuCodes, Text(child, "Code")))); + + Apply(WizardRecordTypeEnum.Permission, PermissionFile, "Name", permissionNames, permission => + { + var name = Text(permission, "Name"); + return Rows(PermissionFile).Any(p => Same(Text(p, "ParentName"), name) && !In(permissionNames, Text(p, "Name"))) + || Rows(MenuFile).Any(m => Same(Text(m, "RequiredPermissionName"), name)); + }); + + Apply(WizardRecordTypeEnum.PermissionGroup, PermissionGroupFile, "Name", groupNames, group => + Rows(PermissionFile).Any(p => Same(Text(p, "GroupName"), Text(group, "Name")))); + + Apply(WizardRecordTypeEnum.Module, ModuleFile, "Id", records.ModuleIds ?? [], module => + { + var id = Text(module, "Id"); + return Rows(MenuFile).Any(m => Same(Text(m, "ModuleId"), id)) + || Rows(PermissionFile).Any(p => + (Text(p, PlatformConsts.Permissions.Modules) ?? string.Empty) + .Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains(id, StringComparer.OrdinalIgnoreCase)); + }); + + // Dil anahtari en son: kalan menu, yetki ya da grup satiri onu gorunen ad olarak gosteriyorsa kalir. + Apply(WizardRecordTypeEnum.LanguageKey, LanguageTextFile, "Key", records.LanguageKeys ?? [], text => + { + var key = Text(text, "Key"); + return Rows(MenuFile).Any(m => Same(Text(m, "DisplayName"), key)) + || Rows(PermissionFile).Any(p => Same(Text(p, "DisplayName"), key)) + || Rows(PermissionGroupFile).Any(g => Same(Text(g, "DisplayName"), key)); + }); + + return result; + } + + public async Task SaveAsync() + { + foreach (var listFormCode in dirty) + { + var (path, file) = files[listFormCode]; + file.GeneratedAt = DateTime.UtcNow; + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(file)); + } + + dirty.Clear(); + } + + public static string? Text(Dictionary? row, string field) => WizardExportCollector.GetRowText(row, field); + + public static int Int(Dictionary? row, string field) => + int.TryParse(Text(row, field), out var value) ? value : 0; + + private static bool Matches(Dictionary row, string field, string? value) => + string.Equals(Text(row, field), value, StringComparison.OrdinalIgnoreCase); +} diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedSynchronizer.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedSynchronizer.cs index 2d8a98c2..16ea56c9 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedSynchronizer.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardSeedSynchronizer.cs @@ -52,6 +52,14 @@ public class WizardSeedSynchronizer( if (seed == null) return; + // Bekleyen kurulumu olan dosya ekranin bir sonraki surumunu tasir; veritabanindaki (eski) + // ekrani dosyaya yazmak migrate edilmemis duzenlemeyi silerdi. + if (seed.Pending != null) + { + logger.LogInformation("Wizard seed file for '{ListFormCode}' is waiting for DB Migrate, designer changes were not written to it.", listFormCode); + return; + } + var listForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); if (listForm == null) return; diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs index 3907d7ff..766f3939 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data; using System.IO; using System.Linq; using System.Text.Json; @@ -8,86 +7,64 @@ using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Sozsoft.Languages.Entities; -using Sozsoft.Languages.Languages; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; -using Sozsoft.Platform.Extensions; using Sozsoft.Platform.ListForms; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; -using Volo.Abp.MultiTenancy; using Volo.Abp.PermissionManagement; -using static Sozsoft.Platform.PlatformConsts; namespace Sozsoft.Platform.Data.Seeds; /// -/// Wizard ile oluşturulan konfigürasyonları Seeds/{host|tenants/{tenantId}}/wizard/*.json -/// dosyalarından okuyarak veritabanına aktarır (kapsam, seed edilen veritabanını izler). -/// Wizard çalıştıktan sonra oluşturulan JSON dosyaları projeye commit edildikten sonra, -/// veritabanı silinip yeniden oluşturulduğunda bu seeder tüm wizard konfigürasyonlarını geri yükler. +/// Wizard ile uretilen ekranlari Seeds/{host|tenants/{tenantId}}/wizard/*.json dosyalarindan kurar +/// (kapsam, seed edilen veritabanini izler). +/// +/// Deploy veritabanina yazmaz; bu seeder wizard'in kurulumunun tek yeridir. Dosya yalnizca ekrana +/// ozgu kayitlari tasir: veri kaynagi, ListForm, alanlar ve is akisi. Yetkiler hicbir role atanmaz; +/// hangi rolun ekrani gorecegi rol ekranindan secilir. +/// Menu, modul, izin grubu, yetki ve dil metni satirlari platform seed dosyalarindadir +/// (data/*.json) ve bu seeder'dan sonra ListFormSeedDataApplier tarafindan eklenir. +/// +/// +/// Uygulama kurali: kurulu olmayan ekran kurulur, kurulu olana dokunulmaz. Bekleyen kurulumu olan +/// dosyada ( — deploy ya da duzenleme sonrasi) once wizard'in +/// onceki ve guncel kayitlari veritabanindan kaldirilir, sonra ekran yeniden kurulur ve bekleyen +/// bolum dosyadan silinir; kaldirilan satirlari applier dosyadaki guncel halleriyle geri ekler. +/// /// -public class WizardDataSeeder : IDataSeedContributor, ITransientDependency +public class WizardDataSeeder( + IRepository repoLangKey, + IRepository repoLangText, + IRepository repoPermGroup, + IRepository repoPerm, + IRepository repoMenu, + IRepository repoDataSource, + IRepository repoListForm, + IRepository repoListFormField, + IRepository repoListFormWorkflow, + IRepository repoPermGrant, + WizardInstallationChecker installationChecker, + IConfiguration configuration, + ILogger logger) : IDataSeedContributor, ITransientDependency { - private readonly IRepository _repoLangKey; - private readonly IRepository _repoLangText; - private readonly IRepository _repoPermGroup; - private readonly IRepository _repoPerm; - private readonly IRepository _repoMenu; - private readonly IRepository _repoModule; - private readonly IRepository _repoDataSource; - private readonly IRepository _repoListForm; - private readonly IRepository _repoListFormField; - private readonly IRepository _repoListFormWorkflow; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - private readonly string _cultureNameDefault = PlatformConsts.DefaultLanguage; private readonly string _appName = PlatformConsts.AppName; - public WizardDataSeeder( - IRepository repoLangKey, - IRepository repoLangText, - IRepository repoPermGroup, - IRepository repoPerm, - IRepository repoMenu, - IRepository repoModule, - IRepository repoDataSource, - IRepository repoListForm, - IRepository repoListFormField, - IRepository repoListFormWorkflow, - IConfiguration configuration, - ILogger logger) - { - _repoLangKey = repoLangKey; - _repoLangText = repoLangText; - _repoPermGroup = repoPermGroup; - _repoPerm = repoPerm; - _repoMenu = repoMenu; - _repoModule = repoModule; - _repoDataSource = repoDataSource; - _repoListForm = repoListForm; - _repoListFormField = repoListFormField; - _repoListFormWorkflow = repoListFormWorkflow; - _configuration = configuration; - _logger = logger; - } - public async Task SeedAsync(DataSeedContext context) { var scopeFolderName = SeedPathResolver.GetScopeFolderName(context.TenantId); - var wizardDataPath = SeedPathResolver.GetScopePath(_configuration, context.TenantId, SeedPathResolver.WizardFolder); + var wizardDataPath = SeedPathResolver.GetScopePath(configuration, context.TenantId, SeedPathResolver.WizardFolder); if (!Directory.Exists(wizardDataPath)) { - _logger.LogInformation("Seeds/{Scope}/wizard directory not found, skipping.", scopeFolderName); + logger.LogInformation("Seeds/{Scope}/wizard directory not found, skipping.", scopeFolderName); return; } var jsonFiles = Directory.GetFiles(wizardDataPath, "*.json"); if (jsonFiles.Length == 0) { - _logger.LogInformation("No JSON files found in Seeds/{Scope}/wizard directory, skipping.", scopeFolderName); + logger.LogInformation("No JSON files found in Seeds/{Scope}/wizard directory, skipping.", scopeFolderName); return; } @@ -101,7 +78,7 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath)); if (seed?.Wizard == null) { - _logger.LogWarning("Invalid file skipped: {FilePath}", filePath); + logger.LogWarning("Invalid file skipped: {FilePath}", filePath); continue; } @@ -109,11 +86,11 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency } catch (Exception ex) { - _logger.LogError(ex, $"Error - {filePath}: {ex.Message}"); + logger.LogError(ex, "Error - {FilePath}: {Message}", filePath, ex.Message); } } - _logger.LogInformation("WizardDataSeeder started. {Count} files to be processed.", seedFiles.Count); + logger.LogInformation("WizardDataSeeder started. {Count} files to be processed.", seedFiles.Count); foreach (var (filePath, seedFile) in seedFiles .OrderBy(x => x.Seed.Wizard.SeededAt) @@ -122,269 +99,100 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency var fileName = Path.GetFileName(filePath); try { - var wizardName = seedFile.Wizard.WizardName?.Trim(); - if (string.IsNullOrWhiteSpace(wizardName)) + var wizard = seedFile.Wizard; + if (string.IsNullOrWhiteSpace(wizard.WizardName)) { - _logger.LogWarning("WizardName is empty, skipped: {FilePath}", filePath); + logger.LogWarning("WizardName is empty, skipped: {FilePath}", filePath); continue; } - var isCustomComponent = seedFile.Wizard.ComponentKind == WizardComponentKindEnum.Custom; + var isCustomComponent = wizard.ComponentKind == WizardComponentKindEnum.Custom; if (!isCustomComponent && seedFile.ListForm == null) { - _logger.LogWarning("[{File}] ListForm section is missing, skipped.", fileName); + logger.LogWarning("[{File}] ListForm section is missing, skipped.", fileName); continue; } - await BackfillMenuParentModuleIdAsync(seedFile.Wizard); - - // Zaten seeded mi kontrol et. Custom yolunda ListForm üretilmediği için - // idempotency menü kaydı üzerinden değerlendirilir. - // Menusuz Custom wizard'da ne ListForm ne de menu kaydi olustugu icin - // idempotency okuma permission'i uzerinden degerlendirilir. - var alreadySeeded = isCustomComponent - ? seedFile.Wizard.CreateMenu - ? await _repoMenu.AnyAsync(a => a.Code == seedFile.Wizard.ResolveMenuCode()) - : await _repoPerm.AnyAsync(a => a.Name == seedFile.Wizard.ResolveMenuCode()) - : await _repoListForm.AnyAsync(a => a.ListFormCode == seedFile.Wizard.ListFormCode); - - if (alreadySeeded) + if (seedFile.Pending != null) { - // Ekran kurulu olsa da dil metinleri eksik kalmis olabilir: seed dosyasina yeni - // bir alan eklendiginde ya da ekran seeder disinda olustugunda sutun basliklari - // ham anahtar olarak gorunur. Mevcut metinlerin uzerine yazilmadigi icin bu - // tamamlama ekrani degistirmez. - await SeedLanguageTextsAsync(seedFile.Wizard, isCustomComponent); - _logger.LogInformation("[{File}] '{WizardName}' already exists, only missing language texts were created.", fileName, wizardName); + logger.LogInformation("[{File}] '{WizardName}' has a pending deploy, previous records are being replaced...", fileName, wizard.WizardName); + await RemovePreviousRecordsAsync(seedFile); + } + else if (await installationChecker.IsInstalledAsync(isCustomComponent, wizard.ResolveMenuCode(), wizard.ResolveListFormCode())) + { + logger.LogInformation("[{File}] '{WizardName}' already installed, skipped.", fileName, wizard.WizardName); continue; } - _logger.LogInformation("[{File}] '{WizardName}' is being applied...", fileName, wizardName); - await ApplyWizardSeedAsync(seedFile); - _logger.LogInformation("[{File}] '{WizardName}' has been successfully applied.", fileName, wizardName); + await InstallAsync(seedFile); + + if (seedFile.Pending != null) + { + seedFile.Pending = null; + await File.WriteAllTextAsync(filePath, seedFile.ToSeedJson()); + } + + logger.LogInformation("[{File}] '{WizardName}' has been successfully applied.", fileName, wizard.WizardName); } catch (Exception ex) { - _logger.LogError(ex, $"Error - {filePath}: {ex.Message}"); + logger.LogError(ex, "Error - {FilePath}: {Message}", filePath, ex.Message); } } } - private async Task ApplyWizardSeedAsync(WizardSeedFileDto seedFile) + /// + /// Ekrana ozgu kayitlari kurar: veri kaynagi (yoksa), ListForm, alanlar ve is akisi dosyadaki + /// sozlesmeden. Menu/yetki/dil satirlari ListFormSeedDataApplier'a kalir. + /// + private async Task InstallAsync(WizardSeedFileDto seedFile) { var input = seedFile.Wizard; - input.Groups ??= []; - - var code = input.ResolveMenuCode(); - var listFormCode = input.ResolveListFormCode(); - var nameLangKey = code; - var permCreateName = $"{code}.Create"; - var permUpdateName = $"{code}.Update"; - var permDeleteName = $"{code}.Delete"; - var permExportName = $"{code}.Export"; - var permImportName = $"{code}.Import"; - var permNoteName = $"{code}.Note"; - - // Yol ayrımı: Custom yolunda ListForm ekosistemi üretilmez (AppService.Create ile aynı davranış). - var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom; - var customRoutePath = NormalizeSeedCustomRoutePath(input); - - // Dil - Language Keys - await SeedLanguageTextsAsync(input, isCustomComponent); - - // Permission Group - gorunen adin dil anahtari SeedLanguageTextsAsync icinde uretilir. - var groupName = ResolvePermissionGroupName(input); - input.PermissionGroupName = groupName; - if (!await _repoPermGroup.AnyAsync(a => a.Name == groupName)) - { - var group = new PermissionGroupDefinitionRecord(Guid.NewGuid(), groupName, groupName); - group.SetPermissionOrder((await _repoPermGroup.GetListAsync()).NextPermissionOrder()); - await _repoPermGroup.InsertAsync(group, autoSave: true); - } - - // Permissions - tek seferde mevcut permission'ları çek, sonra her birini kontrol et - var permQueryable = await _repoPerm.GetQueryableAsync(); - var existingPerms = permQueryable.Where(a => a.GroupName == groupName).ToList(); - - // Yetkiler menuyle ayni modul filtresine tabidir; modul bos kalirsa rol ekraninda - // modul secimiyle filtrelenen listede gorunmez. - var permissionModules = input.MenuParentModuleId?.Trim(); - - var permRead = existingPerms.FirstOrDefault(a => a.Name == code); - permRead ??= await InsertPermissionAsync(groupName, code, null, nameLangKey, permissionModules, - existingPerms.Where(p => string.IsNullOrWhiteSpace(p.ParentName)).NextPermissionOrder()); - - // CRUD üçlüsü her iki yolda da üretilir: Custom component de Form - // üzerinden kayıt ekler, günceller ve siler (AppService.Create ile aynı davranış). - // Alt yetkilerin sirasi kardesler arasindadir ve bu dizilimi izler (Create = 1, ...). - if (!existingPerms.Any(a => a.Name == permCreateName)) - await InsertPermissionAsync(groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, permissionModules, 1); - - if (!existingPerms.Any(a => a.Name == permUpdateName)) - await InsertPermissionAsync(groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, permissionModules, 2); - - if (!existingPerms.Any(a => a.Name == permDeleteName)) - await InsertPermissionAsync(groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, permissionModules, 3); - - // Export/Import/Note yalnızca ListForm ekosisteminde karşılığı olan işlemlerdir. - if (!isCustomComponent) - { - if (!existingPerms.Any(a => a.Name == permExportName)) - await InsertPermissionAsync(groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, permissionModules, 4); - - if (!existingPerms.Any(a => a.Name == permImportName)) - await InsertPermissionAsync(groupName, permImportName, permRead.Name, WizardConsts.LangKeyImport, permissionModules, 5); - - if (!existingPerms.Any(a => a.Name == permNoteName)) - await InsertPermissionAsync(groupName, permNoteName, permRead.Name, WizardConsts.LangKeyNote, permissionModules, 6); - } - - // // Permission Grants - Admin role için, sadece eksik olanları ekle - // var existingGrants = await _permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName); - // var existingGrantNames = new HashSet(existingGrants.Select(g => g.Name)); - - // var grantsToInsert = new[] - // { - // permRead.Name, permCreate.Name, permUpdate.Name, - // permDelete.Name, permExport.Name, permImport.Name, permNote.Name - // } - // .Where(name => !existingGrantNames.Contains(name)) - // .Select(name => new PermissionGrant(Guid.NewGuid(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName)) - // .ToList(); - - // if (grantsToInsert.Count > 0) - // { - // await _permissionGrantRepository.InsertManyAsync(grantsToInsert, autoSave: true); - // } - - // Menunun acacagi adres; menu uretilmese de seed dosyasindaki degeri korunur. - input.MenuUrl = isCustomComponent - ? customRoutePath - : !string.IsNullOrWhiteSpace(input.MenuUrl) - ? input.MenuUrl.Trim() - : WizardConsts.MenuUrl(code); - - // Menusuz wizard: menu (ve ust menu) kaydi hic uretilmez. - if (input.CreateMenu) - { - // Menu Parent - await EnsureModuleAsync(input.MenuParentModuleId); - - var menuQueryable = await _repoMenu.GetQueryableAsync(); - var menuParent = await _repoMenu.FirstOrDefaultAsync(a => a.Code == input.MenuParentCode); - if (menuParent == null) - { - var maxRootOrder = menuQueryable.Where(a => a.ParentCode == null || a.ParentCode == "").Select(a => (int?)a.Order).Max() ?? 0; - var menuParentIcon = !string.IsNullOrWhiteSpace(input.MenuParentIcon) - ? input.MenuParentIcon - : !string.IsNullOrWhiteSpace(input.MenuIcon) - ? input.MenuIcon - : WizardConsts.MenuIcon; - menuParent = await _repoMenu.InsertAsync(new Menu - { - Code = input.MenuParentCode, - DisplayName = input.MenuParentCode, - IsDisabled = false, - ModuleId = input.MenuParentModuleId, - Icon = menuParentIcon, - Order = maxRootOrder + 1, - }, autoSave: true); - } - - // Menu - var maxChildOrder = menuQueryable.Where(a => a.ParentCode == menuParent.Code).Select(a => (int?)a.Order).Max() ?? 0; - var menuOrder = input.MenuOrder > 0 ? input.MenuOrder : maxChildOrder + 1; - input.MenuOrder = menuOrder; - var menuUrl = input.MenuUrl; - var existingMenu = await _repoMenu.FirstOrDefaultAsync(a => a.Code == code); - if (existingMenu == null) - { - await _repoMenu.InsertAsync(new Menu - { - Code = code, - DisplayName = nameLangKey, - IsDisabled = false, - ParentCode = menuParent.Code, - Icon = input.MenuIcon ?? WizardConsts.MenuIcon, - Target = null, - ElementId = null, - CssClass = null, - Url = menuUrl, - RequiredPermissionName = permRead.Name, - Order = menuOrder, - ModuleId = menuParent.ModuleId, - }, autoSave: true); - } - else if (existingMenu.Order != menuOrder || existingMenu.Url != menuUrl) - { - existingMenu.Order = menuOrder; - existingMenu.Url = menuUrl; - await _repoMenu.UpdateAsync(existingMenu, autoSave: true); - } - } // Custom yolunda ListForm ekosistemi üretilmez. - if (isCustomComponent) + if (input.ComponentKind == WizardComponentKindEnum.Custom) return; - // DataSource - var existingDataSource = await _repoDataSource.FirstOrDefaultAsync(a => a.Code == input.DataSourceCode); - if (existingDataSource == null) + var listFormCode = input.ResolveListFormCode(); + + if (!await repoDataSource.AnyAsync(a => a.Code == input.DataSourceCode)) { - await _repoDataSource.InsertAsync(new DataSource + await repoDataSource.InsertAsync(new DataSource { Code = input.DataSourceCode, - DataSourceType = input.DataSourceConnectionString != null && - input.DataSourceConnectionString.IndexOf("Server", StringComparison.OrdinalIgnoreCase) >= 0 + DataSourceType = input.DataSourceConnectionString?.Contains("Server", StringComparison.OrdinalIgnoreCase) == true ? DataSourceTypeEnum.Mssql : DataSourceTypeEnum.Postgresql, ConnectionString = input.DataSourceConnectionString }, autoSave: true); } - // ListForm ve alanlari - varsa sil, seed dosyasindaki sozlesmeden yeniden kur - var existingListForm = await _repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == input.ListFormCode); - if (existingListForm != null) - { - await _repoListForm.DeleteAsync(existingListForm, autoSave: true); - } - - var existingListFormFields = await _repoListFormField.GetListAsync(a => a.ListFormCode == input.ListFormCode); - if (existingListFormFields.Count > 0) - { - await _repoListFormField.DeleteManyAsync(existingListFormFields, autoSave: true); - } - - var existingWorkflowCriteria = await _repoListFormWorkflow.GetListAsync(a => a.ListFormCode == input.ListFormCode); - if (existingWorkflowCriteria.Count > 0) - { - await _repoListFormWorkflow.DeleteManyAsync(existingWorkflowCriteria, autoSave: true); - } + await DeleteListFormAsync(listFormCode); // Ekran, seed dosyasinin tasidigi ListForm sozlesmesinden kurulur; boylece wizard // sonrasinda tasarimci uzerinde yapilan degisiklikler de geri gelir. var listForm = new ListForm(); seedFile.ListForm.ApplyTo(listForm); - await _repoListForm.InsertAsync(listForm, autoSave: true); + await repoListForm.InsertAsync(listForm, autoSave: true); foreach (var fieldDto in seedFile.Fields) { var field = new ListFormField(); fieldDto.ApplyTo(field); - await _repoListFormField.InsertAsync(field, autoSave: true); + await repoListFormField.InsertAsync(field, autoSave: true); } foreach (var criteria in seedFile.ListForm.WorkflowDto.Criteria) { if (string.IsNullOrWhiteSpace(criteria.Id)) { - _logger.LogWarning("Workflow criteria skipped because Id is empty. ListFormCode: {ListFormCode}, Title: {Title}", input.ListFormCode, criteria.Title); + logger.LogWarning("Workflow criteria skipped because Id is empty. ListFormCode: {ListFormCode}, Title: {Title}", listFormCode, criteria.Title); continue; } - await _repoListFormWorkflow.InsertAsync(new ListFormWorkflow(criteria.Id) + await repoListFormWorkflow.InsertAsync(new ListFormWorkflow(criteria.Id) { - ListFormCode = string.IsNullOrWhiteSpace(criteria.ListFormCode) ? input.ListFormCode : criteria.ListFormCode, + ListFormCode = string.IsNullOrWhiteSpace(criteria.ListFormCode) ? listFormCode : criteria.ListFormCode, Kind = criteria.Kind, Title = criteria.Title, CompareColumn = criteria.CompareColumn, @@ -404,160 +212,89 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency } /// - /// Custom yolunda menunun acacagi rotayi normalize eder. Rota MenuUrl uzerinden - /// tasinir; bos ise menu hicbir yere gitmeyecegi icin seed dosyasi hatali kabul edilir. + /// Bekleyen deploy'dan once wizard'in veritabanindaki kayitlarini kaldirir: onceki surumlerin + /// () ve guncel surumun sahip oldugu menu, yetki, izin grubu + /// ve dil anahtarlari ile onceki ekran kodlari. Guncel satirlar dosyada durdugu icin applier onlari + /// yeni degerleriyle geri ekler; dosyada artik olmayanlar kalici olarak gider. Boylece migrate'in + /// "yalnizca eksik olani ekle" kurali duzenlenmis bir ekrani da guncel haline getirir. + /// + /// Kaplar korunur: baska bir kaydin hala bagli oldugu ust menu ve izin grubu silinmez. Modul ve veri + /// kaynagi kaldirilmaz; ikisi de baska ekranlarca paylasilabilir ve kalmalari zararsizdir. Rollerin + /// yetki atamalari yetki adina baglidir; yetki geri eklendiginde atamalar yerindedir, yalnizca + /// kalici olarak giden yetkinin atamalari silinir. + /// /// - private static string NormalizeSeedCustomRoutePath(ListFormWizardDto input) + private async Task RemovePreviousRecordsAsync(WizardSeedFileDto seedFile) { - if (input.ComponentKind != WizardComponentKindEnum.Custom) - return null; + var pending = seedFile.Pending!; + var owned = (seedFile.InsertedRecords ?? new WizardInsertedRecordsDto()).Union(pending.Replaced); + var currentPermissions = WizardSeedRowKeys.PermissionNames(seedFile.Wizard).ToHashSet(StringComparer.OrdinalIgnoreCase); - var routePath = input.MenuUrl?.Trim(); - if (string.IsNullOrWhiteSpace(routePath)) - throw new InvalidOperationException( - $"'{input.WizardName}' custom component wizard'inda Route Path bos olamaz."); + foreach (var listFormCode in pending.ListFormCodes ?? []) + await DeleteListFormAsync(listFormCode); - return routePath.StartsWith('/') ? routePath : "/" + routePath; - } - - /// - /// Seed dosyasindaki modul kaydi yoksa olusturur; menunun ModuleId yabanci anahtari - /// tanimsiz bir module bagli kalmasin diye menu yazilmadan once cagrilir. - /// - private async Task EnsureModuleAsync(string moduleId) - { - var cleaned = moduleId?.Trim(); - if (string.IsNullOrWhiteSpace(cleaned)) - return; - - if (await _repoModule.AnyAsync(a => a.Id == cleaned)) - return; - - await _repoModule.InsertAsync(new Module(cleaned) { Name = cleaned }, autoSave: true); - _logger.LogInformation("Module '{ModuleId}' created.", cleaned); - } - - private async Task InsertPermissionAsync( - string groupName, - string name, - string parentName, - string displayName, - string modules, - int order) - { - var permission = new PermissionDefinitionRecord( - Guid.NewGuid(), groupName, name, parentName, displayName, true, MultiTenancySides.Both); - permission.SetModules(modules); - permission.SetPermissionOrder(order); - - return await _repoPerm.InsertAsync(permission, autoSave: true); - } - - private async Task BackfillMenuParentModuleIdAsync(ListFormWizardDto input) - { - if (string.IsNullOrWhiteSpace(input.MenuParentCode) || - string.IsNullOrWhiteSpace(input.MenuParentModuleId)) - return; - - await EnsureModuleAsync(input.MenuParentModuleId); - - var menuParent = await _repoMenu.FirstOrDefaultAsync(a => a.Code == input.MenuParentCode); - if (menuParent == null || !string.IsNullOrWhiteSpace(menuParent.ModuleId)) - return; - - menuParent.ModuleId = input.MenuParentModuleId.Trim(); - await _repoMenu.UpdateAsync(menuParent, autoSave: true); - _logger.LogInformation( - "Menu parent '{MenuParentCode}' ModuleId backfilled as '{ModuleId}'.", - input.MenuParentCode, - menuParent.ModuleId); - } - - - /// - /// Wizard'in urettigi ekranin dil metinleri: menu adi, ust menu adi, izin grubu gorunen adi, - /// ekran basligi/aciklamasi ve alan basliklari. Ekran kurulumundan bagimsiz calisir; boylece - /// zaten kurulu bir ekranda eksik kalan anahtarlar da her seed calismasinda tamamlanir. - /// - private async Task SeedLanguageTextsAsync(ListFormWizardDto input, bool isCustomComponent) - { - await CreateLangKeyAsync(input.ResolveMenuCode(), input.MenuEn, input.MenuTr); - - // Ust menu ve izin grubu paylasilan kayitlardir: ilk wizard onlari uretmis olsa bile dil - // metinleri eksik kalabilir, bu yuzden menu/grup kaydinin varligindan bagimsiz yazilir. - if (input.CreateMenu) - await CreateLangKeyAsync(input.MenuParentCode, input.MenuParentEn, input.MenuParentTr); - - var groupName = ResolvePermissionGroupName(input); - - // Grup adi ust menu koduyla ayniysa anahtar tektir; menu adi ile izin grubu adi ayni - // metni gosterir, ayrica gorunen ad girilmediyse menu metinlerine duser. - if (!string.Equals(groupName, input.MenuParentCode, StringComparison.OrdinalIgnoreCase)) + var menuCodes = owned.MenuCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + var menus = await repoMenu.GetListAsync(a => menuCodes.Contains(a.Code)); + foreach (var menu in menus) { - await CreateLangKeyAsync(groupName, input.PermissionGroupEn?.Trim(), input.PermissionGroupTr?.Trim()); - } - else - { - await CreateLangKeyAsync(groupName, - input.PermissionGroupEn?.Trim() ?? input.MenuParentEn, - input.PermissionGroupTr?.Trim() ?? input.MenuParentTr); + // Wizard disindan bir alt menusu olan ust menu kalir; aksi halde alt menu sahipsiz kalirdi. + if (await repoMenu.AnyAsync(a => a.ParentCode == menu.Code && !menuCodes.Contains(a.Code))) + continue; + + await repoMenu.DeleteAsync(menu, autoSave: true); } - // Custom yolunda ListForm ekosistemi (baslik, aciklama, alan basliklari) uretilmez. - if (isCustomComponent) - return; + var permissionNames = owned.PermissionNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + var permissions = await repoPerm.GetListAsync(a => permissionNames.Contains(a.Name)); + if (permissions.Count > 0) + await repoPerm.DeleteManyAsync(permissions, autoSave: true); - var listFormCode = input.ResolveListFormCode(); - await CreateLangKeyAsync($"{listFormCode}.Title", input.TitleEn, input.TitleTr); - await CreateLangKeyAsync($"{listFormCode}.Desc", input.DescEn, input.DescTr); - - // Alan basliklarinin iki dilli metinleri yalnizca wizard bolumunde tasinir. - foreach (var item in (input.Groups ?? []).SelectMany(g => g.Items ?? [])) + foreach (var name in permissionNames.Where(n => !currentPermissions.Contains(n))) { - await CreateLangKeyAsync(item.CaptionName, item.EnglishCaption, item.TurkishCaption); + var grants = await repoPermGrant.GetListAsync(a => a.Name == name); + if (grants.Count > 0) + await repoPermGrant.DeleteManyAsync(grants, autoSave: true); + } + + foreach (var groupName in owned.PermissionGroupNames) + { + if (await repoPerm.AnyAsync(a => a.GroupName == groupName)) + continue; + + var group = await repoPermGroup.FirstOrDefaultAsync(a => a.Name == groupName); + if (group != null) + await repoPermGroup.DeleteAsync(group, autoSave: true); + } + + foreach (var key in owned.LanguageKeys) + { + var texts = await repoLangText.GetListAsync(a => a.ResourceName == _appName && a.Key == key); + if (texts.Count > 0) + await repoLangText.DeleteManyAsync(texts, autoSave: true); + + var languageKey = await repoLangKey.FirstOrDefaultAsync(a => a.ResourceName == _appName && a.Key == key); + if (languageKey != null) + await repoLangKey.DeleteAsync(languageKey, autoSave: true); } } /// - /// Yetkilerin toplanacagi izin grubu adi. Menuye eklenmeyen wizard'da grup sorulmaz; bos - /// geldiginde varsayilan uygulama grubuna duser. + /// Ekrani, alanlarini ve is akisini siler. Alanlar ve is akisi ListForm'a zorunlu (cascade) + /// anahtarla baglidir; ekran once silinirse EF izlenen bagimlilari da siler ve ardindan gelen + /// toplu silme concurrency hatasi firlatir. Bu yuzden bagimlilar once silinir. /// - private static string ResolvePermissionGroupName(ListFormWizardDto input) => - string.IsNullOrWhiteSpace(input.PermissionGroupName) ? AppName : input.PermissionGroupName.Trim(); - - private async Task CreateLangKeyAsync(string key, string textEn, string textTr) + private async Task DeleteListFormAsync(string listFormCode) { - if (string.IsNullOrWhiteSpace(key)) return; + var fields = await repoListFormField.GetListAsync(a => a.ListFormCode == listFormCode); + if (fields.Count > 0) + await repoListFormField.DeleteManyAsync(fields, autoSave: true); - var langKey = await _repoLangKey.FirstOrDefaultAsync(a => a.ResourceName == _appName && a.Key == key) - ?? await _repoLangKey.InsertAsync(new LanguageKey { ResourceName = _appName, Key = key }, autoSave: true); + var criteria = await repoListFormWorkflow.GetListAsync(a => a.ListFormCode == listFormCode); + if (criteria.Count > 0) + await repoListFormWorkflow.DeleteManyAsync(criteria, autoSave: true); - var existingTexts = await _repoLangText.GetListAsync(a => a.ResourceName == _appName && a.Key == langKey.Key); - - var existingEn = existingTexts.FirstOrDefault(a => a.CultureName == _cultureNameDefault); - if (existingEn == null) - { - await _repoLangText.InsertAsync(new LanguageText - { - ResourceName = _appName, - Key = langKey.Key, - CultureName = _cultureNameDefault, - Value = textEn ?? key - }, autoSave: true); - } - - var existingTr = existingTexts.FirstOrDefault(a => a.CultureName == LanguageCodes.Tr); - if (existingTr == null) - { - await _repoLangText.InsertAsync(new LanguageText - { - ResourceName = _appName, - Key = langKey.Key, - CultureName = LanguageCodes.Tr, - Value = textTr ?? key - }, autoSave: true); - } + var listForm = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode); + if (listForm != null) + await repoListForm.DeleteAsync(listForm, autoSave: true); } } - - diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardRecordTypeEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardRecordTypeEnum.cs index 5def9f34..0f0af037 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardRecordTypeEnum.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardRecordTypeEnum.cs @@ -1,4 +1,4 @@ -namespace Sozsoft.Platform.Enums; +namespace Sozsoft.Platform.Enums; /// /// Wizard silme raporundaki kayit turleri. Ilk grup wizard'in urettigi kayitlardir; ikinci grup @@ -37,7 +37,7 @@ public enum WizardDeleteStatusEnum { Deleted = 0, - /// Kayit baska bir yerde kullanildigi ya da yeniden deploy edilecegi icin birakildi. + /// Kayit baska bir yerde kullanildigi icin birakildi. Kept = 1, /// Seed dosyasinda izi var ama veritabaninda yok; daha once elle silinmis olabilir. @@ -52,9 +52,6 @@ public enum WizardDeleteReasonEnum /// Baska bir kayit bu kayda baglidir; kullananlar raporda listelenir. InUse = 1, - /// Duzenleme yolunda paylasilan kayit korunur; deploy onu yeniden kullanir. - Redeploy = 2, - /// Tablo silme onayinda secilmedi; tablo ve ona bagli seed dosyalari yerinde kalir. NotSelected = 3, diff --git a/api/src/Sozsoft.Platform.Domain.Shared/SeedJsonText.cs b/api/src/Sozsoft.Platform.Domain.Shared/SeedJsonText.cs new file mode 100644 index 00000000..96dd6390 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain.Shared/SeedJsonText.cs @@ -0,0 +1,29 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Sozsoft.Platform; + +/// +/// Seed dosyalarina yazilan JSON metninin son hali. UnsafeRelaxedJsonEscaping Turkce harfleri +/// ve BMP'deki karakterleri oldugu gibi birakir ama BMP disindaki karakterleri (emoji) her zaman +/// 🔥 bicimine kacirir. Dosya bastan yazildiginda elle girilmis her emoji bu bicime +/// doner ve diff'te sahte fark uretir; bu yuzden vekil ciftleri gercek karakterlerine geri cevrilir. +/// +public static partial class SeedJsonText +{ + // Onunde tek sayida ters bolu varsa kacis gercektir; cift sayidaysa metnin kendisidir ("\\uD83D"). + [GeneratedRegex(@"(? + m.Groups[1].Value + + (char)int.Parse(m.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture) + + (char)int.Parse(m.Groups[3].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } +} diff --git a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs index 5bcc25a0..35b4d1ba 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs @@ -12,16 +12,7 @@ public static class WizardConsts public static int EditFormHeightByRow => 75; public static string WizardKey(string code) => $"{Prefix.App}.Wizard.{code}"; - public static string WizardKeyTitle(string code) => $"{WizardKey(code)}.Title"; - public static string WizardKeyDesc(string code) => $"{WizardKey(code)}.Desc"; - public static string WizardKeyParent(string code) => $"{WizardKey(code)}.Parent"; - public static string PermCreate(string code) => $"{WizardKey(code)}.Create"; - public static string PermUpdate(string code) => $"{WizardKey(code)}.Update"; - public static string PermDelete(string code) => $"{WizardKey(code)}.Delete"; - public static string PermExport(string code) => $"{WizardKey(code)}.Export"; - public static string PermImport(string code) => $"{WizardKey(code)}.Import"; - public static string PermNote(string code) => $"{WizardKey(code)}.Note"; public static string LangKeyCreate => "App.Platform.Create"; public static string LangKeyUpdate => "App.Platform.Update"; public static string LangKeyDelete => "App.Platform.Delete"; diff --git a/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataFile.cs b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataFile.cs index 0e5acd75..7b9460dd 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataFile.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/ListFormSeedDataFile.cs @@ -42,4 +42,12 @@ public class ListFormSeedDataFileDto DefaultIgnoreCondition = JsonIgnoreCondition.Never, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + /// + /// Dosyaya yazilacak metin. Seed dosyasi yazan her yol bunu kullanir; emoji gibi karakterler + /// kacirilmadan yazilir () ve dosya depodaki digerleri gibi satir sonuyla + /// biter — dosya bastan yazildiginda sahte fark olusmaz. + /// + public static string Serialize(ListFormSeedDataFileDto file) => + SeedJsonText.RestoreSurrogatePairs(JsonSerializer.Serialize(file, JsonOptions)) + "\n"; } diff --git a/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs b/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs index fa855036..7310a178 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs @@ -55,10 +55,7 @@ public class PlatformDbMigrationService : ITransientDependency var seed = _configuration.GetValue("Seed", false); Logger.LogInformation($"Migrating database. Seed: {seed}. Env: {Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")}"); - //Clear Cache - var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!); - var db = redis.GetDatabase(); - db.Execute("FLUSHALL"); + ClearCache(); var initialMigrationAdded = AddInitialMigrationIfNotExist(); @@ -110,9 +107,20 @@ public class PlatformDbMigrationService : ITransientDependency await ExecuteFinalSeedersAsync(tenants); + // Seed; menu, yetki ve dil metni ekler. Calisan uygulama bu arada eski halleri onbellege + // almis olabilir (ornegin wizard'in yeni menusu); migrate sonrasinda da temizlenir. + if (seed) + ClearCache(); + Logger.LogInformation("You can safely end this process..."); } + private void ClearCache() + { + using var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!); + redis.GetDatabase().Execute("FLUSHALL"); + } + /// /// Tüm host ve tenant migration/seed adımları bittikten sonra, /// {host|tenants/{tenantId}}/{sql|postgres}/execute klasöründeki script'lerin dosya adından diff --git a/api/src/Sozsoft.Platform.Domain/Data/SeedRowIds.cs b/api/src/Sozsoft.Platform.Domain/Data/SeedRowIds.cs new file mode 100644 index 00000000..6bffc231 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/SeedRowIds.cs @@ -0,0 +1,42 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Seed dosyasina yazilan Guid anahtarli satirlarin kimligi. Kimlik is anahtarindan +/// turetilir (URL isim alanli, RFC 4122 surum 5); ayni satir her ortamda ve her uretimde ayni +/// kimligi alir, dosyada sahte fark ya da kopya satir olusmaz. +/// +public static class SeedRowIds +{ + /// RFC 4122 URL isim alani (6ba7b811-9dad-11d1-80b4-00c04fd430c8). + private static readonly byte[] UrlNamespace = + [ + 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 + ]; + + /// + /// Dil metni satirinin kimligi: {Tablo}:{ResourceName}:{Key}:{CultureName} adindan uretilir. + /// + public static Guid LanguageText(string resourceName, string key, string cultureName) => + NameBased($"{TableNameResolver.GetFullTableName(nameof(TableNameEnum.LanguageText))}:{resourceName}:{key}:{cultureName}"); + + private static Guid NameBased(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name); + var input = new byte[UrlNamespace.Length + nameBytes.Length]; + UrlNamespace.CopyTo(input, 0); + nameBytes.CopyTo(input, UrlNamespace.Length); + + var hash = SHA1.HashData(input); + + // Surum (5) ve varyant (RFC 4122) bitleri. + hash[6] = (byte)((hash[6] & 0x0F) | 0x50); + hash[8] = (byte)((hash[8] & 0x3F) | 0x80); + + return new Guid(hash.AsSpan(0, 16), bigEndian: true); + } +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/WizardInstallationChecker.cs b/api/src/Sozsoft.Platform.Domain/Data/WizardInstallationChecker.cs new file mode 100644 index 00000000..e8c7f0c1 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/WizardInstallationChecker.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Sozsoft.Platform.Entities; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.PermissionManagement; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Bir wizard ekraninin bu veritabaninda kurulu olup olmadigi: List yolunda ListForm kaydi, Custom +/// yolunda (ListForm uretilmedigi icin) okuma yetkisi. Migrate'in "kur ya da atla" karari +/// (WizardDataSeeder) ile Wizard Manager'daki "migrate bekliyor" durumu ayni kuraldir; tek +/// yerde durur. +/// +public class WizardInstallationChecker( + IRepository repoListForm, + IRepository repoPerm) : ITransientDependency +{ + public async Task IsInstalledAsync(bool isCustom, string menuCode, string listFormCode) => + isCustom + ? await repoPerm.AnyAsync(a => a.Name == menuCode) + : await repoListForm.AnyAsync(a => a.ListFormCode == listFormCode); + + /// + /// Birden fazla wizard icin ayni karar; iki sorguyla verilir. Anahtar, List yolunda ekran kodu, + /// Custom yolunda menu kodudur (buyuk/kucuk harf duyarsiz). + /// + public async Task> GetInstalledAsync(IReadOnlyCollection listFormCodes, IReadOnlyCollection menuCodes) + { + var installed = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (listFormCodes.Count > 0) + installed.UnionWith((await repoListForm.GetListAsync(a => listFormCodes.Contains(a.ListFormCode))).Select(a => a.ListFormCode)); + + if (menuCodes.Count > 0) + installed.UnionWith((await repoPerm.GetListAsync(a => menuCodes.Contains(a.Name))).Select(a => a.Name)); + + return installed; + } +} diff --git a/claude.md b/claude.md index da13cf1b..dea5aa84 100644 --- a/claude.md +++ b/claude.md @@ -228,9 +228,21 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → Hazır prompt'lar (`McpPromptNames` + `McpDevelopPrompts`) yalnızca metindir; adımları tekrar etmez, `get_dev_guide` bölümüne yönlendirir. Bir kural bağlayıcıysa prompt'a değil araca yazılır (örnek: `Ready to Start` şartı `start_dev_request`'tedir). +- **Wizard deploy veritabanına yazmaz** → wizard ekranı, MCP `deploy_wizard` ve düzenleme aynı + yoldan (`ListFormWizardAppService.Create`) geçer ve **yalnızca seed dosyası yazar**: `wizard/{Ad}.json` + + menü/modül/izin grubu/yetki/dil satırları (`data/App.Menus.{Menu,Module}.json`, + `data/Abp.Identity.Permission{Groups,s}.json`, `data/App.Languages.LanguageText.json`; + `WizardSeedRowStore`, anahtarlar `WizardSeedRowKeys`). Kurulum yalnızca DB Migrate'tedir: + `WizardDataSeeder` ekrana özgü kayıtları (veri kaynağı, ListForm, alan, iş akışı) kurar, + `ListFormSeedDataApplier` satırları ekler. Deploy/import dosyaya `Pending` yazar; Wizard Manager + onu "Migrate bekliyor" gösterir (`WizardFileInfoDto.IsPending`), migrate önceki sürümün kayıtlarını + (`Pending.Replaced`) kaldırıp ekranı yeniden kurar ve `Pending`'i siler. Yetkiler **hiçbir role + otomatik atanmaz** (admin dahil); rol ekranından verilir. Deploy'a veritabanı yazan ya da silen bir + adım ekleme. Ayrıntı: `lowcode.instructions.md` §4.0. - **MCP ile demo veri** → geliştirme isteği örnek satır taşıyorsa (prompt'taki tablo/liste, ekran görüntüsü) ekran `deploy_wizard`'a `SeedData: true` ile gider (`SeedFilePath = data/{ListFormCode}.json` - + `SeedSync*` açık, `ListFormWizardDto.BuildListForm`), satırlar ardından `insert_row` ile eklenir ve + + `SeedSync*` açık, `ListFormWizardDto.BuildListForm`); kullanıcı DB Migrate çalıştırıp ekran + kurulduktan sonra (`isPending: false`) satırlar `insert_row` ile eklenir ve senkronizasyon onları `configs/seeds/host/data/{ListFormCode}.json` dosyasına yazar; migrate ekranla birlikte seed eder. Dosya elle yazılmaz, görselden okunamayan değer uydurulmaz. `SeedData` wizard ekranında sorulmaz ama düzenlemede korunur (`RefreshFrom` + UI modeli). @@ -321,10 +333,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → - **Wizard import'u** → analiz dosyalar yazılmadan **önce** doğrular (`WizardImportValidator`); `Error` seviyesinde bir bulgu varsa oturuma `blocked` işareti düşer ve `ApplyImport` reddeder. Kapanış (`CompleteImport`) bağımlılık sırasıyla çalışır: `sql/object` → `sql/execute` → `crud` - → `data` → `wizard`. Tablo eksikse önce scripti çalışır; sıra değiştirilmez. Ekran kurulumu - wizard ekranıyla **aynı** yoldan geçer (`DeployWizardAsync`); dosyadaki `ListForm`/`Fields` - sözleşmesi olduğu gibi kullanılır, wizard cevaplarından yeniden türetilmez — türetmek tasarımcı - değişikliklerini silerdi. Var olan ekrana dokunulmaz, her ekran kendi UoW'unda kurulur. + → `data` → `wizard`. Tablo eksikse önce scripti çalışır; sıra değiştirilmez. Ekran import'ta + **kurulmaz**: wizard dosyasına `Pending` yazılır ve ekran deploy'daki gibi DB Migrate'te kurulur; + dosyadaki `ListForm`/`Fields` sözleşmesi olduğu gibi kullanılır, wizard cevaplarından yeniden + türetilmez — türetmek tasarımcı değişikliklerini silerdi. Wizard'ın menü/yetki/dil satırlarını + taşıyan platform dosyaları `data` adımında uygulanmaz (`WizardSeedRowStore.ListFormCodes`). Engelleyici bulgu iki sınıftır: **arşiv kaynaklı** (`RequiresReexport` — bozuk/eksik dosya, karşılığı olmayan referans) hedef ortamda giderilemez, export tekrarlanır; **ortam kaynaklı** (eksik tablo, şema farkı) ekrandan giderilir. Yeni bir `Error` eklerken hangisi olduğu işaretlenir. @@ -348,6 +361,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki değerlerle güncellenir ve soft delete izleri (`IsDeleted`/`DeletionTime`/`DeleterId`) temizlenir. Yeni bir kayıt yolu (yeni endpoint, toplu işlem, tipli AppService) eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar. +- **Seed dosyası yazmak** → veri dosyası `ListFormSeedDataFileDto.Serialize`, wizard dosyası + `WizardSeedFileDto.ToSeedJson` ile yazılır; düz `JsonSerializer.Serialize` + `File.WriteAllText` + kullanılmaz. System.Text.Json emojiyi her zaman vekil çift kaçışına çevirir; dosya baştan + yazıldığında bütün emojiler sahte fark üretir. İkisi de `SeedJsonText.RestoreSurrogatePairs`'ten + geçer. - **`crud/{Entity}.json`** → uygulama kuralı `CrudEndpointSeedApplier`'dadır ve damga karşılaştırmalıdır: eksik endpoint eklenir, mevcut kayıt yalnızca dosyanın `GeneratedAt` değeri kaydın `LastModificationTime`/`CreationTime` değerinden yeniyse güncellenir/silinir. Migrate diff --git a/configs/seeds/host/data/App.Languages.LanguageText.json b/configs/seeds/host/data/App.Languages.LanguageText.json index 058702b0..dfb6cd93 100644 --- a/configs/seeds/host/data/App.Languages.LanguageText.json +++ b/configs/seeds/host/data/App.Languages.LanguageText.json @@ -38322,20 +38322,6 @@ "Key": "App.ListForm.WizardDeleteReason.InUse", "Value": "Başka bir yerde kullanılıyor" }, - { - "Id": "5e269bab-a213-52de-a06b-03ae3e4d106b", - "CultureName": "en", - "ResourceName": "Platform", - "Key": "App.ListForm.WizardDeleteReason.Redeploy", - "Value": "Kept for redeploy" - }, - { - "Id": "6cb9c831-1b8d-55f0-9827-3b55e02adb6b", - "CultureName": "tr", - "ResourceName": "Platform", - "Key": "App.ListForm.WizardDeleteReason.Redeploy", - "Value": "Yeniden deploy için korundu" - }, { "Id": "da051441-4ed5-589a-9a3b-b8066f8a3141", "CultureName": "en", @@ -64269,14 +64255,14 @@ "CultureName": "en", "ResourceName": "Platform", "Key": "App.ListForm.WizardImportWizardsApplied", - "Value": "Screens installed" + "Value": "Screens waiting for DB Migrate" }, { "Id": "02d281a8-85a6-57d1-ae67-82e2824e5bea", "CultureName": "tr", "ResourceName": "Platform", "Key": "App.ListForm.WizardImportWizardsApplied", - "Value": "Kurulan ekranlar" + "Value": "DB Migrate bekleyen ekranlar" }, { "Id": "69860bed-53a5-59ee-8252-39f9b54a1554", @@ -69135,6 +69121,104 @@ "ResourceName": "Platform", "Key": "App.Listform.ListformField.TwoLetterISOLanguageName", "Value": "İki Harfli ISO Kodu" + }, + { + "Id": "92e980fc-d1ba-5fa7-becd-b8d906a449ab", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusPending", + "Value": "Waiting for migrate" + }, + { + "Id": "a08169eb-9b91-5baa-be38-ec0b42b5910e", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusPending", + "Value": "Migrate bekliyor" + }, + { + "Id": "02b15695-60a4-5c81-bb0d-384002859e6a", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusInstalled", + "Value": "Installed" + }, + { + "Id": "5cc4549c-5719-53d4-9db3-35833a147142", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusInstalled", + "Value": "Kurulu" + }, + { + "Id": "cb9e2cc1-e6e9-5733-9bcc-89a728cb7fd7", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusPendingHint", + "Value": "Seed files are written. The screen, menu and permissions are written to the database by DB Migrate." + }, + { + "Id": "5c1f0e9f-8210-5f28-9a58-54299940c737", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusPendingHint", + "Value": "Seed dosyaları yazıldı. Ekran, menü ve yetkiler DB Migrate ile veritabanına yazılır." + }, + { + "Id": "b1ac9b48-8d1c-5926-9a7c-cfbc15f4f47b", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusInstalledHint", + "Value": "Applied to the database by DB Migrate." + }, + { + "Id": "75f80850-d411-5d93-b715-d56fd441f0ba", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardStatusInstalledHint", + "Value": "DB Migrate ile veritabanına uygulandı." + }, + { + "Id": "96cba76a-76bf-5925-9c93-4ce9011f72b3", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardPendingCount", + "Value": "{0} waiting for migrate" + }, + { + "Id": "797b08b6-7f10-5abf-9381-b9b6ae2b7f4c", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardPendingCount", + "Value": "{0} wizard migrate bekliyor" + }, + { + "Id": "5e521ec7-91c6-50fc-892c-e631bee3326a", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardDeployedPendingMigrate", + "Value": "Wizard saved to the seed files. Run DB Migrate to create the screen, menu and permissions, then grant the permissions to roles from the role screen." + }, + { + "Id": "cc09dc76-7067-5779-bf68-f40c4adc100c", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.ListForm.WizardDeployedPendingMigrate", + "Value": "Wizard seed dosyalarına kaydedildi. Ekranı, menüyü ve yetkileri oluşturmak için DB Migrate çalıştırın; ardından yetkileri rol ekranından rollere verin." + }, + { + "Id": "2df778f2-8575-59a0-b49a-75bcc98a4c13", + "CultureName": "en", + "ResourceName": "Platform", + "Key": "App.Mcp.Error.VerifyWizardPending", + "Value": "Wizard \"{0}\" is not installed yet: its seed files are written but DB Migrate has not run. Ask the user to run DB Migrate from the Wizard Manager, then verify again." + }, + { + "Id": "3217cd45-cac5-5d2c-9964-37627da3b507", + "CultureName": "tr", + "ResourceName": "Platform", + "Key": "App.Mcp.Error.VerifyWizardPending", + "Value": "\"{0}\" wizard'ı henüz kurulmadı: seed dosyaları yazıldı ama DB Migrate çalışmadı. Kullanıcıdan Wizard Manager'da DB Migrate çalıştırmasını isteyin, sonra tekrar doğrulayın." } ] } \ No newline at end of file diff --git a/ui/src/components/shared/DbMigrateButton.tsx b/ui/src/components/shared/DbMigrateButton.tsx index 9cdbb4b8..9a65314d 100644 --- a/ui/src/components/shared/DbMigrateButton.tsx +++ b/ui/src/components/shared/DbMigrateButton.tsx @@ -11,6 +11,8 @@ interface DbMigrateButtonProps { /** Küçük ekranlarda etiketi gizle (yalnızca ikon göster). */ hideLabelOnMobile?: boolean className?: string + /** Migrate hatasız bittiğinde çağrılır. */ + onCompleted?: () => void } /** @@ -20,7 +22,11 @@ interface DbMigrateButtonProps { * toplanmıştır. Yetkisi olmayan kullanıcıya buton hiç render edilmez; asıl kontrol * yine de sunucuda (`App.Setup.Migrate`) yapılır. */ -const DbMigrateButton = ({ hideLabelOnMobile = false, className }: DbMigrateButtonProps) => { +const DbMigrateButton = ({ + hideLabelOnMobile = false, + className, + onCompleted, +}: DbMigrateButtonProps) => { const { translate } = useLocalization() const { checkPermission } = usePermission() const [isConfirmOpen, setIsConfirmOpen] = useState(false) @@ -54,7 +60,7 @@ const DbMigrateButton = ({ hideLabelOnMobile = false, className }: DbMigrateButt onClose={() => setIsConfirmOpen(false)} onConfirm={() => { setIsConfirmOpen(false) - UiEvalService.ApiDbMigrate() + UiEvalService.ApiDbMigrate(onCompleted) }} >

diff --git a/ui/src/proxy/admin/wizard/models.ts b/ui/src/proxy/admin/wizard/models.ts index fd4ef0ef..f7420e56 100644 --- a/ui/src/proxy/admin/wizard/models.ts +++ b/ui/src/proxy/admin/wizard/models.ts @@ -137,6 +137,8 @@ export interface WizardFileInfoDto { menuIcon: string createdAt: string hasInsertedRecords: boolean + /** Dosya veritabanına henüz uygulanmadı; ekran DB Migrate ile kurulur. */ + isPending: boolean componentKind: WizardComponentKindEnum customComponentName: string menuUrl: string @@ -176,7 +178,6 @@ export enum WizardDeleteStatusEnum { export enum WizardDeleteReasonEnum { None = 0, InUse = 1, - Redeploy = 2, NotSelected = 3, NotManaged = 4, Failed = 5, diff --git a/ui/src/services/UiEvalService.tsx b/ui/src/services/UiEvalService.tsx index ff814c26..0b47d5ae 100644 --- a/ui/src/services/UiEvalService.tsx +++ b/ui/src/services/UiEvalService.tsx @@ -90,9 +90,10 @@ export abstract class UiEvalService { /** * Canlı sistemde migration tetikler (SQL Query Manager ve Wizard dosya yöneticisi ortak kullanır). - * Yetki sunucu tarafında `App.Setup.Migrate` izniyle doğrulanır. + * Yetki sunucu tarafında `App.Setup.Migrate` izniyle doğrulanır. `onSuccess`, migrate hatasız + * bittiğinde çağrılır; migrate'in kurduğu kayıtları gösteren ekran kendini yeniler. */ - static ApiDbMigrate = () => { + static ApiDbMigrate = (onSuccess?: () => void) => { toast.push( {UiEvalService.translate('::App.Platform.StartMessage')} @@ -126,6 +127,7 @@ export abstract class UiEvalService { , { placement: 'bottom-end' }, ) + onSuccess?.() }, }) } diff --git a/ui/src/views/admin/listForm/wizard/Wizard.tsx b/ui/src/views/admin/listForm/wizard/Wizard.tsx index 2313a4ef..ab238ab7 100644 --- a/ui/src/views/admin/listForm/wizard/Wizard.tsx +++ b/ui/src/views/admin/listForm/wizard/Wizard.tsx @@ -1082,7 +1082,6 @@ const Wizard = () => { formikRef.current?.setFieldValue('customComponentName', name) formikRef.current?.setFieldValue('menuUrl', routePath) } - const { getConfig } = useStoreActions((a) => a.abpConfig) const preventEnterSubmit = (event: KeyboardEvent) => { if (event.key !== 'Enter') return @@ -1104,10 +1103,23 @@ const Wizard = () => { const values = formik.values const isCustom = values.componentKind === WizardComponentKindEnum.Custom - // Edit modunda eski dosyanın silinmesini (ve DB kayıtlarının temizlenmesini) sunucu yapar; - // istek `editFileName` ile geldiğinde güncelleme yetkisiyle çalışır. + // Edit modunda eski dosyanın yerine yenisini sunucu yazar; istek `editFileName` ile + // geldiğinde güncelleme yetkisiyle çalışır. const editing = isEditMode && editFileName ? { editFileName } : {} + // Deploy yalnızca seed dosyalarını yazar; ekran, menü ve yetkiler DB Migrate ile kurulur. + // Bu yüzden ekranın adresine değil, migrate'in çalıştırıldığı dosya yöneticisine dönülür. + const returnToManager = () => { + toast.push( + + {translate('::App.ListForm.WizardDeployedPendingMigrate')} + , + { placement: 'bottom-end' }, + ) + + navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizardManager, { replace: true }) + } + try { // Custom yolunda ListForm'a ait hiçbir koleksiyon gönderilmez. if (isCustom) { @@ -1119,16 +1131,7 @@ const Wizard = () => { widgets: [], }) - await getConfig(true) - - toast.push( - - {translate('::App.Platform.FormBilgileriKaydedildi')} - , - { placement: 'bottom-end' }, - ) - - navigate(values.menuUrl, { replace: true }) + returnToManager() return } @@ -1168,19 +1171,7 @@ const Wizard = () => { }, }) - // Menü/permission değişikliklerinin görünmesi için ABP config'i tazele - await getConfig(true) - - toast.push( - - {translate('::App.Platform.FormBilgileriKaydedildi')} - , - { placement: 'bottom-end' }, - ) - - navigate(ROUTES_ENUM.protected.admin.list.replace(':listFormCode', values.listFormCode), { - replace: true, - }) + returnToManager() } catch (err: any) { toast.push( diff --git a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx index c20f7d52..d53dfa14 100644 --- a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx @@ -26,6 +26,8 @@ import { FaLevelUpAlt, FaPlusSquare, FaMinusSquare, + FaHourglassHalf, + FaCheckCircle, } from 'react-icons/fa' import DbMigrateButton from '@/components/shared/DbMigrateButton' import { @@ -180,6 +182,7 @@ const WizardFileManager = () => { const { states } = useStoreState((state) => state.admin.lists) const { setStates } = useStoreActions((a) => a.admin.lists) + const { getConfig } = useStoreActions((a) => a.abpConfig) const viewMode: ListViewLayoutType = states.find((s) => s.listFormCode === VIEW_STATE_CODE)?.layout === 'grid' ? 'grid' : 'card' const setViewMode = (layout: ListViewLayoutType) => @@ -252,6 +255,15 @@ const WizardFileManager = () => { loadFiles() }, [loadFiles]) + // Deploy yalnizca seed dosyalarini yazar; ekran, menu ve yetkiler migrate ile gelir. Migrate + // bitince durumlar ve menu (ABP config) yenilenir. + const handleMigrateCompleted = useCallback(async () => { + await loadFiles() + await getConfig(true) + }, [loadFiles, getConfig]) + + const pendingCount = useMemo(() => files.filter((f) => f.isPending).length, [files]) + // Zip'i sunucu uretir; istemci yalnizca indirmeyi tetikler. Dosya adi Content-Disposition // basligindan okunur, okunamazsa wizard adina duser. /** Arsivi indirir; dosya adi sunucunun verdigi addir, yoksa wizard adina duser. */ @@ -476,8 +488,9 @@ const WizardFileManager = () => { } /> )} - {/* Menünün açtığı adres yeni sekmede açılır; adres yoksa buton anlamsızdır. */} - {f.menuUrl && ( + {/* Menünün açtığı adres yeni sekmede açılır; adres yoksa ya da ekran henüz kurulmadıysa + buton anlamsızdır. */} + {f.menuUrl && !f.isPending && ( - + {pendingCount > 0 && ( + + + {translate('::App.ListForm.WizardPendingCount', { 0: pendingCount })} + + )} + {canImport && ( <>