Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci-comprehensive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ jobs:
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
INSTALLATION_CONFIG_DIR: ${{ github.workspace }}/.test_installation_config
run: |
pytest -m integration -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing

Expand Down Expand Up @@ -453,6 +454,7 @@ jobs:
FLASK_APP: app.py
FLASK_ENV: testing
PYTHONPATH: ${{ github.workspace }}
INSTALLATION_CONFIG_DIR: ${{ github.workspace }}/.test_installation_config
run: |
pytest -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing \
--junitxml=junit.xml --maxfail=5
Expand Down
3 changes: 3 additions & 0 deletions app/models/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta
from decimal import Decimal
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.orm.attributes import flag_modified
from app import db
from .client_prepaid_consumption import ClientPrepaidConsumption
import secrets
Expand Down Expand Up @@ -215,12 +216,14 @@ def set_custom_field(self, key, value):
self.custom_fields = {}
self.custom_fields[key] = value
self.updated_at = datetime.utcnow()
flag_modified(self, "custom_fields")

def remove_custom_field(self, key):
"""Remove a custom field"""
if self.custom_fields and key in self.custom_fields:
del self.custom_fields[key]
self.updated_at = datetime.utcnow()
flag_modified(self, "custom_fields")

def get_rendered_links(self):
"""Get all rendered links from active link templates that match this client's custom fields"""
Expand Down
7 changes: 6 additions & 1 deletion app/models/client_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ class ClientNote(db.Model):
content = db.Column(db.Text, nullable=False)

# Reference to client
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True)
client_id = db.Column(
db.Integer,
db.ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
index=True,
)

# Author of the note
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
Expand Down
39 changes: 33 additions & 6 deletions app/models/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
from datetime import datetime
import os
import threading

from app import db
from app.config import Config
import os

# Re-entrancy guard: avoid add+commit when get_settings is called from inside a flush/commit
_creating_settings = threading.local()


def _session_in_flush(session):
"""Return True if the session is currently in a flush (to avoid nested add+commit)."""
try:
# SQLAlchemy sets _flushing on the session during flush
if getattr(session, "_flushing", False):
return True
# Fallback: in a transaction and inside a flush context (if exposed)
if getattr(session, "in_transaction", lambda: False)() and getattr(
session, "_current_flush_context", None
) is not None:
return True
return False
except Exception:
return False


class Settings(db.Model):
Expand Down Expand Up @@ -482,20 +503,26 @@ def get_settings(cls):
return cls()

# Avoid performing session writes during flush/commit phases.
# When called from default column factories (e.g., created_at=local_now),
# When called from default column factories or listeners during flush,
# SQLAlchemy may be in the middle of a flush. Writing here would raise
# SAWarnings/ResourceClosedError. In that case, return a transient instance
# with sensible defaults; the persistent row can be created later by
# initialization code or explicit admin flows.
# SAWarnings/ResourceClosedError. Skip add+commit and return a transient
# instance; the persistent row can be created later by init or admin flows.
try:
if not getattr(db.session, "_flushing", False):
if getattr(_creating_settings, "active", False):
return cls()
if _session_in_flush(db.session):
return cls()
try:
_creating_settings.active = True
# Create new settings instance initialized from environment variables
settings = cls()
# Initialize from environment variables (.env file)
cls._initialize_from_env(settings)
db.session.add(settings)
db.session.commit()
return settings
finally:
_creating_settings.active = False
except Exception:
# If anything goes wrong creating the persistent row, rollback and
# fall back to an in-memory Settings instance.
Expand Down
14 changes: 8 additions & 6 deletions app/utils/installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,19 @@
class InstallationConfig:
"""Manages installation-specific configuration"""

CONFIG_DIR = "/data"
CONFIG_DIR = "/data" # default; overridden by INSTALLATION_CONFIG_DIR when set
CONFIG_FILE = "installation.json"

def __init__(self):
self.config_path = os.path.join(self.CONFIG_DIR, self.CONFIG_FILE)
self._ensure_config_dir()
effective_dir = os.environ.get("INSTALLATION_CONFIG_DIR", self.CONFIG_DIR)
self.config_path = os.path.join(effective_dir, self.CONFIG_FILE)
self._ensure_config_dir(effective_dir)
self._config = self._load_config()

def _ensure_config_dir(self):
"""Ensure the configuration directory exists"""
os.makedirs(self.CONFIG_DIR, exist_ok=True)
def _ensure_config_dir(self, config_dir=None):
"""Ensure the configuration directory exists."""
dir_path = config_dir if config_dir is not None else os.environ.get("INSTALLATION_CONFIG_DIR", self.CONFIG_DIR)
os.makedirs(dir_path, exist_ok=True)

def _load_config(self) -> Dict:
"""Load configuration from file"""
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import os
import tempfile
import uuid

# Set before app is imported so InstallationConfig uses a writable dir in tests (avoids /data on CI)
if "INSTALLATION_CONFIG_DIR" not in os.environ:
os.environ["INSTALLATION_CONFIG_DIR"] = tempfile.mkdtemp(prefix="timetracker_install_")

from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy.pool import NullPool
Expand Down
1 change: 1 addition & 0 deletions tests/test_installation_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def temp_config_dir(tmp_path):
def installation_config(temp_config_dir, monkeypatch):
"""Create an InstallationConfig instance with temporary directory"""
monkeypatch.setattr("app.utils.installation.InstallationConfig.CONFIG_DIR", temp_config_dir)
monkeypatch.setenv("INSTALLATION_CONFIG_DIR", temp_config_dir)
config = InstallationConfig()
return config

Expand Down
Loading