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
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ updates:
labels:
- dependencies
- python
# Gunicorn 26 removed the eventlet worker used by docker/start-fixed.py.
ignore:
- dependency-name: gunicorn
update-types: ["version-update:semver-major"]

- package-ecosystem: npm
directory: /
Expand Down
20 changes: 15 additions & 5 deletions .github/workflows/migration-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,16 @@ jobs:
- name: Check for migration changes
id: migration_check
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
# PRs use the PR base; pushes use the previous tip (github.event.before).
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
# Compare against the PR base (not HEAD~1) so multi-commit PRs are covered
# All-zero "before" means first push / force-push with no prior tip.
if [ "${BASE_SHA:-}" = "0000000000000000000000000000000000000000" ]; then
BASE_SHA=""
fi
# Compare against the PR/push base (not HEAD~1) so multi-commit ranges are covered
if [ -z "$BASE_SHA" ]; then
echo "⚠️ No PR base SHA; falling back to HEAD~1"
echo "⚠️ No PR/push base SHA; falling back to HEAD~1"
DIFF_RANGE="HEAD~1"
else
DIFF_RANGE="${BASE_SHA}...HEAD"
Expand All @@ -88,16 +93,21 @@ jobs:
DATABASE_URL: postgresql+psycopg2://test_user:test_password@localhost:5432/test_db
FLASK_APP: app.py
FLASK_ENV: testing
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
set -euo pipefail

# All-zero "before" means first push / force-push with no prior tip.
if [ "${BASE_SHA:-}" = "0000000000000000000000000000000000000000" ]; then
BASE_SHA=""
fi

echo "🔍 Validating migration consistency..."
echo "Current directory: $(pwd)"
echo "Migration files:"
ls -la migrations/versions/ | tail -5

# Ensure PR base commit is available for checkout (fork PRs / shallow edges)
# Ensure PR/push base commit is available for checkout (fork PRs / shallow edges)
if [ -n "${BASE_SHA:-}" ]; then
git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin "$BASE_SHA" || true
fi
Expand Down
1 change: 0 additions & 1 deletion app/integrations/microsoft_teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,6 @@ def _resolve_user_from_activity(self, activity: Dict[str, Any]):
return User.query.get(owner_id)
return None


def send_message(self, channel_id: str, message: str) -> Dict[str, Any]:
"""Send a message to a Teams channel."""
token = self.get_access_token()
Expand Down
5 changes: 1 addition & 4 deletions app/models/kanban_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,7 @@ def wip_violation_message(cls, project_id, new_status, task_id=None):
if not column or not column.has_wip_limit():
return None
if column.would_exceed_wip(project_id, extra=1, exclude_task_id=task_id):
return (
f'Column "{column.label}" has reached its work-in-progress limit '
f"({column.wip_limit} tasks)."
)
return f'Column "{column.label}" has reached its work-in-progress limit ' f"({column.wip_limit} tasks)."
return None

def to_dict(self):
Expand Down
14 changes: 9 additions & 5 deletions app/routes/api_scim.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ def _user_to_scim(user: User) -> dict:
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat() + "Z" if user.created_at else None,
"lastModified": (user.last_login or user.created_at).isoformat() + "Z"
if (user.last_login or user.created_at)
else None,
"lastModified": (
(user.last_login or user.created_at).isoformat() + "Z" if (user.last_login or user.created_at) else None
),
"location": f"/scim/v2/Users/{user.id}",
},
}
Expand Down Expand Up @@ -334,7 +334,7 @@ def _apply_user_fields(user: User, data: dict) -> None:
user.username = str(data["userName"]).strip().lower()
if "externalId" in data:
val = data.get("externalId")
user.scim_external_id = (str(val).strip() if val else None)
user.scim_external_id = str(val).strip() if val else None
if "active" in data and data["active"] is not None:
user.is_active = bool(data["active"])
name = _parse_name_from_scim(data)
Expand Down Expand Up @@ -418,7 +418,11 @@ def scim_patch_user(user_id: str):
deactivated = not user.is_active
if not safe_commit("scim_patch_user", {"user_id": user.id}):
return _scim_error("Failed to patch user", 500)
_audit("updated" if not deactivated else "updated", user, "SCIM user patched" + (" (deactivated)" if deactivated and not user.is_active else ""))
_audit(
"updated" if not deactivated else "updated",
user,
"SCIM user patched" + (" (deactivated)" if deactivated and not user.is_active else ""),
)
return jsonify(_user_to_scim(user)), 200, {"Content-Type": SCIM_JSON}


Expand Down
25 changes: 14 additions & 11 deletions app/routes/api_v1_resource_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,18 @@ def create_resource_allocation():
return validation_error_response({"estimated_hours": ["Must be a number"]})

db.session.commit()
return jsonify(
{
"allocation": {
"user_id": user.id,
"task_id": task.id,
"project_id": task.project_id,
"date": due_date.isoformat(),
"planned_hours": float(task.estimated_hours or 0),
"task_name": task.name,
return (
jsonify(
{
"allocation": {
"user_id": user.id,
"task_id": task.id,
"project_id": task.project_id,
"date": due_date.isoformat(),
"planned_hours": float(task.estimated_hours or 0),
"task_name": task.name,
}
}
}
), 201
),
201,
)
14 changes: 10 additions & 4 deletions app/routes/auth_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@ def saml_acs():

attributes = auth.get_attributes() or {}
nameid = auth.get_nameid() or ""
issuer = (current_app.config.get("SAML_IDP_ENTITY_ID") or auth.get_issuers() or [""])[0] if hasattr(
auth, "get_issuers"
) else (current_app.config.get("SAML_IDP_ENTITY_ID") or "")
issuer = (
(current_app.config.get("SAML_IDP_ENTITY_ID") or auth.get_issuers() or [""])[0]
if hasattr(auth, "get_issuers")
else (current_app.config.get("SAML_IDP_ENTITY_ID") or "")
)
try:
issuers = auth.get_issuers() or []
if issuers:
Expand All @@ -153,7 +155,11 @@ def _attr(keys):
return vals
return None

email = (_attr(["email", "mail", "Email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]) or nameid or "").strip().lower() or None
email = (
_attr(["email", "mail", "Email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"])
or nameid
or ""
).strip().lower() or None
given = _attr(["givenName", "firstName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"]) or ""
family = _attr(["sn", "surname", "lastName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"]) or ""
display = _attr(["displayName", "cn", "http://schemas.microsoft.com/identity/claims/displayname"]) or ""
Expand Down
4 changes: 1 addition & 3 deletions app/routes/bank_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ def reconciliation_confirm(txn_id):
flash(_("Invoice is required"), "error")
return redirect(url_for("bank_reconciliation.reconciliation_queue"))

result = BankReconciliationService().confirm_match(
txn_id, invoice_id, received_by=current_user.id
)
result = BankReconciliationService().confirm_match(txn_id, invoice_id, received_by=current_user.id)
if result.get("success"):
flash(_("Payment recorded and transaction matched"), "success")
else:
Expand Down
1 change: 1 addition & 0 deletions app/routes/integrations_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ def teams_bot_messages():
body = result.get("body") or {}
return jsonify(body), status


# =====================================================================
# Slack Attendance (workspace-level /in, /brb, /back, /out)
# =====================================================================
Expand Down
7 changes: 4 additions & 3 deletions app/routes/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1494,9 +1494,10 @@ def new_adjustment():
)

if location:
stock = updated_stock or WarehouseStock.query.filter_by(
warehouse_id=warehouse_id, stock_item_id=stock_item_id
).first()
stock = (
updated_stock
or WarehouseStock.query.filter_by(warehouse_id=warehouse_id, stock_item_id=stock_item_id).first()
)
if stock:
stock.location = location

Expand Down
4 changes: 1 addition & 3 deletions app/routes/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,9 +1296,7 @@ def export_invoice_xrechnung(invoice_id):
try:
xml, _sha = build_xrechnung_ubl_invoice_xml(invoice, settings, client)
fn = f"{invoice.invoice_number}-xrechnung.xml"
return Response(
xml, mimetype="application/xml", headers={"Content-Disposition": f"attachment; filename={fn}"}
)
return Response(xml, mimetype="application/xml", headers={"Content-Disposition": f"attachment; filename={fn}"})
except Exception as e:
from app.utils.error_reporting import log_and_capture

Expand Down
8 changes: 5 additions & 3 deletions app/routes/oauth_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ def _authenticate_client() -> tuple[OAuthApplication | None, str | None]:
return None, "invalid_client"

client_id = client_id or request.form.get("client_id") or (request.get_json(silent=True) or {}).get("client_id")
client_secret = client_secret or request.form.get("client_secret") or (request.get_json(silent=True) or {}).get(
"client_secret"
client_secret = (
client_secret or request.form.get("client_secret") or (request.get_json(silent=True) or {}).get("client_secret")
)

if not client_id:
Expand Down Expand Up @@ -350,7 +350,9 @@ def admin_create_app():
name = (request.form.get("name") or "").strip()
description = (request.form.get("description") or "").strip()
redirect_uris_raw = (request.form.get("redirect_uris") or "").strip()
allowed_scopes = (request.form.get("allowed_scopes") or "read:projects,read:time_entries,write:time_entries").strip()
allowed_scopes = (
request.form.get("allowed_scopes") or "read:projects,read:time_entries,write:time_entries"
).strip()
is_confidential = request.form.get("is_confidential") == "1"

if not name:
Expand Down
10 changes: 6 additions & 4 deletions app/services/bank_reconciliation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,7 @@ def suggest_matches(
open_statuses = ("unpaid", "partially_paid")
candidates = Invoice.query.filter(Invoice.payment_status.in_(open_statuses)).all()

ref_haystack = " ".join(
filter(None, [transaction.reference or "", transaction.counterparty or ""])
).lower()
ref_haystack = " ".join(filter(None, [transaction.reference or "", transaction.counterparty or ""])).lower()

scored: List[Tuple[float, Invoice]] = []
for inv in candidates:
Expand Down Expand Up @@ -243,7 +241,11 @@ def confirm_match(
txn.matched_invoice_id = invoice.id
txn.matched_payment_id = payment.id
if not safe_commit("bank_txn_confirm", {"txn_id": txn.id, "invoice_id": invoice.id}):
return {"success": False, "message": "Payment created but could not update transaction", "error": "database_error"}
return {
"success": False,
"message": "Payment created but could not update transaction",
"error": "database_error",
}

return {"success": True, "message": "Matched", "transaction": txn, "payment": payment}

Expand Down
4 changes: 1 addition & 3 deletions app/services/external_identity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,7 @@ def provision_external_user(
settings = Settings.get_settings()
user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0)
except Exception:
current_app.logger.debug(
"Could not apply default daily working hours for external user", exc_info=True
)
current_app.logger.debug("Could not apply default daily working hours for external user", exc_info=True)

role_obj = Role.query.filter_by(name=role_name).first()
if role_obj:
Expand Down
4 changes: 1 addition & 3 deletions app/services/team_chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ def get_channel(self, channel_id: int) -> Optional[ChatChannel]:
def user_can_access_channel(self, channel_id: int, user_id: int, *, is_admin: bool = False) -> bool:
if is_admin:
return True
return (
ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=user_id).first() is not None
)
return ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=user_id).first() is not None

def list_messages(
self,
Expand Down
1 change: 0 additions & 1 deletion app/utils/auth_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,3 @@ def forgot_password_available(auth_method: str | None) -> bool:
def ldap_enabled_from_auth_method(auth_method: str | None) -> bool:
"""LDAP auth is active for this AUTH_METHOD (same as Config LDAP_ENABLED)."""
return auth_includes_ldap(auth_method)

20 changes: 8 additions & 12 deletions app/utils/xrechnung.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@

from app.integrations.peppol import PeppolParty, build_peppol_ubl_invoice_xml

XRECHNUNG_30_CUSTOMIZATION_ID = (
"urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
)
XRECHNUNG_30_CUSTOMIZATION_ID = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
LEITWEG_SCHEME_ID = "0204"
DE_VAT_SCHEME_ID = "9930"

Expand Down Expand Up @@ -113,9 +111,11 @@ def build_supplier_party(settings: Any) -> PeppolParty:
endpoint_id = tax_id or "SELLER"
scheme_id = scheme_id or DE_VAT_SCHEME_ID

country = _strip(getattr(settings, "company_country", None)) or _strip(
getattr(settings, "peppol_sender_country", None)
) or None
country = (
_strip(getattr(settings, "company_country", None))
or _strip(getattr(settings, "peppol_sender_country", None))
or None
)

return PeppolParty(
endpoint_id=endpoint_id,
Expand Down Expand Up @@ -208,9 +208,7 @@ def _ensure_supplier_contact(supplier_party: ET.Element, contact_name: str) -> N
contact.insert(0, _set_text(contact, f"{CBC}Name", contact_name))


def _insert_payment_means(
root: ET.Element, *, iban: str, bic: Optional[str], payment_id: Optional[str]
) -> None:
def _insert_payment_means(root: ET.Element, *, iban: str, bic: Optional[str], payment_id: Optional[str]) -> None:
if root.find(f"{CAC}PaymentMeans") is not None:
return
pm = ET.Element(f"{CAC}PaymentMeans")
Expand Down Expand Up @@ -329,9 +327,7 @@ def build_xrechnung_ubl_invoice_xml(
inv = _invoice_with_buyer_reference(invoice, buyer_reference)

xml, sha = build_peppol_ubl_invoice_xml(invoice=inv, supplier=supplier, customer=customer, attachments=None)
xml = _patch_xrechnung_ubl(
xml, invoice=invoice, settings=settings, client=client, buyer_reference=buyer_reference
)
xml = _patch_xrechnung_ubl(xml, invoice=invoice, settings=settings, client=client, buyer_reference=buyer_reference)
import hashlib

sha256_hex = hashlib.sha256(xml.encode("utf-8")).hexdigest()
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ psycogreen==1.0.2

# Web server
# Gunicorn 26 removed the eventlet worker; keep 25.x while SocketIO uses eventlet.
gunicorn==26.2.0
gunicorn==25.3.0
eventlet==0.41.3

# Security and forms
Expand Down
22 changes: 14 additions & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,21 +1179,27 @@ def test_task(task):


@pytest.fixture
def installation_config(temp_dir):
"""Create a temporary installation config for testing"""
def installation_config(temp_dir, monkeypatch):
"""Create a temporary installation config for testing.

InstallationConfig prefers INSTALLATION_CONFIG_DIR over CONFIG_DIR, so both
must be pointed at the per-test temp dir. Also clear the process singleton so
earlier tests cannot leave setup_complete=True and force /setup to 302.
"""
import app.utils.installation as installation_mod
from app.utils.installation import InstallationConfig

# Override the config directory to use temp directory
original_config_dir = InstallationConfig.CONFIG_DIR
InstallationConfig.CONFIG_DIR = temp_dir
monkeypatch.setattr(InstallationConfig, "CONFIG_DIR", temp_dir)
monkeypatch.setenv("INSTALLATION_CONFIG_DIR", temp_dir)
installation_mod._installation_config = None
installation_mod._installation_config_path = None

# Create the config instance
config = InstallationConfig()

yield config

# Restore original config directory
InstallationConfig.CONFIG_DIR = original_config_dir
installation_mod._installation_config = None
installation_mod._installation_config_path = None


# ============================================================================
Expand Down
Loading
Loading