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
13 KiB
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, callvanilla_missions_importer.run()on first startup - In
lifespanteardown: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/missionsreturns{disabledMissions, enabledMissions}POST /api/v1/missions/enabledpersists and writes mission block to server.cfgPOST /api/v1/missions/templateinserts mission row withenabled=falsePOST /api/v1/missions-filessaves .pbo and inserts DB recordGET /api/v1/cdlccomputesfileExistsfrom filesystem at request timePOST /api/v1/cdlc/{id}/toggleflips enabled flag- APScheduler starts in lifespan; all 6 jobs registered with cron from settings
- Each job records execution row in
job_executiontable - Discord fires on server start/stop when
discord.enabled=true InvalidJwtCleanerhourly job deletes rows whereexpiration_datetime < now()VanillaMissionsImporterinserts default missions on first startup
Contract for Phase 8
Phase 8 adds no new service classes. It wraps everything built in Phases 1–7 with:
- Global exception handler (FastAPI
@app.exception_handler) - SPA redirect middleware (non-API paths →
index.html) - Optional rate limiting middleware
- Structured logging via
structlog