feat: migrate to pyproject.toml, add test suites, split CI workflows

- Replace requirements.txt with pyproject.toml for modern Python packaging
- Add unit, integration, lint and security test suites under tests/
- Add utils/export_runtime_requirements.py and utils/check_hadolint_sarif.py
- Split monolithic CI into reusable lint.yml, security.yml and tests.yml
- Refactor ci.yml to orchestrate reusable workflows; publish on semver tag only
- Modernize Dockerfile: pin python:3.12-slim, install via pyproject.toml
- Expand Makefile with lint, security, test and CI targets
- Add test-e2e via act with portfolio container stop/start around run
- Fix navbar_logo_visibility.spec.js: win.fullscreen() → win.enterFullscreen()
- Set use_reloader=False in app.run() to prevent double-start in CI
- Add app/core.* and build artifacts to .gitignore
- Fix apt-get → sudo apt-get in tests.yml e2e job
- Fix pip install --ignore-installed to handle stale act cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 23:03:09 +02:00
parent 2c61da9fc3
commit 252b50d2a7
38 changed files with 1366 additions and 165 deletions

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utilities used by the Portfolio UI web application."""

View File

@@ -1,7 +1,9 @@
import os
import hashlib
import requests
import mimetypes
import os
import requests
class CacheManager:
def __init__(self, cache_dir="static/cache"):
@@ -9,8 +11,7 @@ class CacheManager:
self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self):
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
os.makedirs(self.cache_dir, exist_ok=True)
def clear_cache(self):
if os.path.exists(self.cache_dir):
@@ -20,8 +21,10 @@ class CacheManager:
os.remove(path)
def cache_file(self, file_url):
# generate a short hash for filename
hash_suffix = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8).hexdigest()
hash_suffix = hashlib.blake2s(
file_url.encode("utf-8"),
digest_size=8,
).hexdigest()
parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1]
@@ -31,7 +34,7 @@ class CacheManager:
except requests.RequestException:
return None
content_type = resp.headers.get('Content-Type', '')
content_type = resp.headers.get("Content-Type", "")
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
filename = f"{base}_{hash_suffix}{ext}"
full_path = os.path.join(self.cache_dir, filename)
@@ -41,5 +44,4 @@ class CacheManager:
for chunk in resp.iter_content(1024):
f.write(chunk)
# return path relative to /static/
return f"cache/{filename}"

View File

@@ -32,7 +32,7 @@ def compute_card_classes(cards):
lg_classes.append("col-lg-6")
else:
lg_classes.append("col-lg-4")
# md classes: If the number of cards is even or if not the last card, otherwise "col-md-12"
# Use a full-width last card on medium screens only when the total count is odd.
md_classes = []
for i in range(num_cards):
if num_cards % 2 == 0 or i < num_cards - 1:

View File

@@ -13,22 +13,9 @@ class ConfigurationResolver:
"""
self._recursive_resolve(self.config, self.config)
def __load_children(self,path):
"""
Check if explicitly children should be loaded and not parent
"""
return path.split('.').pop() == "children"
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
if old_key in dict_origine:
# Entferne den alten Key
old_value = dict_origine.pop(old_key)
# Füge die neuen Key-Value-Paare hinzu
dict_origine.update(new_dict)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
index = list_origine.index(old_element)
list_origine[index:index+1] = new_elements
list_origine[index : index + 1] = new_elements
def _replace_element_in_list(self, list_origine, old_element, new_element):
index = list_origine.index(old_element)
@@ -42,27 +29,43 @@ class ConfigurationResolver:
for key, value in list(current_config.items()):
if key == "children":
if value is None or not isinstance(value, list):
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
raise ValueError(
"Expected 'children' to be a list, but got "
f"{type(value).__name__} instead."
)
for item in value:
if "link" in item:
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
loaded_link = self._find_entry(
root_config,
self._mapped_key(item["link"]),
False,
)
if isinstance(loaded_link, list):
self._replace_in_list_by_list(value,item,loaded_link)
self._replace_in_list_by_list(value, item, loaded_link)
else:
self._replace_element_in_list(value,item,loaded_link)
self._replace_element_in_list(value, item, loaded_link)
else:
self._recursive_resolve(value, root_config)
self._recursive_resolve(value, root_config)
elif key == "link":
try:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
if isinstance(loaded, list) and len(loaded) > 2:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
current_config.clear()
current_config.update(loaded)
except Exception as e:
except Exception as e:
raise ValueError(
f"Error resolving link '{value}': {str(e)}. "
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
f"Current part: {key}, Current config: {current_config}"
+ (
f", Loaded: {loaded}"
if "loaded" in locals() or "loaded" in globals()
else ""
)
)
else:
self._recursive_resolve(value, root_config)
@@ -70,69 +73,74 @@ class ConfigurationResolver:
for item in current_config:
self._recursive_resolve(item, root_config)
def _get_children(self,current):
if isinstance(current, dict) and ("children" in current and current["children"]):
def _get_children(self, current):
if isinstance(current, dict) and (
"children" in current and current["children"]
):
current = current["children"]
return current
def _mapped_key(self,name):
def _mapped_key(self, name):
return name.replace(" ", "").lower()
def _find_by_name(self,current, part):
def _find_by_name(self, current, part):
return next(
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
None
)
(
item
for item in current
if isinstance(item, dict)
and self._mapped_key(item.get("name", "")) == part
),
None,
)
def _find_entry(self, config, path, children):
"""
Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation.
"""
parts = path.split('.')
parts = path.split(".")
current = config
for part in parts:
if isinstance(current, list):
# If children explicit declared just load children
if part != "children":
# Look for a matching name in the list
found = self._find_by_name(current,part)
found = self._find_by_name(current, part)
if found:
current = found
print(
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
else:
raise ValueError(
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"No matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
elif isinstance(current, dict):
# Case-insensitive dictionary lookup
key = next((k for k in current if self._mapped_key(k) == part), None)
# If no fitting key was found search in the children
if key is None:
if "children" not in current:
raise KeyError(
f"No 'children' found in current dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current dictionary: {current}"
)
# The following line seems buggy; Why is children loaded allways and not just when children is set?
current = self._find_by_name(current["children"],part)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
"No 'children' found in current dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
else:
current = self._find_by_name(current["children"], part)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
else:
current = current[key]
else:
raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. "
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
)
if children:
current = self._get_children(current)