Files
arma-server-web-manager/phases/phase-05-mod-management.md
Khoa (Revenovich) Tran Gia e02db3ddde 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
2026-04-14 15:06:56 +07:00

293 lines
12 KiB
Markdown

# 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`