WizardFileManager önce Json sonra DbMigrate

This commit is contained in:
Sedat ÖZTÜRK 2026-09-25 09:39:34 +03:00
parent 246dc17fcb
commit 5560d3bb8c
31 changed files with 1789 additions and 1272 deletions

View file

@ -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.

View file

@ -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

View file

@ -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. |

View file

@ -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)
{

View file

@ -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";

View file

@ -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<WizardFileInfoDto?> 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<McpVerifyResultDto> VerifyWizardAsync(
[Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")]
string fileName)

View file

@ -34,11 +34,18 @@ public class WizardSeedFileDto
public bool IsCreatedField { get; set; }
/// <summary>
/// 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.
/// </summary>
public WizardInsertedRecordsDto InsertedRecords { get; set; } = new();
/// <summary>
/// Doluysa dosya henuz veritabanina uygulanmamistir: deploy yalnizca dosyalari yazar, kurulumu
/// bir sonraki migrate yapar (<c>WizardDataSeeder</c>) ve bu bolumu temizler.
/// </summary>
public WizardPendingDto Pending { get; set; }
/// <summary>
/// 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 };
/// <summary>
@ -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);
}
/// <summary>
@ -365,12 +383,51 @@ public class WizardInsertedRecordsDto
/// <summary>Bu Wizard tarafından oluşturulan yeni Module kayıtları.</summary>
public List<string> ModuleIds { get; set; } = [];
/// <summary>Iki listenin birlesimi; ayni kayit bir kez yer alir.</summary>
public WizardInsertedRecordsDto Union(WizardInsertedRecordsDto other)
{
static List<string> Merge(List<string> a, List<string> 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;
}
/// <summary>
/// Deploy edilmis ama henuz migrate edilmemis bir wizard'in bekleyen kurulumu.
/// <para>
/// Duzenleme veritabanina dokunmadigi icin onceki surumun kurdugu kayitlar migrate'e kadar yerinde
/// durur. <see cref="Replaced"/> 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.
/// </para>
/// </summary>
public class WizardPendingDto
{
/// <summary>Ilk bekleyen deploy zamani; migrate edilmeden yapilan duzenlemeler degistirmez.</summary>
public DateTime Since { get; set; }
/// <summary>Onceki surumlerin sahip oldugu kayitlar.</summary>
public WizardInsertedRecordsDto Replaced { get; set; } = new();
/// <summary>Onceki surumlerin kurdugu ekran kodlari; kod degistiyse eski ekran kaldirilir.</summary>
public List<string> ListFormCodes { get; set; } = [];
}
/// <summary>Kaydi kullanan (silinmesine engel olan) taraf.</summary>
public class WizardRecordRefDto
{
@ -456,6 +513,12 @@ public class WizardFileInfoDto
public string CreatedAt { get; set; }
public bool HasInsertedRecords { get; set; }
/// <summary>
/// Dosya veritabanina henuz uygulanmadi: deploy edildi ya da duzenlendi ama migrate calismadi,
/// veya ekran bu veritabaninda hic kurulmadi. Kurulum DB Migrate ile yapilir.
/// </summary>
public bool IsPending { get; set; }
/// <summary>Wizard'in List mi Custom Component mi urettigi; listede rozet olarak gosterilir.</summary>
public WizardComponentKindEnum ComponentKind { get; set; }

View file

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.ListForms;
/// <summary>
/// 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.
/// </summary>
public static class WizardSeedRowKeys
{
/// <summary>Okuma yetkisinin altindaki alt yetkiler: (sonek, dil anahtari), siralari dizilimdir.</summary>
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)];
}
/// <summary>Menuye eklenmeyen wizard'da izin grubu sorulmaz; bos geldiginde uygulama grubuna duser.</summary>
public static string PermissionGroupName(ListFormWizardDto wizard) =>
string.IsNullOrWhiteSpace(wizard.PermissionGroupName) ? PlatformConsts.AppName : wizard.PermissionGroupName.Trim();
/// <summary>Okuma yetkisi ve alt yetkileri; ilk oge okuma yetkisidir.</summary>
public static List<string> PermissionNames(ListFormWizardDto wizard)
{
var code = wizard.ResolveMenuCode();
return [code, .. ChildPermissions(wizard).Select(p => $"{code}.{p.Suffix}")];
}
/// <summary>
/// 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 <c>InsertedRecords</c> belirler.
/// </summary>
public static WizardInsertedRecordsDto All(ListFormWizardDto wizard)
{
var code = wizard.ResolveMenuCode();
var group = PermissionGroupName(wizard);
var isList = wizard.ComponentKind != WizardComponentKindEnum.Custom;
var menus = new List<string> { code };
var languageKeys = new List<string> { code, group };
var modules = new List<string>();
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)],
};
}
}

View file

@ -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));
}
}

View file

@ -54,6 +54,9 @@ public sealed class WizardExportCollector(string scopeRoot)
private readonly HashSet<string> customComponents = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> warnings = [];
/// <summary>Toplanan wizard'larin platform seed dosyalarindaki (menu, yetki, dil) satir anahtarlari.</summary>
private WizardInsertedRecordsDto platformRows = new();
/// <summary>ListFormCode -> wizard seed dosyasi; alt form cozumu icin bir kez kurulur.</summary>
private Dictionary<string, string>? 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);
}
}
/// <summary>
/// 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.
/// </summary>
private async Task MaterializePlatformRowsAsync()
{
(string ListFormCode, string Field, List<string> 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<ListFormSeedDataFileDto>(
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));
}
}
/// <summary>
/// Component adi -> dogrudan bagimliliklari. Wizard listesi gruplamayi export'un izledigi
/// zincirle ayni kaynaktan kurar; boylece listede birlikte gorunenler arsive de birlikte girer.

View file

@ -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.
/// </para>
/// <para>
/// <see cref="WizardDeleteMode.Redeploy"/> 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 <b>baglandigi</b> 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.
/// </para>
/// <para>
/// Silme yolunda wizard'in <b>baglandigi</b> 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 (<see cref="CleanAsync"/>) ile seed satirlarinin
/// cikarilmasi (<see cref="RemoveSeedRowsAsync"/>) ayri adimlardir: satirlar ancak veritabani islemi
/// basariyla tamamlandiktan sonra cikarilir, aksi halde dosya ile veritabani ayrisirdi.
/// </para>
/// </summary>
public class WizardRecordCleaner(
@ -65,20 +64,24 @@ public class WizardRecordCleaner(
/// <summary>Raporda bir kaydi kullananlarin en fazla bu kadari listelenir.</summary>
private const int MaxUsedBy = 20;
public async Task<List<WizardDeleteLogItemDto>> CleanAsync(WizardSeedFileDto seed, WizardDeleteMode mode)
public async Task<List<WizardDeleteLogItemDto>> CleanAsync(WizardSeedFileDto seed)
{
var report = new List<WizardDeleteLogItemDto>();
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<string>();
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(
/// <summary>
/// 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.
/// </summary>
private async Task CleanListFormAsync(string listFormCode, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report)
private async Task CleanListFormAsync(string listFormCode, List<WizardDeleteLogItemDto> 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);
}
}
/// <summary>
/// Ekranin veri aynasi (<c>ListForm.SeedFilePath</c>) ekransiz uygulanamaz; ekranla birlikte
/// gider. Duzenlemede ekran ayni yolla yeniden kurulacagi icin dokunulmaz.
/// gider.
/// </summary>
private void DeleteSeedDataMirror(string? seedFilePath, List<WizardDeleteLogItemDto> 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.
/// </summary>
private async Task<List<Menu>> CleanMenusAsync(List<string> codes, string? menuParentCode, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report)
private async Task<List<Menu>> CleanMenusAsync(List<string> codes, List<WizardDeleteLogItemDto> report)
{
var deleted = new List<Menu>();
var pending = new List<string>();
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(
/// <summary>
/// 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.
/// </summary>
private async Task CleanPermissionsAsync(List<string> names, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report)
private async Task CleanPermissionsAsync(List<string> names, List<WizardDeleteLogItemDto> report)
{
var pending = names.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var blockers = new Dictionary<string, List<WizardRecordRefDto>>(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<WizardDeleteLogItemDto> report)
private async Task DeletePermissionGrantsAsync(string name, List<WizardDeleteLogItemDto> 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(
/// <paramref name="reportKept"/> kapaliyken kayit wizard'in degil, yalnizca baglandigi kaptir:
/// bulunamamasi ya da kullanimda kalmasi rapora yazilmaz. Silinen grup adlarini dondurur.
/// </summary>
private async Task<List<string>> CleanPermissionGroupsAsync(List<string> names, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report, bool reportKept)
private async Task<List<string>> CleanPermissionGroupsAsync(List<string> names, List<WizardDeleteLogItemDto> report, bool reportKept)
{
var deleted = new List<string>();
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.
/// <paramref name="reportKept"/> izin gruplarindakiyle aynidir.
/// </summary>
private async Task CleanModulesAsync(List<string> ids, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report, bool reportKept)
private async Task CleanModulesAsync(List<string> ids, List<WizardDeleteLogItemDto> report, bool reportKept)
{
if (ids.Count == 0)
return;
List<PermissionDefinitionRecord>? permissions = null;
List<Volo.Abp.TenantManagement.Tenant>? 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<WizardRecordRefDto>();
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<string> codes, WizardDeleteMode mode, List<WizardDeleteLogItemDto> report)
private async Task<List<WizardRecordRefDto>> FindModuleUsagesAsync(string id)
{
// Modul listeleri ekstra ozellikte metin olarak durur; sorguyla suzulemez, bellekte bakilir.
var usedBy = new List<WizardRecordRefDto>();
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<string> codes, List<WizardDeleteLogItemDto> 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<WizardRecordRefDto>();
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<List<WizardRecordRefDto>> FindLanguageKeyUsagesAsync(string key)
{
var usedBy = new List<WizardRecordRefDto>();
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<WizardRecordRefDto>> 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 ─────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private async Task RemoveSeedRowsAsync(List<WizardDeleteLogItemDto> report)
public async Task RemoveSeedRowsAsync(List<WizardDeleteLogItemDto> report)
{
List<string> Deleted(WizardRecordTypeEnum type) =>
[.. report.Where(i => i.RecordType == type && i.Status == WizardDeleteStatusEnum.Deleted).Select(i => i.Name)];
List<string> 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));
}
/// <summary>Wizard'in kendi urettikleri disinda kalan, bos olmayan adaylar.</summary>
@ -618,25 +610,4 @@ public class WizardRecordCleaner(
.Take(MaxUsedBy)],
});
}
private static void AddRedeploy(List<WizardDeleteLogItemDto> report, WizardRecordTypeEnum type, string name)
{
report.Add(new WizardDeleteLogItemDto
{
RecordType = type,
Name = name,
Status = WizardDeleteStatusEnum.Kept,
Reason = WizardDeleteReasonEnum.Redeploy,
});
}
}
/// <summary>Wizard kayitlarinin hangi amacla silindigi.</summary>
public enum WizardDeleteMode
{
/// <summary>Wizard Manager'dan silme: kullanilmayan her kayit gider.</summary>
Delete,
/// <summary>Duzenleme: kayitlar hemen ardindan yeniden uretilir, paylasilan kaplar korunur.</summary>
Redeploy,
}

View file

@ -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;
/// <summary>
/// Wizard'in yazdigi platform seed dosyalari: menu, modul, izin grubu, yetki ve dil metni.
/// <para>
/// Deploy veritabanina yazmaz; ekranin menu/yetki/dil kayitlari bu dosyalara satir olarak girer ve
/// migrate onlari <c>ListFormSeedDataApplier</c> ile ekler. Dosyalar bir kez okunur, bellekte
/// degistirilir ve yalnizca degisenler yazilir.
/// </para>
/// </summary>
public sealed class WizardSeedRowStore
{
/// <summary>Dosyanin ekran kodu, satir anahtari ve dosya yoksa yazilacak uygulama sirasi.</summary>
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),
];
/// <summary>Wizard'in satir yazdigi ekran kodlari; import bu dosyalari veritabanina uygulamaz.</summary>
public static IReadOnlyList<string> ListFormCodes { get; } = [.. Specs.Select(s => s.ListFormCode)];
private readonly Dictionary<string, (string Path, ListFormSeedDataFileDto File)> files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> dirty = new(StringComparer.OrdinalIgnoreCase);
private WizardSeedRowStore()
{
}
/// <summary>Kapsam klasorundeki dosyalari okur; olmayan dosya bos olarak baslar ve ilk yazimda olusur.</summary>
public static async Task<WizardSeedRowStore> 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<ListFormSeedDataFileDto>(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<Dictionary<string, object>> Rows(string listFormCode) => files[listFormCode].File.Rows;
public Dictionary<string, object>? 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;
/// <summary>
/// Satiri <paramref name="matchFields"/> 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.
/// </summary>
public void Upsert(string listFormCode, Dictionary<string, object> 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<string, object>(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);
}
/// <summary>
/// <paramref name="field"/> degeri <paramref name="values"/> icinde olan satirlari cikarir;
/// <paramref name="keep"/> true donen satir birakilir. Cikarilan satirlarin alan degerlerini
/// dondurur.
/// </summary>
public List<string> Remove(string listFormCode, string field, IEnumerable<string> values, Func<Dictionary<string, object>, 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)];
}
/// <summary>
/// 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 <paramref name="usedElsewhere"/> ile cagiranin verdigi
/// dis kullanim (veritabani). Siralama bagimlilik yonundedir — once kullanan, sonra kullanilan —
/// ve her kontrol o ana kadar kalan satirlara karsi yapilir.
/// </summary>
public List<(WizardRecordTypeEnum Type, string Name, bool Removed)> RemoveOwned(
WizardInsertedRecordsDto records,
Func<WizardRecordTypeEnum, string, bool>? usedElsewhere = null)
{
static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
static bool In(List<string> 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<string> names, Func<Dictionary<string, object>, 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<string, object>? row, string field) => WizardExportCollector.GetRowText(row, field);
public static int Int(Dictionary<string, object>? row, string field) =>
int.TryParse(Text(row, field), out var value) ? value : 0;
private static bool Matches(Dictionary<string, object> row, string field, string? value) =>
string.Equals(Text(row, field), value, StringComparison.OrdinalIgnoreCase);
}

View file

@ -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;

View file

@ -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;
/// <summary>
/// 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).
/// <para>
/// 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
/// (<c>data/*.json</c>) ve bu seeder'dan sonra <c>ListFormSeedDataApplier</c> tarafindan eklenir.
/// </para>
/// <para>
/// Uygulama kurali: kurulu olmayan ekran kurulur, kurulu olana dokunulmaz. Bekleyen kurulumu olan
/// dosyada (<see cref="WizardSeedFileDto.Pending"/> — 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.
/// </para>
/// </summary>
public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
public class WizardDataSeeder(
IRepository<LanguageKey, Guid> repoLangKey,
IRepository<LanguageText, Guid> repoLangText,
IRepository<PermissionGroupDefinitionRecord, Guid> repoPermGroup,
IRepository<PermissionDefinitionRecord, Guid> repoPerm,
IRepository<Menu, Guid> repoMenu,
IRepository<DataSource, Guid> repoDataSource,
IRepository<ListForm, Guid> repoListForm,
IRepository<ListFormField, Guid> repoListFormField,
IRepository<ListFormWorkflow, string> repoListFormWorkflow,
IRepository<PermissionGrant, Guid> repoPermGrant,
WizardInstallationChecker installationChecker,
IConfiguration configuration,
ILogger<WizardDataSeeder> logger) : IDataSeedContributor, ITransientDependency
{
private readonly IRepository<LanguageKey, Guid> _repoLangKey;
private readonly IRepository<LanguageText, Guid> _repoLangText;
private readonly IRepository<PermissionGroupDefinitionRecord, Guid> _repoPermGroup;
private readonly IRepository<PermissionDefinitionRecord, Guid> _repoPerm;
private readonly IRepository<Menu, Guid> _repoMenu;
private readonly IRepository<Module, string> _repoModule;
private readonly IRepository<DataSource, Guid> _repoDataSource;
private readonly IRepository<ListForm, Guid> _repoListForm;
private readonly IRepository<ListFormField, Guid> _repoListFormField;
private readonly IRepository<ListFormWorkflow, string> _repoListFormWorkflow;
private readonly IConfiguration _configuration;
private readonly ILogger<WizardDataSeeder> _logger;
private readonly string _cultureNameDefault = PlatformConsts.DefaultLanguage;
private readonly string _appName = PlatformConsts.AppName;
public WizardDataSeeder(
IRepository<LanguageKey, Guid> repoLangKey,
IRepository<LanguageText, Guid> repoLangText,
IRepository<PermissionGroupDefinitionRecord, Guid> repoPermGroup,
IRepository<PermissionDefinitionRecord, Guid> repoPerm,
IRepository<Menu, Guid> repoMenu,
IRepository<Module, string> repoModule,
IRepository<DataSource, Guid> repoDataSource,
IRepository<ListForm, Guid> repoListForm,
IRepository<ListFormField, Guid> repoListFormField,
IRepository<ListFormWorkflow, string> repoListFormWorkflow,
IConfiguration configuration,
ILogger<WizardDataSeeder> 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)
/// <summary>
/// Ekrana ozgu kayitlari kurar: veri kaynagi (yoksa), ListForm, alanlar ve is akisi dosyadaki
/// sozlesmeden. Menu/yetki/dil satirlari <c>ListFormSeedDataApplier</c>'a kalir.
/// </summary>
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<string>(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
}
/// <summary>
/// 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
/// (<see cref="WizardPendingDto.Replaced"/>) 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Seed dosyasindaki modul kaydi yoksa olusturur; menunun ModuleId yabanci anahtari
/// tanimsiz bir module bagli kalmasin diye menu yazilmadan once cagrilir.
/// </summary>
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<PermissionDefinitionRecord> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -1,4 +1,4 @@
namespace Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.Enums;
/// <summary>
/// Wizard silme raporundaki kayit turleri. Ilk grup wizard'in urettigi kayitlardir; ikinci grup
@ -37,7 +37,7 @@ public enum WizardDeleteStatusEnum
{
Deleted = 0,
/// <summary>Kayit baska bir yerde kullanildigi ya da yeniden deploy edilecegi icin birakildi.</summary>
/// <summary>Kayit baska bir yerde kullanildigi icin birakildi.</summary>
Kept = 1,
/// <summary>Seed dosyasinda izi var ama veritabaninda yok; daha once elle silinmis olabilir.</summary>
@ -52,9 +52,6 @@ public enum WizardDeleteReasonEnum
/// <summary>Baska bir kayit bu kayda baglidir; kullananlar raporda listelenir.</summary>
InUse = 1,
/// <summary>Duzenleme yolunda paylasilan kayit korunur; deploy onu yeniden kullanir.</summary>
Redeploy = 2,
/// <summary>Tablo silme onayinda secilmedi; tablo ve ona bagli seed dosyalari yerinde kalir.</summary>
NotSelected = 3,

View file

@ -0,0 +1,29 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Sozsoft.Platform;
/// <summary>
/// Seed dosyalarina yazilan JSON metninin son hali. <c>UnsafeRelaxedJsonEscaping</c> Turkce harfleri
/// ve BMP'deki karakterleri oldugu gibi birakir ama BMP disindaki karakterleri (emoji) her zaman
/// <c>🔥</c> 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.
/// </summary>
public static partial class SeedJsonText
{
// Onunde tek sayida ters bolu varsa kacis gercektir; cift sayidaysa metnin kendisidir ("\\uD83D").
[GeneratedRegex(@"(?<!\\)((?:\\\\)*)\\u(D[89AB][0-9A-F]{2})\\u(D[C-F][0-9A-F]{2})", RegexOptions.IgnoreCase)]
private static partial Regex EscapedSurrogatePair();
public static string RestoreSurrogatePairs(string json)
{
if (string.IsNullOrEmpty(json) || !json.Contains("\\u", StringComparison.Ordinal))
return json;
return EscapedSurrogatePair().Replace(json, m =>
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));
}
}

View file

@ -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";

View file

@ -42,4 +42,12 @@ public class ListFormSeedDataFileDto
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Dosyaya yazilacak metin. Seed dosyasi yazan her yol bunu kullanir; emoji gibi karakterler
/// kacirilmadan yazilir (<see cref="SeedJsonText"/>) ve dosya depodaki digerleri gibi satir sonuyla
/// biter — dosya bastan yazildiginda sahte fark olusmaz.
/// </summary>
public static string Serialize(ListFormSeedDataFileDto file) =>
SeedJsonText.RestoreSurrogatePairs(JsonSerializer.Serialize(file, JsonOptions)) + "\n";
}

View file

@ -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");
}
/// <summary>
/// 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

View file

@ -0,0 +1,42 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.Data.Seeds;
/// <summary>
/// Seed dosyasina yazilan <c>Guid</c> 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.
/// </summary>
public static class SeedRowIds
{
/// <summary>RFC 4122 URL isim alani (<c>6ba7b811-9dad-11d1-80b4-00c04fd430c8</c>).</summary>
private static readonly byte[] UrlNamespace =
[
0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8
];
/// <summary>
/// Dil metni satirinin kimligi: <c>{Tablo}:{ResourceName}:{Key}:{CultureName}</c> adindan uretilir.
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// 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
/// (<c>WizardDataSeeder</c>) ile Wizard Manager'daki "migrate bekliyor" durumu ayni kuraldir; tek
/// yerde durur.
/// </summary>
public class WizardInstallationChecker(
IRepository<ListForm, Guid> repoListForm,
IRepository<PermissionDefinitionRecord, Guid> repoPerm) : ITransientDependency
{
public async Task<bool> IsInstalledAsync(bool isCustom, string menuCode, string listFormCode) =>
isCustom
? await repoPerm.AnyAsync(a => a.Name == menuCode)
: await repoListForm.AnyAsync(a => a.ListFormCode == listFormCode);
/// <summary>
/// Birden fazla wizard icin ayni karar; iki sorguyla verilir. Anahtar, List yolunda ekran kodu,
/// Custom yolunda menu kodudur (buyuk/kucuk harf duyarsiz).
/// </summary>
public async Task<HashSet<string>> GetInstalledAsync(IReadOnlyCollection<string> listFormCodes, IReadOnlyCollection<string> menuCodes)
{
var installed = new HashSet<string>(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;
}
}

View file

@ -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

View file

@ -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."
}
]
}

View file

@ -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)
}}
>
<p className="text-gray-600 dark:text-gray-400">

View file

@ -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,

View file

@ -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(
<Notification type="info" duration={3000}>
{UiEvalService.translate('::App.Platform.StartMessage')}
@ -126,6 +127,7 @@ export abstract class UiEvalService {
</Notification>,
{ placement: 'bottom-end' },
)
onSuccess?.()
},
})
}

View file

@ -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<HTMLFormElement>) => {
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(
<Notification type="success" duration={4000}>
{translate('::App.ListForm.WizardDeployedPendingMigrate')}
</Notification>,
{ 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(
<Notification type="success" duration={2000}>
{translate('::App.Platform.FormBilgileriKaydedildi')}
</Notification>,
{ 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(
<Notification type="success" duration={2000}>
{translate('::App.Platform.FormBilgileriKaydedildi')}
</Notification>,
{ placement: 'bottom-end' },
)
navigate(ROUTES_ENUM.protected.admin.list.replace(':listFormCode', values.listFormCode), {
replace: true,
})
returnToManager()
} catch (err: any) {
toast.push(
<Notification type="danger">

View file

@ -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 && (
<Button
type="button"
variant="plain"
@ -542,6 +555,28 @@ const WizardFileManager = () => {
)
}
// Deploy edilmiş ama DB Migrate ile veritabanına uygulanmamış dosya ayrı görünür.
const renderStatusBadge = (f: WizardFileInfoDto) => (
<span
title={translate(
f.isPending
? '::App.ListForm.WizardStatusPendingHint'
: '::App.ListForm.WizardStatusInstalledHint',
)}
className={classNames(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap',
f.isPending
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
)}
>
{f.isPending ? <FaHourglassHalf /> : <FaCheckCircle />}
{translate(
f.isPending ? '::App.ListForm.WizardStatusPending' : '::App.ListForm.WizardStatusInstalled',
)}
</span>
)
const renderMenuPath = (f: WizardFileInfoDto, className?: string) => {
const text = menuPathText(f)
if (!text) return null
@ -632,7 +667,16 @@ const WizardFileManager = () => {
>
<FaSync />
</Button>
<DbMigrateButton hideLabelOnMobile />
{pendingCount > 0 && (
<span
className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
title={translate('::App.ListForm.WizardStatusPendingHint')}
>
<FaHourglassHalf />
{translate('::App.ListForm.WizardPendingCount', { 0: pendingCount })}
</span>
)}
<DbMigrateButton hideLabelOnMobile onCompleted={handleMigrateCompleted} />
{canImport && (
<>
<Button
@ -734,6 +778,7 @@ const WizardFileManager = () => {
{/* 3. satır: yol ayrımı (Custom / List) ve bileşen kodu */}
<div className="flex flex-wrap pt-1 items-center gap-1.5 text-xs">
{renderKindBadge(f)}
{renderStatusBadge(f)}
<div className="text-xs text-gray-400 truncate">{componentName(f)}</div>
</div>
@ -868,7 +913,12 @@ const WizardFileManager = () => {
<td className="px-4 py-2 text-gray-600 dark:text-gray-300">
{componentName(f)}
</td>
<td className="px-4 py-2 whitespace-nowrap">{renderKindBadge(f)}</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
{renderKindBadge(f)}
{renderStatusBadge(f)}
</div>
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-300 whitespace-nowrap">
{renderMenuPath(f)}
</td>

View file

@ -24,7 +24,7 @@ import {
rollbackWizardImport,
} from '@/services/wizard.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreActions, useStoreState } from '@/store/store'
import { useStoreState } from '@/store/store'
import Input from '@/components/ui/Input'
import { useDialogContext } from '@/components/ui/Dialog/Dialog'
import SqlTableDesignerDialog from '@/views/developerKit/SqlTableDesignerDialog'
@ -120,7 +120,6 @@ const ImportDialogBody = ({ children }: { children: ReactNode }) => {
*/
const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogProps) => {
const { translate } = useLocalization()
const { getConfig } = useStoreActions((a) => a.abpConfig)
const mode = useStoreState((state) => state.theme.mode)
const [phase, setPhase] = useState<ImportPhase>('analyzing')
@ -388,12 +387,8 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
setSchemaApplied(res.data.appliedSchema ?? [])
setCrudApplied(res.data.appliedCrud ?? [])
setSeedApplied(res.data.appliedData ?? [])
const installed = res.data.appliedWizards ?? []
setWizardsApplied(installed)
// Kurulan ekranin menusu ve yetkileri ABP config'inden gelir; yenilenmezse kullanici
// ekrani ancak sayfayi bastan yukledikten sonra gorurdu.
if (installed.some((row) => !row.error)) await getConfig(true)
// Ekranlar import'ta kurulmaz; wizard dosyalari DB Migrate'i bekler.
setWizardsApplied(res.data.appliedWizards ?? [])
} catch (err: any) {
// Dosyalar hedefe yazildi; yalnizca veritabanina uygulama ya da temizlik basarisiz oldu.
setError(err?.response?.data?.error?.message ?? err?.message ?? null)

View file

@ -209,8 +209,8 @@ function Row({ label, value }: { label: string; value?: string | number }) {
// ─── WizardStepDeploy ─────────────────────────────────────────────────────────────
/**
* Deploy adımı: önceki adımlarda toplanan her şeyin özetini gösterir ve onay sonrası
* wizard'ı sunucuya gönderir. Sunucu bu istekten menü, izin, dil, ListForm ve seed
* dosyasını üretir; özet ekranında bağlantı cümlesi maskelenerek gösterilir.
* wizard'ı sunucuya gönderir. Sunucu menü, izin, dil ve ListForm'u yalnızca seed dosyalarına
* yazar; veritabanına DB Migrate ile geçer. Özet ekranında bağlantı cümlesi maskelenerek gösterilir.
*/
const WizardStepDeploy = ({
values,