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

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