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
12 KiB
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
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";
# 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
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/.
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
# 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:
@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)
{
"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 intomod_settingstable.ModUpdateJob: checks Steam Web APIlastUpdatedper enabled mod; schedules SteamCMD update if newer.
Completion Checklist
GET /api/v1/modsreturns{disabledMods, enabledMods, notManagedMods}POST /api/v1/mods/enabledpersists enabled list and copies.bikeyfilesPOST /api/v1/mods-filessaves .zip upload and registers mod in DBGET /api/v1/mods-files/{name}/existsreturns{exists: bool}GET /api/v1/mods-presetsreturns sorted preset name listPUT /api/v1/mods-presets/{name}saves presetPOST /api/v1/mods-presets/selectactivates presetGET /api/v1/mods/settingsreturns list without contentGET /api/v1/mods/settings/{id}/contentreturns raw content string- WebSocket
/api/v1/ws/mod-install-statusaccepts 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 ModServicefrom src.domain.server.mod.models import WorkshopModInstallationStatusfrom src.domain.server.mod.ws_handler import ModInstallProgressHandlerfrom src.domain.server.mod.mod_file_storage import scan_mod_directories