feat: add Java→Python migration plan with 9 self-contained phase files
Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python. Each phase file is fully self-contained: lists Java source files to read, output files to create, implementation patterns, REST endpoint contracts, and a completion checklist. A future agent can execute any single phase without rescanning the Java project. Phases: - 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas - 02: Auth & Users — JWT middleware, RBAC, user CRUD - 03: CFG parser + server process — server.cfg round-trip, start/stop - 04: Server settings — general/network/logging/security/difficulty - 05: Mod management — mod CRUD, presets, settings, WebSocket progress - 06: Steam integration — SteamCMD queue, Workshop API, python-a2s - 07: Missions, CDLC, Discord, APScheduler jobs - 08: Middleware & polish — global exception handler, SPA redirect, structlog - 09: Testing — pytest-asyncio, respx, 80% coverage target
This commit is contained in:
292
phases/phase-05-mod-management.md
Normal file
292
phases/phase-05-mod-management.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Phase 5 — Mod Management
|
||||
|
||||
**Status**: PENDING
|
||||
**Depends on**: Phase 1 complete (InstalledModRepository, ModPresetRepository, ModSettingsRepository); Phase 3 complete (ServerConfigStorage)
|
||||
**Next phase**: `phase-06-steam-integration.md`
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Four REST routers covering mods, mod file uploads, mod presets, and mod settings. Five service classes. A WebSocket handler that pushes download-progress JSON to the Angular frontend.
|
||||
|
||||
After this phase: the Mods page fully works (list, enable/disable, delete, upload). Preset management works. Mod settings CRUD works.
|
||||
|
||||
---
|
||||
|
||||
## Java Source Files to Read
|
||||
|
||||
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
|
||||
|
||||
| File | What to extract |
|
||||
|------|----------------|
|
||||
| `<JAVA_SRC>domain/server/mod/ModServiceImpl.java` | Full implementation — getModsCollection(), saveModFile(), saveEnabledModList(), manageMod(), deleteMod() |
|
||||
| `<JAVA_SRC>domain/server/mod/ModPresetServiceImpl.java` | getModPresetsNames(), getModPreset(), saveModPreset(), deletePreset(), importPreset(), selectPreset() |
|
||||
| `<JAVA_SRC>domain/server/mod/ModSettingsService.java` | getModSettingsWithoutContents(), getModSettingsContent(), saveModSettings(), deleteModSettings() |
|
||||
| `<JAVA_SRC>domain/server/mod/ModKeyServiceImpl.java` | copyKeysForMod(), clearServerKeys() |
|
||||
| `<JAVA_SRC>domain/server/mod/ModDependenciesService.java` | getDependencies(workshopFileId) |
|
||||
| `<JAVA_SRC>domain/server/mod/WorkshopModInstallProgressWebsocketHandler.java` | publishInstallationStatus() pattern |
|
||||
| `<JAVA_SRC>domain/server/mod/model/WorkshopModInstallationStatus.java` | Fields: fileId, title, status, installAttemptCount |
|
||||
| `<JAVA_SRC>domain/server/mod/model/RelatedMod.java` | Fields + Status enum (INSTALLED, NOT_INSTALLED) |
|
||||
| `<JAVA_SRC>domain/server/mod/dto/ModPreset.java` | Fields |
|
||||
| `<JAVA_SRC>domain/server/mod/dto/ModSettings.java` | Fields |
|
||||
| `<JAVA_SRC>domain/server/mod/dto/ModSettingsHeader.java` | Fields |
|
||||
| `<JAVA_SRC>domain/server/mod/dto/PresetImportParams.java` | Fields (name, list of ModParam{id, title}) |
|
||||
| `<JAVA_SRC>domain/server/mod/model/ModPresetSaveParams.java` | Fields |
|
||||
| `<JAVA_SRC>domain/server/mod/job/InstallDeleteModsFromFilesystemJob.java` | Scan logic (register in Phase 7) |
|
||||
| `<JAVA_SRC>domain/server/mod/job/ModSettingsScanJob.java` | Scan logic (register in Phase 7) |
|
||||
| `<JAVA_SRC>domain/server/mod/job/ModUpdateJob.java` | Update logic (register in Phase 7) |
|
||||
| `<JAVA_SRC>domain/server/storage/mod/FileSystemMod.java` | Fields: name, workshopFileId, modDirectory, hasFiles(), lastUpdated |
|
||||
| `<JAVA_SRC>domain/server/storage/mod/ModDirectory.java` | Fields: path, directoryName, sizeBytes |
|
||||
| `<JAVA_SRC>web/ModsRestController.java` | Exact paths and request/response JSON |
|
||||
| `<JAVA_SRC>web/ModsFilesRestController.java` | Multipart upload endpoint |
|
||||
| `<JAVA_SRC>web/ModsPresetsRestController.java` | All preset endpoints |
|
||||
| `<JAVA_SRC>web/ModSettingsRestController.java` | All mod settings endpoints |
|
||||
|
||||
---
|
||||
|
||||
## Output Files to Create
|
||||
|
||||
```
|
||||
src/domain/server/mod/__init__.py
|
||||
src/domain/server/mod/mod_service.py
|
||||
src/domain/server/mod/mod_preset_service.py
|
||||
src/domain/server/mod/mod_settings_service.py
|
||||
src/domain/server/mod/mod_key_service.py
|
||||
src/domain/server/mod/mod_dependencies_service.py
|
||||
src/domain/server/mod/mod_file_storage.py (filesystem scanner + zip extractor)
|
||||
src/domain/server/mod/models.py (Mod, ModsCollection, EnabledMod, RelatedMod, ModPreset,
|
||||
ModSettings, WorkshopModInstallationStatus)
|
||||
src/domain/server/mod/jobs.py (InstallDeleteModsJob, ModSettingsScanJob, ModUpdateJob
|
||||
— register in Phase 7)
|
||||
src/domain/server/mod/ws_handler.py (WebSocket broadcast for download progress)
|
||||
src/web/schemas/mods.py
|
||||
src/web/schemas/mod_presets.py
|
||||
src/web/schemas/mod_settings.py
|
||||
src/web/mods_router.py
|
||||
src/web/mods_files_router.py
|
||||
src/web/mods_presets_router.py
|
||||
src/web/mod_settings_router.py
|
||||
```
|
||||
|
||||
Update `src/main.py`: register all 4 routers, add `@app.websocket("/api/v1/ws/mod-install-status")`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Mod statuses
|
||||
|
||||
```python
|
||||
from enum import StrEnum
|
||||
|
||||
class ModStatus(StrEnum):
|
||||
READY = "READY"
|
||||
MISSING_FILES = "MISSING_FILES"
|
||||
MISSING_DEPENDENCY_MODS = "MISSING_DEPENDENCY_MODS"
|
||||
```
|
||||
|
||||
### ModFileStorage — filesystem scanning
|
||||
|
||||
Scans `<server_dir>/mods/` for `@`-prefixed directories. Each contains `meta.cpp`:
|
||||
|
||||
```
|
||||
publishedid = 463939057;
|
||||
name = "ACE3";
|
||||
```
|
||||
|
||||
```python
|
||||
# src/domain/server/mod/mod_file_storage.py
|
||||
import re, shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
def scan_mod_directories(server_dir: Path) -> list[FileSystemMod]:
|
||||
mods_dir = server_dir / "mods"
|
||||
mods_dir.mkdir(parents=True, exist_ok=True)
|
||||
return [_read_filesystem_mod(d) for d in mods_dir.iterdir()
|
||||
if d.is_dir() and d.name.startswith("@")]
|
||||
|
||||
def _read_filesystem_mod(mod_path: Path) -> FileSystemMod:
|
||||
meta = mod_path / "meta.cpp"
|
||||
workshop_file_id, name = 0, mod_path.name
|
||||
if meta.exists():
|
||||
text = meta.read_text(errors="ignore")
|
||||
if m := re.search(r'publishedid\s*=\s*(\d+)', text, re.I):
|
||||
workshop_file_id = int(m.group(1))
|
||||
if m := re.search(r'name\s*=\s*"([^"]+)"', text, re.I):
|
||||
name = m.group(1)
|
||||
size = sum(f.stat().st_size for f in mod_path.rglob("*") if f.is_file())
|
||||
return FileSystemMod(
|
||||
name=name,
|
||||
workshop_file_id=workshop_file_id,
|
||||
mod_directory=ModDirectory(mod_path, mod_path.name, size),
|
||||
last_updated=datetime.fromtimestamp(mod_path.stat().st_mtime),
|
||||
)
|
||||
```
|
||||
|
||||
### ModService — key methods
|
||||
|
||||
```python
|
||||
class ModService:
|
||||
async def get_mods_collection(self) -> ModsCollection:
|
||||
fs_mods = scan_mod_directories(self.server_dir)
|
||||
db_mods = await self.mod_repo.find_all()
|
||||
disabled = [m for m in db_mods if not m.enabled]
|
||||
enabled = [m for m in db_mods if m.enabled]
|
||||
not_managed = _find_not_managed(fs_mods, db_mods)
|
||||
return ModsCollection(
|
||||
disabled_mods=_to_mod_views(disabled, fs_mods),
|
||||
enabled_mods=_to_mod_views(enabled, fs_mods),
|
||||
not_managed_mods=_to_mod_views_fs(not_managed),
|
||||
)
|
||||
|
||||
async def save_mod_file(self, file: UploadFile, overwrite: bool) -> None:
|
||||
# 1. Save zip to temp; 2. Extract to server_dir/mods/; 3. Rename to lowercase
|
||||
# 4. Read meta.cpp → workshopFileId; 5. Insert InstalledModEntity
|
||||
...
|
||||
|
||||
async def save_enabled_mod_list(self, enabled_mods: list[EnabledMod]) -> None:
|
||||
await self.mod_repo.disable_all_mods()
|
||||
await self.mod_repo.enable_mods([m.workshop_file_id for m in enabled_mods])
|
||||
self.mod_key_service.clear_server_keys()
|
||||
for mod in await self.mod_repo.find_enabled():
|
||||
self.mod_key_service.copy_keys_for_mod(Path(mod.directory_path))
|
||||
```
|
||||
|
||||
### ModKeyService
|
||||
|
||||
Keys are `.bikey` files; destination is `<server_dir>/Keys/`.
|
||||
|
||||
```python
|
||||
class ModKeyService:
|
||||
def copy_keys_for_mod(self, mod_path: Path) -> None:
|
||||
for key_file in mod_path.rglob("*.bikey"):
|
||||
shutil.copy2(key_file, self.keys_dir / key_file.name)
|
||||
|
||||
def clear_server_keys(self) -> None:
|
||||
for f in self.keys_dir.glob("*.bikey"):
|
||||
f.unlink(missing_ok=True)
|
||||
```
|
||||
|
||||
### WebSocket progress handler
|
||||
|
||||
```python
|
||||
# src/domain/server/mod/ws_handler.py
|
||||
import json
|
||||
from fastapi import WebSocket
|
||||
|
||||
class ModInstallProgressHandler:
|
||||
_sessions: dict[int, WebSocket] = {}
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self._sessions[id(ws)] = ws
|
||||
|
||||
async def disconnect(self, ws: WebSocket) -> None:
|
||||
self._sessions.pop(id(ws), None)
|
||||
|
||||
async def broadcast(self, status: dict) -> None:
|
||||
msg = json.dumps(status)
|
||||
for ws in list(self._sessions.values()):
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
In `src/main.py`:
|
||||
```python
|
||||
@app.websocket("/api/v1/ws/mod-install-status")
|
||||
async def mod_install_ws(websocket: WebSocket):
|
||||
await mod_install_handler.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
await mod_install_handler.disconnect(websocket)
|
||||
```
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
**Mods** (`/api/v1/mods`) — permissions: `MODS_VIEW`, `MODS_UPDATE`, `MODS_DELETE`, `MODS_UPLOAD`
|
||||
```
|
||||
GET /api/v1/mods → {disabledMods, enabledMods, notManagedMods}
|
||||
POST /api/v1/mods/enabled body: {mods:[{workshopFileId,serverMod}]} → 200
|
||||
DELETE /api/v1/mods body: {name} → 200
|
||||
POST /api/v1/mods/manage body: {name} → 200
|
||||
DELETE /api/v1/mods/not-managed body: {directoryName} → 200
|
||||
```
|
||||
|
||||
**Mod Files** (`/api/v1/mods-files`)
|
||||
```
|
||||
POST /api/v1/mods-files multipart: file(s) + overwrite? → 200
|
||||
GET /api/v1/mods-files/{name}/exists → {exists: bool}
|
||||
```
|
||||
|
||||
**Mod Presets** (`/api/v1/mods-presets`)
|
||||
```
|
||||
GET /api/v1/mods-presets → {presets: [name,...]}
|
||||
GET /api/v1/mods-presets/{id} → ModPreset
|
||||
PUT /api/v1/mods-presets/{name} body: {modNames:[...]} → {saved: true}
|
||||
DELETE /api/v1/mods-presets/{name} → {deleted: true}
|
||||
POST /api/v1/mods-presets/import body: {name, modParams:[{id,title}]} → 200
|
||||
POST /api/v1/mods-presets/select body: {name} → 200
|
||||
```
|
||||
|
||||
**Mod Settings** (`/api/v1/mods/settings`)
|
||||
```
|
||||
GET /api/v1/mods/settings → list[ModSettingsHeader]
|
||||
GET /api/v1/mods/settings/{id} → ModSettingsHeader
|
||||
GET /api/v1/mods/settings/{id}/content → {content: str}
|
||||
PUT /api/v1/mods/settings/{id} body: ModSettings → ModSettingsHeader
|
||||
POST /api/v1/mods/settings body: ModSettings → ModSettingsHeader
|
||||
DELETE /api/v1/mods/settings/{id} → 200
|
||||
```
|
||||
|
||||
### Key JSON shape — Mod object (camelCase)
|
||||
|
||||
```json
|
||||
{
|
||||
"workshopFileId": 463939057,
|
||||
"name": "ACE3",
|
||||
"serverMod": false,
|
||||
"previewUrl": "https://example.com/preview.jpg",
|
||||
"workshopUrl": "https://steamcommunity.com/sharedfiles/filedetails/?id=463939057",
|
||||
"status": "READY",
|
||||
"sizeBytes": 104857600,
|
||||
"directoryName": "@ace",
|
||||
"lastWorkshopUpdateDateTime": "2024-01-01T00:00:00",
|
||||
"lastWorkshopUpdateAttemptDateTime": null
|
||||
}
|
||||
```
|
||||
|
||||
### Jobs (create here, register in Phase 7)
|
||||
|
||||
- `InstallDeleteModsFromFilesystemJob`: scans `<server_dir>/mods/` and syncs DB (insert new found mods, mark missing ones).
|
||||
- `ModSettingsScanJob`: scans mod dirs for settings files; inserts new entries into `mod_settings` table.
|
||||
- `ModUpdateJob`: checks Steam Web API `lastUpdated` per enabled mod; schedules SteamCMD update if newer.
|
||||
|
||||
---
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
- [ ] `GET /api/v1/mods` returns `{disabledMods, enabledMods, notManagedMods}`
|
||||
- [ ] `POST /api/v1/mods/enabled` persists enabled list and copies `.bikey` files
|
||||
- [ ] `POST /api/v1/mods-files` saves .zip upload and registers mod in DB
|
||||
- [ ] `GET /api/v1/mods-files/{name}/exists` returns `{exists: bool}`
|
||||
- [ ] `GET /api/v1/mods-presets` returns sorted preset name list
|
||||
- [ ] `PUT /api/v1/mods-presets/{name}` saves preset
|
||||
- [ ] `POST /api/v1/mods-presets/select` activates preset
|
||||
- [ ] `GET /api/v1/mods/settings` returns list without content
|
||||
- [ ] `GET /api/v1/mods/settings/{id}/content` returns raw content string
|
||||
- [ ] WebSocket `/api/v1/ws/mod-install-status` accepts connections and broadcasts JSON
|
||||
- [ ] All endpoints return 403 without required permission
|
||||
|
||||
## Contract for Phase 6
|
||||
|
||||
Phase 6 imports:
|
||||
- `from src.domain.server.mod.mod_service import ModService`
|
||||
- `from src.domain.server.mod.models import WorkshopModInstallationStatus`
|
||||
- `from src.domain.server.mod.ws_handler import ModInstallProgressHandler`
|
||||
- `from src.domain.server.mod.mod_file_storage import scan_mod_directories`
|
||||
Reference in New Issue
Block a user