Feature/ui (#1)
Deploy bin / deploy (push) Successful in 1s
Deploy agebd python package / deploy (push) Successful in 3s

- Add ServiceManager.ui
- Update ServiceManager.ui when adding a new service
- Better git error handling (clean abort if remote branch already exists)

---------

Co-authored-by: Benjamin Labrecque <labrecque.benji@gmail.com>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-09 15:06:13 +02:00
co-authored by Benjamin Labrecque
parent 5c532fb92c
commit 2194f65fe7
8 changed files with 2339 additions and 78 deletions
+41 -29
View File
@@ -13,46 +13,58 @@ def git_push_changes(
master_hla_names_rel_path: str,
master_ioc_subs_rel_path: str,
iocs_overview_rel_path: str,
service_manager_ui_rel_path: str,
commit_msg: str,
):
"""
Push newly created service to Gitea
"""
files_to_stage = [
service_dir_rel_path,
registry_file_rel_path,
master_hla_names_rel_path,
master_ioc_subs_rel_path,
iocs_overview_rel_path,
service_manager_ui_rel_path,
]
logger.info(f"Staging files inside: {files_to_stage}")
repo.index.add(files_to_stage)
# Check if there are changes to avoid empty commit crashes
if not repo.is_dirty(index=True, working_tree=False):
logger.info("No changes detected. Skipping commit.")
return
logger.info("Committing changes...")
repo.index.commit(commit_msg)
try:
logger.info(
"Staging files inside: "
f"{service_dir_rel_path}, "
f"{registry_file_rel_path}, "
f"{master_hla_names_rel_path}, "
f"{master_ioc_subs_rel_path}..."
f"{iocs_overview_rel_path}..."
)
repo.index.add(
[
service_dir_rel_path,
registry_file_rel_path,
master_hla_names_rel_path,
master_ioc_subs_rel_path,
iocs_overview_rel_path,
]
)
# Check if there are changes to avoid empty commit crashes
if not repo.is_dirty(index=True, working_tree=False):
logger.info("No changes detected. Skipping commit.")
return
logger.info("Committing changes...")
repo.index.commit(commit_msg)
logger.info(f"Pushing branch '{branch_name}' to origin...")
origin = repo.remote(name="origin")
origin.push(refspec=f"{branch_name}:{branch_name}").raise_if_error()
except git.GitCommandError as e:
logger.error(f"Push failed due to an error: {e}")
logger.info("Rolling back local Git index changes...")
logger.info("Successfully pushed!")
# Step A: Undo the local commit if it was created (moves HEAD back 1 commit, keeps changes)
# This prevents leaving a dead/unpushed commit on your local branch
try:
repo.head.reset("HEAD~1", index=True, working_tree=False)
except Exception:
# Fallback if the exception happened BEFORE the commit step even ran
pass
except Exception as e:
logger.info(f"Git Operation Failed: {e}")
# Step B: Unstage the specific files (Equivalent to: git reset HEAD <files>)
# This leaves the files intact but removes them from the staging area
repo.index.reset(paths=files_to_stage)
logger.info("Staging area successfully reset to clean state.")
# TODO: we could also force push... need to think about workflow though.
raise RuntimeError(
f"Failed to push to git due to: {e}.\nDoes the branch already exist in Gitea? If yes, it needs to be deleted first."
)
logger.info("Successfully pushed!")
def delete_local_branch(repo: git.Repo, branch_name: str):
+180 -2
View File
@@ -1,8 +1,9 @@
import re
from typing import ClassVar
import xml.etree.ElementTree as ET
from typing import Any, ClassVar
import yaml
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, ConfigDict, field_validator
from core.enums import ServiceStatus
from core.utils import get_git_root
@@ -17,6 +18,11 @@ MASTER_HLA_NAMES_FILENAME = MASTER_SERVICE_CONFIG_DIR / "hla_names.yml"
IOCS_OVERVIEW_FILENAME = REPO_ROOT / "docs" / "ioc" / "ioc_overview.md"
SERVICE_MANAGER_UI_FILENAME = REPO_ROOT / "qt" / "A_BD_ServiceManager.ui"
MASTER_SERVICE_DIR = REPO_ROOT / "services" / "master" / "current"
MASTER_IOC_SUBS_FILE = MASTER_SERVICE_DIR / "ioc" / "AGEBD-CPCL-MASTER_main.subs"
class Service(BaseModel):
name: str
@@ -127,3 +133,175 @@ class IocsOverview(BaseModel):
def write_to_file(self):
with open(self.config_path, "w") as f:
f.write(self.content)
class MasterIocSubs(BaseModel):
config_path: ClassVar[str] = str(MASTER_IOC_SUBS_FILE)
content: str
@classmethod
def read_from_file(cls) -> "MasterIocSubs":
with open(cls.config_path) as f:
file_content = f.read()
return MasterIocSubs(content=file_content)
def update_content(
self,
service_name: str,
starton: str = "0", # TODO: ok defaults?
autooff: str = "0", # TODO: ok defaults?
):
"""
Parses the configuration text and appends a new row before the closing bracket.
"""
quoted_service = f'"{service_name}"'
new_row = f' {{ "{{{{ agebd_env }}}}", {quoted_service:<22} , "{starton}", "{autooff}" }}'
# Use regex to find the last closing curly brace of the configuration block
# This targets the line that contains only a lone closing brace, optional spaces, and maybe a comma/semicolon.
pattern = r"(\s*\n\s*\}(?:;|,)?\s*$)"
if re.search(pattern, self.content, re.MULTILINE):
# Insert our new row right before that closing brace line
self.content = re.sub(
pattern, f"\n{new_row}\\1", self.content, count=1, flags=re.MULTILINE
)
else:
raise ValueError(
"Could not locate the closing structure format '}' inside the configuration."
)
def write_to_file(self) -> None:
with open(self.config_path, "w") as f:
f.write(self.content)
# TODO: maybe separate models into their own files
class ServiceManagerUI(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
config_path: ClassVar[str] = str(SERVICE_MANAGER_UI_FILENAME)
tree: Any
@classmethod
def read_from_file(cls) -> "ServiceManagerUI":
tree = ET.parse(cls.config_path)
return ServiceManagerUI(tree=tree)
def update_xml(self, service: Service, ui_name: str, panelcmd: str) -> None:
num_macro_elements = self._update_macro(service=service, ui_name=ui_name, panelcmd=panelcmd)
self._update_heights(num_macro_elements=num_macro_elements)
def _update_macro(self, service: Service, ui_name: str, panelcmd: str) -> int:
new_macro_element = _ServiceManagerUIMacroElement(
service_name_upper=service.name_upper, ui_name=ui_name, panelcmd=panelcmd
)
num_macro_elements = None
root = self.tree.getroot()
for widget in root.iter("widget"):
if widget.get("class") == "caInclude" and widget.get("name") == "cainclude":
for prop in widget.findall("property"):
# Update the macro data string
if prop.get("name") == "macro":
string_elem = prop.find("string")
if string_elem is not None and string_elem.text is not None:
string_elem.text = (
string_elem.text + ";" + new_macro_element.to_string()
)
# Dynamically update the count match so caInclude renders all rows
if prop.get("name") == "numberOfItems":
number_elem = prop.find("number")
assert number_elem is not None and number_elem.text is not None
num_macro_elements = int(number_elem.text)
number_elem.text = str(num_macro_elements + 1)
assert num_macro_elements is not None, "could not find number of macro elements in xml"
return num_macro_elements
def _update_heights(self, num_macro_elements: int) -> None:
"""
There are 3 heights to be adjusted:
1. Main window
2. Tab widget
3. Widget inside the tab widget
"""
root = self.tree.getroot()
ca_include_height = self._get_height_cainclude()
height_to_add = int(
(ca_include_height / num_macro_elements * (num_macro_elements + 1)) - ca_include_height
)
# 1. Main window
mainwindow = root.find(".//widget[@class='QMainWindow']")
if mainwindow is not None:
rect = mainwindow.find("./property[@name='geometry']/rect")
assert rect is not None
height = rect.find("height")
assert height is not None and height.text is not None
height.text = str(int(height.text) + height_to_add)
for widget in root.iter("widget"):
widget_name = widget.get("name")
# 2. Tab widget
if widget_name == "tabWidget":
rect = widget.find("./property[@name='geometry']/rect")
assert rect is not None
height = rect.find("height")
assert height is not None and height.text is not None
height.text = str(int(height.text) + height_to_add)
# 3. Widget inside the tab widget
elif widget_name == "cainclude":
rect = widget.find("./property[@name='geometry']/rect")
assert rect is not None
height = rect.find("height")
assert height is not None and height.text is not None
height.text = str(int(height.text) + height_to_add)
def _get_height_cainclude(self) -> int:
"""
Get the height of the area that includes all the services
"""
root = self.tree.getroot()
cainclude_height = None
for widget in root.iter("widget"):
widget_name = widget.get("name")
# Target the caInclude Widget of Required Services
if widget_name == "cainclude":
rect = widget.find("./property[@name='geometry']/rect")
assert rect is not None
height = rect.find("height")
assert height is not None and height.text is not None
cainclude_height = int(height.text)
assert cainclude_height, "could not get height of cainclude widget"
return cainclude_height
def write_to_file(self) -> None:
self.tree.write(self.config_path, encoding="utf-8", xml_declaration=True)
class _ServiceManagerUIMacroElement(BaseModel):
service_name_upper: str
ui_name: str
panel: int = 1
svc_exist: int = 1
panelcmd: str
def to_string(self) -> str:
return (
f"IOC=AGEBD-CPCL-{self.service_name_upper},"
f"service={self.service_name_upper},"
f"name={self.ui_name},"
f"panel={self.panel},"
f"svc_exist={self.svc_exist},"
f"panelcmd={self.panelcmd}"
)
+22 -41
View File
@@ -14,7 +14,14 @@ from core.git import (
git_push_changes,
switch_branch,
)
from core.models import IocsOverview, MasterHLANames, Service, ServiceRegistry
from core.models import (
IocsOverview,
MasterHLANames,
MasterIocSubs,
Service,
ServiceManagerUI,
ServiceRegistry,
)
from core.utils import get_git_root
logger = logging.getLogger(__name__)
@@ -23,16 +30,16 @@ REPO_ROOT = get_git_root(__file__)
SERVICE_TEMPLATES_DIR = REPO_ROOT / "templates" / "service"
SERVICE_DEST_DIR = REPO_ROOT / "services"
MASTER_SERVICE_DIR = REPO_ROOT / "services" / "master" / "current"
MASTER_IOC_SUBS_FILE = MASTER_SERVICE_DIR / "ioc" / "AGEBD-CPCL-MASTER_main.subs"
# TODO: lots of hardcoded paths. Create config object?
class ServiceCreator:
def __init__(self, user: str, ioc_description: str) -> None:
def __init__(self, user: str, ioc_description: str, ui_name: str, panelcmd: str) -> None:
self.user = user
self.ioc_description = ioc_description
self.ui_name = ui_name
self.panelcmd = panelcmd
def add_service(self, name: str):
repo = git.Repo(REPO_ROOT)
@@ -49,6 +56,12 @@ class ServiceCreator:
iocs_overview_old = IocsOverview.read_from_file()
iocs_overview = IocsOverview.read_from_file()
iocs_overview.add_ioc(service, description=self.ioc_description)
master_ioc_subs_old = MasterIocSubs.read_from_file()
master_ioc_subs = MasterIocSubs.read_from_file()
master_ioc_subs.update_content(service_name=service.name_upper)
service_manager_old = ServiceManagerUI.read_from_file()
service_manager = ServiceManagerUI.read_from_file()
service_manager.update_xml(service=service, ui_name=self.ui_name, panelcmd=self.panelcmd)
branch_name = f"feature/add-service-{service.dir_name}"
delete_local_branch(repo, branch_name)
@@ -63,12 +76,8 @@ class ServiceCreator:
registry.write_to_config()
hla_names.write_to_config()
iocs_overview.write_to_file()
self._append_to_master_ioc_subs(
service_name=service.name_upper,
starton="0", # TODO: ok?
autooff="0", # TODO: ok?
)
master_ioc_subs.write_to_file()
service_manager.write_to_file()
# TODO: can't push if branch already exists in remote
git_push_changes(
@@ -80,6 +89,7 @@ class ServiceCreator:
master_hla_names_rel_path="services/master/current/app/config/hla_names.yml",
master_ioc_subs_rel_path="services/master/current/ioc/AGEBD-CPCL-MASTER_main.subs",
iocs_overview_rel_path="docs/ioc/ioc_overview.md",
service_manager_ui_rel_path="qt/A_BD_ServiceManager.ui",
commit_msg=f"feature: add new service {service.name_lower}",
)
switch_branch(repo, original_branch)
@@ -88,6 +98,8 @@ class ServiceCreator:
registry_old.write_to_config()
hla_names_old.write_to_config()
iocs_overview_old.write_to_file()
master_ioc_subs_old.write_to_file()
service_manager_old.write_to_file()
switch_branch(repo, original_branch)
raise e
@@ -137,34 +149,3 @@ class ServiceCreator:
logger.error(f"Exit Code: {e.returncode}")
logger.error(f"Details:\n{e.stderr}")
raise UVError(e.stderr) from e
def _append_to_master_ioc_subs(
self,
service_name: str,
starton: str,
autooff: str,
) -> None:
"""
Parses the configuration text and appends a new row before the closing bracket.
"""
with open(MASTER_IOC_SUBS_FILE) as f:
file_content = f.read()
quoted_service = f'"{service_name}"'
new_row = f' {{ "{{{{ agebd_env }}}}", {quoted_service:<22} , "{starton}", "{autooff}" }}'
# Use regex to find the last closing curly brace of the configuration block
# This targets the line that contains only a lone closing brace, optional spaces, and maybe a comma/semicolon.
pattern = r"(\s*\n\s*\}(?:;|,)?\s*$)"
if re.search(pattern, file_content, re.MULTILINE):
# Insert our new row right before that closing brace line
updated_content = re.sub(
pattern, f"\n{new_row}\\1", file_content, count=1, flags=re.MULTILINE
)
with open(MASTER_IOC_SUBS_FILE, "w") as f:
f.write(updated_content)
else:
raise ValueError(
"Could not locate the closing structure format '}' inside the configuration."
)
+11 -2
View File
@@ -16,12 +16,21 @@ def add(
ioc_description: str = typer.Option(
..., "--ioc-description", "-d", help="See <repo-root>/docs/ioc/ioc_overview.md for examples"
),
ui_name: str = typer.Option(
..., "--ui-name", help="See <repo-root>/qt/A_BD_ServiceManager.ui for examples"
),
panelcmd: str = typer.Option(
..., "--panelcmd", "-p", help="See <repo-root>/qt/A_BD_ServiceManager.ui for examples"
),
):
init_logging()
ServiceCreator(
service_creator = ServiceCreator(
user=user, # TODO: read from 'whoami'?
ioc_description=ioc_description,
).add_service(name=name)
ui_name=ui_name, # TODO: make this standard?
panelcmd=panelcmd, # TODO: make this standard?
)
service_creator.add_service(name=name)
@service.command()