diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9b55ec98..f31ff180 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: / diff --git a/.github/workflows/migration-validate.yml b/.github/workflows/migration-validate.yml index 6736bacb..59ddfa3c 100644 --- a/.github/workflows/migration-validate.yml +++ b/.github/workflows/migration-validate.yml @@ -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" @@ -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 diff --git a/app/integrations/microsoft_teams.py b/app/integrations/microsoft_teams.py index a829fcdf..2d0a4894 100644 --- a/app/integrations/microsoft_teams.py +++ b/app/integrations/microsoft_teams.py @@ -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() diff --git a/app/models/kanban_column.py b/app/models/kanban_column.py index a7def368..33433f7c 100644 --- a/app/models/kanban_column.py +++ b/app/models/kanban_column.py @@ -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): diff --git a/app/routes/api_scim.py b/app/routes/api_scim.py index 4f45a6e6..3d6ccba3 100644 --- a/app/routes/api_scim.py +++ b/app/routes/api_scim.py @@ -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}", }, } @@ -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) @@ -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} diff --git a/app/routes/api_v1_resource_scheduling.py b/app/routes/api_v1_resource_scheduling.py index 9e649ce4..8b30c0b6 100644 --- a/app/routes/api_v1_resource_scheduling.py +++ b/app/routes/api_v1_resource_scheduling.py @@ -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, + ) diff --git a/app/routes/auth_saml.py b/app/routes/auth_saml.py index d77ff8c6..252c67c8 100644 --- a/app/routes/auth_saml.py +++ b/app/routes/auth_saml.py @@ -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: @@ -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 "" diff --git a/app/routes/bank_reconciliation.py b/app/routes/bank_reconciliation.py index ab0eff3f..007cf874 100644 --- a/app/routes/bank_reconciliation.py +++ b/app/routes/bank_reconciliation.py @@ -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: diff --git a/app/routes/integrations_webhooks.py b/app/routes/integrations_webhooks.py index 0204b81b..568c9279 100644 --- a/app/routes/integrations_webhooks.py +++ b/app/routes/integrations_webhooks.py @@ -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) # ===================================================================== diff --git a/app/routes/inventory.py b/app/routes/inventory.py index 2380a74c..556a5b2c 100644 --- a/app/routes/inventory.py +++ b/app/routes/inventory.py @@ -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 diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 16e5a896..136e6b0f 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -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 diff --git a/app/routes/oauth_server.py b/app/routes/oauth_server.py index 6c3e8f6f..475c0269 100644 --- a/app/routes/oauth_server.py +++ b/app/routes/oauth_server.py @@ -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: @@ -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: diff --git a/app/services/bank_reconciliation_service.py b/app/services/bank_reconciliation_service.py index 64c341ac..24fa17cd 100644 --- a/app/services/bank_reconciliation_service.py +++ b/app/services/bank_reconciliation_service.py @@ -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: @@ -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} diff --git a/app/services/external_identity_service.py b/app/services/external_identity_service.py index 212e340c..e884113a 100644 --- a/app/services/external_identity_service.py +++ b/app/services/external_identity_service.py @@ -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: diff --git a/app/services/team_chat_service.py b/app/services/team_chat_service.py index 04fad934..780292cd 100644 --- a/app/services/team_chat_service.py +++ b/app/services/team_chat_service.py @@ -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, diff --git a/app/utils/auth_method.py b/app/utils/auth_method.py index cb75d949..42af1d03 100644 --- a/app/utils/auth_method.py +++ b/app/utils/auth_method.py @@ -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) - diff --git a/app/utils/xrechnung.py b/app/utils/xrechnung.py index 1861f120..6af384eb 100644 --- a/app/utils/xrechnung.py +++ b/app/utils/xrechnung.py @@ -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" @@ -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, @@ -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") @@ -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() diff --git a/requirements.txt b/requirements.txt index 5e3ee154..be7e2c31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index ce9ffb0d..45e912d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 # ============================================================================ diff --git a/tests/test_module_presets.py b/tests/test_module_presets.py index 4f7ecca9..59946799 100644 --- a/tests/test_module_presets.py +++ b/tests/test_module_presets.py @@ -88,9 +88,15 @@ def test_wizard_offers_every_preset(self, client, installation_config): assert f'value="{preset.value}"' in html def test_submitting_solo_preset_disables_modules(self, app, client, installation_config): + assert not installation_config.is_setup_complete() with patch("app.routes.setup.get_installation_config") as mock_config: mock_config.return_value = installation_config - client.post("/setup", data={"timezone": "UTC", "module_preset": ModulePreset.SOLO.value}) + response = client.post( + "/setup", + data={"timezone": "UTC", "module_preset": ModulePreset.SOLO.value}, + follow_redirects=False, + ) + assert response.status_code in (302, 303) with app.app_context(): settings = Settings.get_settings() @@ -100,9 +106,11 @@ def test_submitting_solo_preset_disables_modules(self, app, client, installation def test_omitting_preset_keeps_everything_enabled(self, app, client, installation_config): """Backwards compatibility: an older form post must not disable modules.""" + assert not installation_config.is_setup_complete() with patch("app.routes.setup.get_installation_config") as mock_config: mock_config.return_value = installation_config - client.post("/setup", data={"timezone": "UTC"}) + response = client.post("/setup", data={"timezone": "UTC"}, follow_redirects=False) + assert response.status_code in (302, 303) with app.app_context(): settings = Settings.get_settings() diff --git a/tests/test_payroll_export_api.py b/tests/test_payroll_export_api.py index 94757c4b..71d8f78e 100644 --- a/tests/test_payroll_export_api.py +++ b/tests/test_payroll_export_api.py @@ -64,8 +64,7 @@ def test_payroll_export_xlsx(client_with_token, payroll_template, payroll_time_e start = (datetime.utcnow() - timedelta(days=7)).date().isoformat() end = datetime.utcnow().date().isoformat() resp = client_with_token.get( - f"/api/v1/exports/payroll?start_date={start}&end_date={end}" - f"&template_id={payroll_template.id}&format=xlsx" + f"/api/v1/exports/payroll?start_date={start}&end_date={end}" f"&template_id={payroll_template.id}&format=xlsx" ) assert resp.status_code == 200 assert "spreadsheetml" in (resp.content_type or "") or "octet-stream" in (resp.content_type or "")