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
314 lines
13 KiB
Markdown
314 lines
13 KiB
Markdown
# 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
|
||
|
||
```python
|
||
# 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):
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
# 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/`.
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
# 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}
|
||
```
|
||
|
||
```python
|
||
# 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:
|
||
```json
|
||
{"id": 1, "name": "TDM Stratis", "template": "tdm_stratis.Stratis", "enabled": true, "difficulty": "Regular"}
|
||
```
|
||
|
||
CDLC:
|
||
```json
|
||
{"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 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`
|