Files
arma-server-web-manager/phases/phase-07-missions-cdlc-discord-jobs.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

13 KiB
Raw Permalink Blame History

Phase 7 — Missions, CDLC, Discord, Jobs

Status: PENDING Depends on: Phase 3 (ProcessService, ServerConfigStorage); Phase 4 (DifficultyScanJob stub); Phase 5 (ModService, mod jobs stubs); Phase 6 (SteamService, run_task_loop) Next phase: phase-08-middleware-polish.md


Goal

Mission CRUD + file upload, CDLC management, Discord webhook notifications, and all APScheduler jobs registered. After this phase every domain feature is complete and the scheduler drives automated background work.


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/mission/MissionServiceImpl.java getMissions(), saveEnabledMissionList(), addMission(), deleteMission(), updateMission(), save(MultipartFile), checkMissionFileExists()
<JAVA_SRC>domain/server/mission/VanillaMissionsImporter.java Import built-in missions on startup
<JAVA_SRC>domain/server/mission/dto/Mission.java Fields: id, name, template, enabled, difficulty
<JAVA_SRC>domain/server/mission/dto/Missions.java Fields: disabledMissions, enabledMissions
<JAVA_SRC>domain/server/mission/job/MissionScannerJob.java Filesystem scan + DB sync logic
<JAVA_SRC>domain/server/cdlc/CdlcService.java findAll(), toggleCdlc(id)
<JAVA_SRC>domain/server/cdlc/CdlcFileStorageService.java Filesystem existence check
<JAVA_SRC>domain/server/cdlc/dto/Cdlc.java Fields: id, name, enabled, fileExists
<JAVA_SRC>domain/discord/DiscordIntegration.java sendMessage(MessageKind) — async, checks enabled flag
<JAVA_SRC>domain/discord/DiscordWebhookHandler.java HTTP POST to webhook URL
<JAVA_SRC>domain/discord/message/MessageKind.java Enum: SERVER_STARTED, SERVER_STARTING, SERVER_STOPPED, SERVER_UPDATING, PLAYER_JOINED
<JAVA_SRC>domain/discord/message/ServerStartedMessageCreator.java Message text
<JAVA_SRC>domain/discord/message/ServerStoppedMessageCreator.java Message text
<JAVA_SRC>domain/discord/model/DiscordMessage.java Fields: username, content, embeds:[{title,description,color}]
<JAVA_SRC>application/scheduling/AswgJob.java Base job interface: getName(), run()
<JAVA_SRC>application/scheduling/AswgTaskScheduler.java schedule(job, cron), runNow(job), cancel(name)
<JAVA_SRC>application/scheduling/JobExecutionInfoService.java saveJobExecution(), getJobHistory()
<JAVA_SRC>application/scheduling/dto/JobExecution.java Fields: jobName, startDate, finishDate, status, message
<JAVA_SRC>domain/server/difficulty/DifficultyScanJob.java Scan Users/*.Arma3Profile + DB sync
<JAVA_SRC>domain/server/mod/job/InstallDeleteModsFromFilesystemJob.java Scan mods/ + DB sync
<JAVA_SRC>domain/server/mod/job/ModSettingsScanJob.java Scan mod dirs for settings files
<JAVA_SRC>domain/server/mod/job/ModUpdateJob.java Check Steam API lastUpdated per mod
<JAVA_SRC>application/security/jwt/cleaner/InvalidJwtCleaner.java Delete expired JWT rows from DB
<JAVA_SRC>web/MissionRestController.java Exact paths and JSON shapes
<JAVA_SRC>web/MissionFilesRestController.java Mission file upload
<JAVA_SRC>web/CdlcRestController.java GET + POST toggle

Output Files to Create

src/domain/server/mission/__init__.py
src/domain/server/mission/mission_service.py
src/domain/server/mission/mission_file_storage.py  (scan MPMissions/ for .pbo files)
src/domain/server/mission/vanilla_missions_importer.py
src/domain/server/mission/jobs.py                  (MissionScannerJob)
src/domain/server/mission/models.py                (Mission, Missions dataclasses)
src/domain/server/cdlc/__init__.py
src/domain/server/cdlc/cdlc_service.py
src/domain/server/cdlc/cdlc_file_storage.py
src/domain/server/cdlc/models.py                   (Cdlc dataclass)
src/domain/discord/__init__.py
src/domain/discord/discord_integration.py
src/domain/discord/discord_webhook_handler.py
src/domain/discord/models.py                       (DiscordMessage, MessageKind enum)
src/application/scheduler/__init__.py
src/application/scheduler/scheduler.py             (APScheduler setup + job registry)
src/application/scheduler/job_execution_service.py
src/web/schemas/missions.py
src/web/schemas/cdlc.py
src/web/mission_router.py
src/web/mission_files_router.py
src/web/cdlc_router.py

Update src/main.py:

  • Register mission_router, mission_files_router, cdlc_router
  • In lifespan: scheduler.start(), register all 6 jobs, call vanilla_missions_importer.run() on first startup
  • In lifespan teardown: scheduler.shutdown()

Implementation Notes

APScheduler setup

# src/application/scheduler/scheduler.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = AsyncIOScheduler()

def schedule_job(job_fn, cron: str, job_id: str) -> None:
    try:
        scheduler.remove_job(job_id)
    except Exception:
        pass
    scheduler.add_job(job_fn, CronTrigger.from_crontab(cron), id=job_id,
                      misfire_grace_time=60)

Register in lifespan (cron strings come from settings.*_cron properties):

scheduler.start()
schedule_job(difficulty_scan_job,  settings.difficulty_scan_cron,   "difficulty_scan")
schedule_job(mission_scanner_job,  settings.mission_scan_cron,      "mission_scan")
schedule_job(mod_filesystem_job,   settings.mod_scan_cron,          "mod_filesystem_scan")
schedule_job(mod_settings_scan_job,settings.mod_settings_scan_cron, "mod_settings_scan")
schedule_job(mod_update_job,       settings.mod_update_cron,        "mod_update")
schedule_job(jwt_cleaner_job,      "0 * * * *",                     "jwt_cleaner")

Read the cron expressions from aswg-default-config.properties (Java file) and add them as settings fields.

Job execution tracking

Each job records execution in the job_execution table:

# src/application/scheduler/job_execution_service.py
async def wrap_job(job_name: str, job_fn) -> None:
    start = datetime.utcnow()
    status, message = "SUCCESS", None
    try:
        await job_fn()
    except Exception as e:
        status, message = "FAILED", str(e)
        log.error("Job %s failed: %s", job_name, e)
    finally:
        await job_execution_repo.save(JobExecutionEntity(
            job_name=job_name,
            start_date=start,
            finish_date=datetime.utcnow(),
            status=status,
            message=message,
        ))

MissionService

Missions are .pbo files in <server_dir>/MPMissions/.

async def get_missions(self) -> Missions:
    db_missions = await self.mission_repo.find_all()
    return Missions(
        disabled_missions=[m for m in db_missions if not m.enabled],
        enabled_missions=[m for m in db_missions if m.enabled],
    )

async def save_enabled_mission_list(self, missions: list[Mission]) -> None:
    await self.mission_repo.disable_all()
    for m in missions:
        await self.mission_repo.enable_by_template(m.template)
        await self.mission_repo.update_difficulty(m.template, m.difficulty)
    # Write missions block to server.cfg via ServerConfigStorage
    cfg = self.config_storage.read()
    cfg.missions = [CfgMission(template=m.template, difficulty=m.difficulty)
                    for m in missions]
    self.config_storage.write(cfg)

async def add_mission(self, name: str, template: str) -> None:
    await self.mission_repo.save(MissionEntity(name=name, template=template,
                                               enabled=False, difficulty="Regular"))

template format: "filename.MapName" e.g. "tdm_stratis.Stratis" (.pbo extension excluded).

MissionScannerJob

Scans <server_dir>/MPMissions/ for *.pbo files. Derives template name by stripping .pbo. Inserts missing DB entries, removes DB entries where file is gone.

VanillaMissionsImporter

Reads a bundled JSON/text list of vanilla Arma 3 mission templates. On first startup (@app.on_event / lifespan), insert any missing. Check existence with mission_repo.find_by_template().

CDLC service

async def find_all(self) -> list[Cdlc]:
    entities = await self.cdlc_repo.find_all()
    return [
        Cdlc(
            id=e.id, name=e.name, enabled=e.enabled,
            file_exists=(Path(settings.server_directory_path) / e.name).exists()
        )
        for e in entities
    ]

async def toggle_cdlc(self, id: int) -> None:
    entity = await self.cdlc_repo.find_by_id(id)
    entity.enabled = not entity.enabled
    await self.cdlc_repo.save(entity)

Discord Integration

# src/domain/discord/models.py
from enum import StrEnum
from dataclasses import dataclass

class MessageKind(StrEnum):
    SERVER_STARTED  = "SERVER_STARTED"
    SERVER_STARTING = "SERVER_STARTING"
    SERVER_STOPPED  = "SERVER_STOPPED"
    SERVER_UPDATING = "SERVER_UPDATING"
    PLAYER_JOINED   = "PLAYER_JOINED"

@dataclass
class DiscordMessage:
    username: str
    content: str
    embeds: list[dict]

    def to_dict(self) -> dict:
        return {"username": self.username, "content": self.content,
                "embeds": self.embeds}
# src/domain/discord/discord_integration.py
import asyncio

_MESSAGE_TEXTS = {
    MessageKind.SERVER_STARTED:  "Server has started.",
    MessageKind.SERVER_STARTING: "Server is starting...",
    MessageKind.SERVER_STOPPED:  "Server has stopped.",
    MessageKind.SERVER_UPDATING: "Server is updating...",
}

class DiscordIntegration:
    async def send_message(self, kind: MessageKind) -> None:
        if not settings.discord_enabled:
            return
        text = _MESSAGE_TEXTS.get(kind, kind.value)
        msg = DiscordMessage(username="ASWG", content=text, embeds=[])
        asyncio.create_task(self._webhook.send(msg))  # fire-and-forget

class DiscordWebhookHandler:
    async def send(self, message: DiscordMessage) -> None:
        try:
            async with httpx.AsyncClient() as client:
                await client.post(settings.discord_webhook_url,
                                  json=message.to_dict(), timeout=10.0)
        except Exception as e:
            log.warning("Discord webhook failed: %s", e)

Wire into ProcessService (edit Phase 3's process_service.py):

  • After start_server() completes: await discord.send_message(MessageKind.SERVER_STARTED)
  • After stop_server(): await discord.send_message(MessageKind.SERVER_STOPPED)

REST Endpoints

Missions (/api/v1/missions)

GET    /api/v1/missions                  → {disabledMissions, enabledMissions}  MISSION_VIEW
POST   /api/v1/missions/enabled          body:{missions:[{name,template,difficulty,enabled}]}  MISSION_UPDATE → 200
POST   /api/v1/missions/template         body:{name,template}  MISSION_ADD → 200
DELETE /api/v1/missions/template         body:{template}  MISSION_DELETE → 200
PUT    /api/v1/missions/id/{id}          body:Mission  MISSION_UPDATE → 200

Mission Files (/api/v1/missions-files)

POST   /api/v1/missions-files            multipart: file(s) + overwrite?  MISSION_UPLOAD → 200
GET    /api/v1/missions-files/{name}/exists → {exists: bool}  MISSION_VIEW

CDLC (/api/v1/cdlc)

GET    /api/v1/cdlc                      → {cdlcs:[{id,name,enabled,fileExists}]}  CDLC_VIEW
POST   /api/v1/cdlc/{id}/toggle         → 200  CDLC_UPDATE

JSON shapes

Mission:

{"id": 1, "name": "TDM Stratis", "template": "tdm_stratis.Stratis", "enabled": true, "difficulty": "Regular"}

CDLC:

{"id": 1, "name": "gm", "enabled": false, "fileExists": true}

Completion Checklist

  • GET /api/v1/missions returns {disabledMissions, enabledMissions}
  • POST /api/v1/missions/enabled persists and writes mission block to server.cfg
  • POST /api/v1/missions/template inserts mission row with enabled=false
  • POST /api/v1/missions-files saves .pbo and inserts DB record
  • GET /api/v1/cdlc computes fileExists from filesystem at request time
  • POST /api/v1/cdlc/{id}/toggle flips enabled flag
  • APScheduler starts in lifespan; all 6 jobs registered with cron from settings
  • Each job records execution row in job_execution table
  • Discord fires on server start/stop when discord.enabled=true
  • InvalidJwtCleaner hourly job deletes rows where expiration_datetime < now()
  • VanillaMissionsImporter inserts default missions on first startup

Contract for Phase 8

Phase 8 adds no new service classes. It wraps everything built in Phases 17 with:

  • Global exception handler (FastAPI @app.exception_handler)
  • SPA redirect middleware (non-API paths → index.html)
  • Optional rate limiting middleware
  • Structured logging via structlog