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
This commit is contained in:
313
phases/phase-07-missions-cdlc-discord-jobs.md
Normal file
313
phases/phase-07-missions-cdlc-discord-jobs.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user