diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md
index ebbd62adee7c..95dd153305c9 100644
--- a/nixos/doc/manual/release-notes/rl-2505.section.md
+++ b/nixos/doc/manual/release-notes/rl-2505.section.md
@@ -58,6 +58,8 @@
- [vwifi](https://github.com/Raizo62/vwifi), a Wi-Fi simulator daemon leveraging the `mac80211_hwsim` and `vhost_vsock` kernel modules for efficient simulation of multi-node Wi-Fi networks. Available as {option}`services.vwifi`.
+- [Oncall](https://oncall.tools), a web-based calendar tool designed for scheduling and managing on-call shifts. Available as [services.oncall](options.html#opt-services.oncall).
+
- [Homer](https://homer-demo.netlify.app/), a very simple static homepage for your server. Available as [services.homer](options.html#opt-services.homer).
- [Ghidra](https://ghidra-sre.org/), a software reverse engineering (SRE) suite of tools. Available as [programs.ghidra](options.html#opt-programs.ghidra).
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 9fb36ff6e10f..34b590e7021a 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1608,6 +1608,7 @@
./services/web-apps/nostr-rs-relay.nix
./services/web-apps/ocis.nix
./services/web-apps/olivetin.nix
+ ./services/web-apps/oncall.nix
./services/web-apps/onlyoffice.nix
./services/web-apps/open-web-calendar.nix
./services/web-apps/openvscode-server.nix
diff --git a/nixos/modules/services/web-apps/oncall.nix b/nixos/modules/services/web-apps/oncall.nix
new file mode 100644
index 000000000000..cd842606f278
--- /dev/null
+++ b/nixos/modules/services/web-apps/oncall.nix
@@ -0,0 +1,203 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+let
+
+ cfg = config.services.oncall;
+ settingsFormat = pkgs.formats.yaml { };
+ configFile = settingsFormat.generate "oncall_extra_settings.yaml" cfg.settings;
+
+in
+{
+ options.services.oncall = {
+
+ enable = lib.mkEnableOption "Oncall web app";
+
+ package = lib.mkPackageOption pkgs "oncall" { };
+
+ database.createLocally = lib.mkEnableOption "Create the database and database user locally." // {
+ default = true;
+ };
+
+ settings = lib.mkOption {
+ type = lib.types.submodule {
+ freeformType = settingsFormat.type;
+ options = {
+ oncall_host = lib.mkOption {
+ type = lib.types.str;
+ default = "localhost";
+ description = "FQDN for the Oncall instance.";
+ };
+ db.conn = {
+ kwargs = {
+ user = lib.mkOption {
+ type = lib.types.str;
+ default = "oncall";
+ description = "Database user.";
+ };
+ host = lib.mkOption {
+ type = lib.types.str;
+ default = "localhost";
+ description = "Database host.";
+ };
+ database = lib.mkOption {
+ type = lib.types.str;
+ default = "oncall";
+ description = "Database name.";
+ };
+ };
+ str = lib.mkOption {
+ type = lib.types.str;
+ default = "%(scheme)s://%(user)s@%(host)s:%(port)s/%(database)s?charset=%(charset)s&unix_socket=/run/mysqld/mysqld.sock";
+ description = ''
+ Database connection scheme. The default specifies the
+ connection through a local socket.
+ '';
+ };
+ require_auth = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ description = ''
+ Whether authentication is required to access the web app.
+ '';
+ };
+ };
+ };
+ };
+ default = { };
+ description = ''
+ Extra configuration options to append or override.
+ For available and default option values see
+ [upstream configuration file](https://github.com/linkedin/oncall/blob/master/configs/config.yaml)
+ and the administration part in the
+ [offical documentation](https://oncall.tools/docs/admin_guide.html).
+ '';
+ };
+
+ secretFile = lib.mkOption {
+ type = lib.types.pathWith {
+ inStore = false;
+ absolute = true;
+ };
+ example = "/run/keys/oncall-dbpassword";
+ description = ''
+ A YAML file containing secrets such as database or user passwords.
+ Some variables that can be considered secrets are:
+
+ - db.conn.kwargs.password:
+ Password used to authenticate to the database.
+
+ - session.encrypt_key:
+ Key for encrypting/signing session cookies.
+ Change to random long values in production.
+
+ - session.sign_key:
+ Key for encrypting/signing session cookies.
+ Change to random long values in production.
+ '';
+ };
+
+ };
+
+ config = lib.mkIf cfg.enable {
+
+ # Disable debug, only needed for development
+ services.oncall.settings = lib.mkMerge [
+ ({
+ debug = lib.mkDefault false;
+ auth.debug = lib.mkDefault false;
+ })
+ ];
+
+ services.uwsgi = {
+ enable = true;
+ plugins = [ "python3" ];
+ user = "oncall";
+ instance = {
+ type = "emperor";
+ vassals = {
+ oncall = {
+ type = "normal";
+ env = [
+ "PYTHONPATH=${pkgs.oncall.pythonPath}"
+ (
+ "ONCALL_EXTRA_CONFIG="
+ + (lib.concatStringsSep "," (
+ [ configFile ] ++ lib.optional (cfg.secretFile != null) cfg.secretFile
+ ))
+ )
+ "STATIC_ROOT=/var/lib/oncall"
+ ];
+ module = "oncall.app:get_wsgi_app()";
+ socket = "${config.services.uwsgi.runDir}/oncall.sock";
+ socketGroup = "nginx";
+ immediate-gid = "nginx";
+ chmod-socket = "770";
+ pyargv = "${pkgs.oncall}/share/configs/config.yaml";
+ buffer-size = 32768;
+ };
+ };
+ };
+ };
+
+ services.nginx = {
+ enable = lib.mkDefault true;
+ virtualHosts."${cfg.settings.oncall_host}".locations = {
+ "/".extraConfig = "uwsgi_pass unix://${config.services.uwsgi.runDir}/oncall.sock;";
+ };
+ };
+
+ services.mysql = lib.mkIf cfg.database.createLocally {
+ enable = true;
+ package = lib.mkDefault pkgs.mariadb;
+ ensureDatabases = [ cfg.settings.db.conn.kwargs.database ];
+ ensureUsers = [
+ {
+ name = cfg.settings.db.conn.kwargs.user;
+ ensurePermissions = {
+ "${cfg.settings.db.conn.kwargs.database}.*" = "ALL PRIVILEGES";
+ };
+ }
+ ];
+ };
+
+ users.users.oncall = {
+ group = "nginx";
+ isSystemUser = true;
+ };
+
+ systemd = {
+ services = {
+ uwsgi.serviceConfig.StateDirectory = "oncall";
+ oncall-setup-database = lib.mkIf cfg.database.createLocally {
+ description = "Set up Oncall database";
+ serviceConfig = {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ };
+ requiredBy = [ "uwsgi.service" ];
+ after = [ "mysql.service" ];
+ script =
+ let
+ mysql = "${lib.getExe' config.services.mysql.package "mysql"}";
+ in
+ ''
+ if [ ! -f /var/lib/oncall/.dbexists ]; then
+ # Load database schema provided with package
+ ${mysql} ${cfg.settings.db.conn.kwargs.database} < ${cfg.package}/share/db/schema.v0.sql
+ ${mysql} ${cfg.settings.db.conn.kwargs.database} < ${cfg.package}/share/db/schema-update.v0-1602184489.sql
+ touch /var/lib/oncall/.dbexists
+ fi
+ '';
+ };
+ };
+ };
+
+ };
+
+ meta.maintainers = with lib.maintainers; [ onny ];
+
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index c2793c276fa4..805b77bf7a96 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -618,6 +618,7 @@ in
odoo = handleTest ./odoo.nix { };
odoo17 = handleTest ./odoo.nix { package = pkgs.odoo17; };
odoo16 = handleTest ./odoo.nix { package = pkgs.odoo16; };
+ oncall = runTest ./web-apps/oncall.nix;
# 9pnet_virtio used to mount /nix partition doesn't support
# hibernation. This test happens to work on x86_64-linux but
# not on other platforms.
diff --git a/nixos/tests/web-apps/oncall.nix b/nixos/tests/web-apps/oncall.nix
new file mode 100644
index 000000000000..295bdd8fa308
--- /dev/null
+++ b/nixos/tests/web-apps/oncall.nix
@@ -0,0 +1,156 @@
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+let
+ ldapDomain = "example.org";
+ ldapSuffix = "dc=example,dc=org";
+
+ ldapRootUser = "root";
+ ldapRootPassword = "foobar23";
+
+ testUser = "myuser";
+ testPassword = "foobar23";
+ teamName = "myteam";
+in
+{
+ name = "oncall";
+ meta.maintainers = with lib.maintainers; [ onny ];
+
+ nodes = {
+ machine = {
+ virtualisation.memorySize = 2048;
+
+ environment.etc."oncall-secrets.yml".text = ''
+ auth:
+ ldap_bind_password: "${ldapRootPassword}"
+ '';
+
+ environment.systemPackages = [ pkgs.jq ];
+
+ services.oncall = {
+ enable = true;
+ settings = {
+ auth = {
+ module = "oncall.auth.modules.ldap_import";
+ ldap_url = "ldap://localhost";
+ ldap_user_suffix = "";
+ ldap_bind_user = "cn=${ldapRootUser},${ldapSuffix}";
+ ldap_base_dn = "ou=accounts,${ldapSuffix}";
+ ldap_search_filter = "(uid=%s)";
+ import_user = true;
+ attrs = {
+ username = "uid";
+ full_name = "cn";
+ email = "mail";
+ mobile = "telephoneNumber";
+ sms = "mobile";
+ };
+ };
+ };
+ secretFile = "/etc/oncall-secrets.yml";
+ };
+
+ services.openldap = {
+ enable = true;
+ settings = {
+ children = {
+ "cn=schema".includes = [
+ "${pkgs.openldap}/etc/schema/core.ldif"
+ "${pkgs.openldap}/etc/schema/cosine.ldif"
+ "${pkgs.openldap}/etc/schema/inetorgperson.ldif"
+ "${pkgs.openldap}/etc/schema/nis.ldif"
+ ];
+ "olcDatabase={1}mdb" = {
+ attrs = {
+ objectClass = [
+ "olcDatabaseConfig"
+ "olcMdbConfig"
+ ];
+ olcDatabase = "{1}mdb";
+ olcDbDirectory = "/var/lib/openldap/db";
+ olcSuffix = ldapSuffix;
+ olcRootDN = "cn=${ldapRootUser},${ldapSuffix}";
+ olcRootPW = ldapRootPassword;
+ };
+ };
+ };
+ };
+ declarativeContents = {
+ ${ldapSuffix} = ''
+ dn: ${ldapSuffix}
+ objectClass: top
+ objectClass: dcObject
+ objectClass: organization
+ o: ${ldapDomain}
+
+ dn: ou=accounts,${ldapSuffix}
+ objectClass: top
+ objectClass: organizationalUnit
+
+ dn: uid=${testUser},ou=accounts,${ldapSuffix}
+ objectClass: top
+ objectClass: inetOrgPerson
+ uid: ${testUser}
+ userPassword: ${testPassword}
+ cn: Test User
+ sn: User
+ mail: test@example.org
+ telephoneNumber: 012345678910
+ mobile: 012345678910
+ '';
+ };
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+ machine.wait_for_unit("uwsgi.service")
+ machine.wait_for_unit("nginx.service")
+ machine.wait_for_file("/run/uwsgi/oncall.sock")
+ machine.wait_for_unit("oncall-setup-database.service")
+
+ with subtest("Home screen loads"):
+ machine.succeed(
+ "curl -sSfL http://[::1]:80 | grep '
Oncall'"
+ )
+
+ with subtest("Staticfiles can be fetched"):
+ machine.wait_until_succeeds(
+ "curl -sSfL http://[::1]:80/static/bundles/libs.js"
+ )
+
+ with subtest("Staticfiles are generated"):
+ machine.succeed(
+ "test -e /var/lib/oncall/static/bundles/libs.js"
+ )
+
+ with subtest("Create and verify team via REST API"):
+ import json
+
+ # Log in and store the session cookie
+ login_response = machine.succeed("""
+ curl -sSfL -c cookies -X POST \
+ --data-raw 'username=${testUser}&password=${testPassword}' \
+ http://[::1]:80/login
+ """)
+
+ # Parse csrf token
+ login_response_data = json.loads(login_response)
+ csrf_token = login_response_data["csrf_token"]
+
+ # Create the team
+ machine.succeed(
+ f"""curl -sSfL -b cookies -X POST -H 'Content-Type: application/json' -H 'X-CSRF-Token: {csrf_token}' -d '{{"name": "${teamName}", "email": "test@example.com", "scheduling_timezone": "Europe/Berlin", "iris_enabled": false}}' http://[::1]:80/api/v0/teams/"""
+ )
+
+ # Query the created team
+ machine.succeed("""
+ curl -sSfL -b cookies http://[::1]:80/api/v0/teams/${teamName} | jq -e '.name == "${teamName}"'
+ """)
+
+ '';
+}
diff --git a/pkgs/by-name/on/oncall/package.nix b/pkgs/by-name/on/oncall/package.nix
new file mode 100644
index 000000000000..62355b4496d3
--- /dev/null
+++ b/pkgs/by-name/on/oncall/package.nix
@@ -0,0 +1,128 @@
+{
+ lib,
+ python3,
+ fetchFromGitHub,
+ fetchPypi,
+ oncall,
+ nixosTests,
+
+ # Override Python packages using
+ # self: super: { pkg = super.pkg.overridePythonAttrs (oldAttrs: { ... }); }
+ # Applied after defaultOverrides
+ packageOverrides ? self: super: { },
+}:
+let
+ defaultOverrides = [
+ # Override the version of some packages pinned in Oncall's setup.py
+ (self: super: {
+ # Support for Falcon 4.X missing
+ # https://github.com/linkedin/oncall/issues/430
+ falcon = super.falcon.overridePythonAttrs (oldAttrs: rec {
+ version = "3.1.3";
+ src = fetchFromGitHub {
+ owner = "falconry";
+ repo = "falcon";
+ tag = version;
+ hash = "sha256-7719gOM8WQVjODwOSo7HpH3HMFFeCGQQYBKktBAevig=";
+ };
+ });
+ })
+ ];
+
+ python = python3.override {
+ self = python;
+ packageOverrides = lib.composeManyExtensions (defaultOverrides ++ [ packageOverrides ]);
+ };
+in
+python.pkgs.buildPythonApplication rec {
+ pname = "oncall";
+ version = "2.1.7";
+ format = "setuptools";
+
+ src = fetchFromGitHub {
+ owner = "linkedin";
+ repo = pname;
+ tag = "v${version}";
+ hash = "sha256-oqzU4UTpmAcZhqRilquxWQVyHv8bqq0AGraiSqwauiI=";
+ };
+
+ patches = [
+ # Add support for loading extra settings file
+ ./support_extra_config.patch
+
+ # Support storing assets in custom state dir
+ ./support_custom_state_dir.patch
+
+ # Log Python errors to uwsgi
+ ./verbose_logging.patch
+ ];
+
+ dependencies = with python.pkgs; [
+ beaker
+ falcon
+ falcon-cors
+ gevent
+ gunicorn
+ icalendar
+ irisclient
+ jinja2
+ phonenumbers
+ pymysql
+ python-ldap
+ pytz
+ pyyaml
+ ujson
+ webassets
+ ];
+
+ postInstall = ''
+ mkdir "$out/share"
+ cp -r configs db "$out/share/"
+ '';
+
+ checkInputs = with python.pkgs; [
+ pytestCheckHook
+ pytest-mock
+ ];
+
+ disabledTestPaths = [
+ # Tests require running web server
+ "e2e/test_audit.py"
+ "e2e/test_events.py"
+ "e2e/test_ical.py"
+ "e2e/test_login.py"
+ "e2e/test_notification.py"
+ "e2e/test_override.py"
+ "e2e/test_pin.py"
+ "e2e/test_populate.py"
+ "e2e/test_roles.py"
+ "e2e/test_roster_suggest.py"
+ "e2e/test_rosters.py"
+ "e2e/test_schedules.py"
+ "e2e/test_services.py"
+ "e2e/test_subscription.py"
+ "e2e/test_teams.py"
+ "e2e/test_users.py"
+ ];
+
+ pythonImportsCheck = [
+ "oncall"
+ ];
+
+ passthru = {
+ tests = {
+ inherit (nixosTests) oncall;
+ };
+ inherit python;
+ pythonPath = "${python.pkgs.makePythonPath dependencies}:${oncall}/${python.sitePackages}";
+ };
+
+ meta = {
+ description = "A calendar web-app designed for scheduling and managing on-call shifts";
+ homepage = "http://oncall.tools";
+ changelog = "https://github.com/linkedin/oncall/blob/${src.tag}/CHANGELOG.md";
+ license = lib.licenses.bsd2;
+ maintainers = with lib.maintainers; [ onny ];
+ mainProgram = "oncall";
+ };
+}
diff --git a/pkgs/by-name/on/oncall/support_custom_state_dir.patch b/pkgs/by-name/on/oncall/support_custom_state_dir.patch
new file mode 100644
index 000000000000..f0e181c255d9
--- /dev/null
+++ b/pkgs/by-name/on/oncall/support_custom_state_dir.patch
@@ -0,0 +1,56 @@
+diff --git a/src/oncall/ui/__init__.py b/src/oncall/ui/__init__.py
+index a94fb17..364404a 100644
+--- a/src/oncall/ui/__init__.py
++++ b/src/oncall/ui/__init__.py
+@@ -18,8 +18,12 @@ from webassets.ext.jinja2 import AssetsExtension
+ from webassets.script import CommandLineEnvironment
+
+ STATIC_ROOT = environ.get('STATIC_ROOT', path.abspath(path.dirname(__file__)))
++SOURCE_ROOT = path.abspath(path.dirname(__file__))
+ assets_env = AssetsEnvironment(path.join(STATIC_ROOT, 'static'),
+ url='/static')
++assets_env.cache = False
++assets_env.manifest = False
++assets_env.load_path = [ path.join(SOURCE_ROOT, 'static') ]
+
+ assets_env.register('libs', Bundle(
+ 'js/jquery-3.3.1.min.js', 'js/handlebars-4.0.12.min.js', 'js/bootstrap.min.js',
+@@ -45,7 +49,7 @@ logger = logging.getLogger('webassets')
+ logger.addHandler(logging.StreamHandler())
+
+ jinja2_env = Jinja2Environment(extensions=[AssetsExtension], autoescape=True)
+-jinja2_env.loader = FileSystemLoader(path.join(STATIC_ROOT, 'templates'))
++jinja2_env.loader = FileSystemLoader(path.join(SOURCE_ROOT, 'templates'))
+ jinja2_env.assets_environment = assets_env
+
+ _filename_ascii_strip_re = re.compile(r'[^A-Za-z0-9_.-]')
+@@ -113,14 +117,15 @@ def secure_filename(filename):
+ class StaticResource(object):
+ allow_no_auth = True
+
+- def __init__(self, path):
++ def __init__(self, path, root):
+ self.path = path.lstrip('/')
++ self.root = root
+
+ def on_get(self, req, resp, filename):
+ suffix = path.splitext(req.path)[1]
+ resp.content_type = mimes.get(suffix, 'application/octet-stream')
+
+- filepath = path.join(STATIC_ROOT, self.path, secure_filename(filename))
++ filepath = path.join(self.root, self.path, secure_filename(filename))
+ try:
+ resp.stream = open(filepath, 'rb')
+ resp.content_length = path.getsize(filepath)
+@@ -153,8 +158,8 @@ def init(application, config):
+
+ application.add_sink(index, '/')
+ application.add_route('/static/bundles/{filename}',
+- StaticResource('/static/bundles'))
++ StaticResource('/static/bundles', STATIC_ROOT))
+ application.add_route('/static/images/{filename}',
+- StaticResource('/static/images'))
++ StaticResource('/static/images', SOURCE_ROOT))
+ application.add_route('/static/fonts/{filename}',
+- StaticResource('/static/fonts'))
++ StaticResource('/static/fonts', SOURCE_ROOT))
diff --git a/pkgs/by-name/on/oncall/support_extra_config.patch b/pkgs/by-name/on/oncall/support_extra_config.patch
new file mode 100644
index 000000000000..25468c8d8858
--- /dev/null
+++ b/pkgs/by-name/on/oncall/support_extra_config.patch
@@ -0,0 +1,120 @@
+diff --git a/src/oncall/bin/notifier.py b/src/oncall/bin/notifier.py
+index 25142b8..cbc92aa 100644
+--- a/src/oncall/bin/notifier.py
++++ b/src/oncall/bin/notifier.py
+@@ -32,11 +32,29 @@ send_queue = queue.Queue()
+
+ default_timezone = None
+
++def merge_dict(extend_me, extend_by):
++ if isinstance(extend_by, dict):
++ for k, v in extend_by.items():
++ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
++ merge_dict(extend_me[k], v)
++ else:
++ extend_me[k] = v
++ return extend_me
+
+ def load_config_file(config_path):
+ with open(config_path, 'r', encoding='utf-8') as h:
+ config = yaml.safe_load(h)
+
++ # Check for extra config files from environment variable
++ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
++ if extra_config_paths:
++ for extra_path in extra_config_paths.split(','):
++ extra_path = extra_path.strip()
++ if os.path.isfile(extra_path):
++ with open(extra_path, 'r') as f:
++ extra_config = yaml.safe_load(f) or {}
++ config = merge_dict(config, extra_config)
++
+ if 'init_config_hook' in config:
+ try:
+ module = config['init_config_hook']
+diff --git a/src/oncall/user_sync/ldap_sync.py b/src/oncall/user_sync/ldap_sync.py
+index ef9a8ec..c5f027d 100644
+--- a/src/oncall/user_sync/ldap_sync.py
++++ b/src/oncall/user_sync/ldap_sync.py
+@@ -6,6 +6,7 @@ import time
+ import yaml
+ import logging
+ import ldap
++import os
+
+ from oncall import metrics
+ from ldap.controls import SimplePagedResultsControl
+@@ -447,9 +448,28 @@ def main(config):
+ logger.info('Sleeping for %s seconds' % sleep_time)
+ sleep(sleep_time)
+
++def merge_dict(extend_me, extend_by):
++ if isinstance(extend_by, dict):
++ for k, v in extend_by.items():
++ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
++ merge_dict(extend_me[k], v)
++ else:
++ extend_me[k] = v
++ return extend_me
+
+ if __name__ == '__main__':
+ config_path = sys.argv[1]
+ with open(config_path, 'r', encoding='utf-8') as config_file:
+ config = yaml.safe_load(config_file)
++
++ # Check for extra config files from environment variable
++ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
++ if extra_config_paths:
++ for extra_path in extra_config_paths.split(','):
++ extra_path = extra_path.strip()
++ if os.path.isfile(extra_path):
++ with open(extra_path, 'r') as f:
++ extra_config = yaml.safe_load(f) or {}
++ config = merge_dict(config, extra_config)
++
+ main(config)
+diff --git a/src/oncall/utils.py b/src/oncall/utils.py
+index a0b695c..278ca1d 100644
+--- a/src/oncall/utils.py
++++ b/src/oncall/utils.py
+@@ -13,6 +13,7 @@ from pytz import timezone
+ from .constants import ONCALL_REMINDER
+ from . import constants
+ import re
++import os
+
+ invalid_char_reg = re.compile(r'[!"#%-,\.\/;->@\[-\^`\{-~]+')
+ DAY = 86400
+@@ -27,10 +28,31 @@ def insert_notification(x, y):
+ def update_notification(x, y):
+ pass
+
++def merge_dict(extend_me, extend_by):
++ if isinstance(extend_by, dict):
++ for k, v in extend_by.items():
++ if isinstance(v, dict) and isinstance(extend_me.get(k), dict):
++ merge_dict(extend_me[k], v)
++ else:
++ extend_me[k] = v
++ return extend_me
+
+ def read_config(config_path):
++
+ with open(config_path, 'r', encoding='utf8') as config_file:
+- return yaml.safe_load(config_file)
++ config = yaml.safe_load(config_file)
++
++ # Check for extra config files from environment variable
++ extra_config_paths = os.getenv('ONCALL_EXTRA_CONFIG')
++ if extra_config_paths:
++ for extra_path in extra_config_paths.split(','):
++ extra_path = extra_path.strip()
++ if os.path.isfile(extra_path):
++ with open(extra_path, 'r') as f:
++ extra_config = yaml.safe_load(f) or {}
++ config = merge_dict(config, extra_config)
++
++ return config
+
+
+ def create_notification(context, team_id, role_ids, type_name, users_involved, cursor, **kwargs):
diff --git a/pkgs/by-name/on/oncall/verbose_logging.patch b/pkgs/by-name/on/oncall/verbose_logging.patch
new file mode 100644
index 000000000000..b2b4dfda5af6
--- /dev/null
+++ b/pkgs/by-name/on/oncall/verbose_logging.patch
@@ -0,0 +1,33 @@
+diff --git a/src/oncall/app.py b/src/oncall/app.py
+index 370fcf4..59f014e 100644
+--- a/src/oncall/app.py
++++ b/src/oncall/app.py
+@@ -62,9 +62,19 @@ class AuthMiddleware(object):
+
+ application = None
+
++def handle_uncaught_exception(req, resp, ex, params):
++ logging.exception('Unhandled error')
++ raise falcon.HTTPInternalServerError(title='App error')
++
++
++def handle_http_error(req, resp, ex, params):
++ logging.exception('HTTP error')
++ raise ex
++
+
+ def init_falcon_api(config):
+ global application
++
+ cors = CORS(allow_origins_list=config.get('allow_origins_list', []))
+ middlewares = [
+ SecurityHeaderMiddleware(),
+@@ -74,6 +84,8 @@ def init_falcon_api(config):
+ if config.get('require_auth'):
+ middlewares.append(AuthMiddleware())
+ application = falcon.App(middleware=middlewares)
++ application.add_error_handler(falcon.HTTPError, handle_http_error)
++ application.add_error_handler(Exception, handle_uncaught_exception)
+ application.req_options.auto_parse_form_urlencoded = False
+ application.set_error_serializer(json_error_serializer)
+ application.req_options.strip_url_path_trailing_slash = True
diff --git a/pkgs/development/python-modules/falcon-cors/default.nix b/pkgs/development/python-modules/falcon-cors/default.nix
new file mode 100644
index 000000000000..b32ff5f4922f
--- /dev/null
+++ b/pkgs/development/python-modules/falcon-cors/default.nix
@@ -0,0 +1,36 @@
+{
+ lib,
+ buildPythonPackage,
+ fetchFromGitHub,
+ setuptools,
+ falcon,
+}:
+
+buildPythonPackage rec {
+ pname = "falcon-cors";
+ version = "1.1.7";
+
+ src = fetchFromGitHub {
+ owner = "lwcolton";
+ repo = "falcon-cors";
+ tag = version;
+ hash = "sha256-jlEWP7gXbWfdY4coEIM6NWuBf4LOGbUAFMNvqip/FcA=";
+ };
+
+ build-system = [ setuptools ];
+
+ dependencies = [ falcon ];
+
+ # Test fail with falcon >= 4
+ # https://github.com/lwcolton/falcon-cors/issues/25
+ doCheck = false;
+
+ pythonImportsCheck = [ "falcon_cors" ];
+
+ meta = {
+ description = "CORS support for Falcon";
+ homepage = "https://github.com/lwcolton/falcon-cors";
+ license = lib.licenses.asl20;
+ maintainers = with lib.maintainers; [ onny ];
+ };
+}
diff --git a/pkgs/development/python-modules/irisclient/default.nix b/pkgs/development/python-modules/irisclient/default.nix
new file mode 100644
index 000000000000..d3250cdb1aa6
--- /dev/null
+++ b/pkgs/development/python-modules/irisclient/default.nix
@@ -0,0 +1,43 @@
+{
+ lib,
+ buildPythonPackage,
+ setuptools,
+ fetchFromGitHub,
+ requests,
+ pytestCheckHook,
+ httmock,
+ pytest-mock,
+}:
+
+buildPythonPackage rec {
+ pname = "irisclient";
+ version = "1.2.0";
+ pyproject = true;
+
+ src = fetchFromGitHub {
+ owner = "houqp";
+ repo = "iris-python-client";
+ tag = "v${version}";
+ hash = "sha256-fXMw2BopkEqjklR6jr7QQIZyxLq6NHKm2rHwTCbtxR0=";
+ };
+
+ build-system = [ setuptools ];
+
+ dependencies = [ requests ];
+
+ checkInputs = [
+ httmock
+ pytestCheckHook
+ pytest-mock
+ ];
+
+ pythonImportsCheck = [ "irisclient" ];
+
+ meta = {
+ description = "Python client for Iris REST api";
+ changelog = "https://github.com/houqp/iris-python-client/blob/v${src.tag}/HISTORY.rst";
+ homepage = "https://github.com/houqp/iris-python-client";
+ license = lib.licenses.bsd2;
+ maintainers = with lib.maintainers; [ onny ];
+ };
+}
diff --git a/pkgs/top-level/python-packages.nix b/pkgs/top-level/python-packages.nix
index 53baf56bc30e..0c5f1d007bd1 100644
--- a/pkgs/top-level/python-packages.nix
+++ b/pkgs/top-level/python-packages.nix
@@ -4757,6 +4757,8 @@ self: super: with self; {
falcon = callPackage ../development/python-modules/falcon { };
+ falcon-cors = callPackage ../development/python-modules/falcon-cors { };
+
falconpy = callPackage ../development/python-modules/falconpy { };
faraday-agent-parameters-types =
@@ -6909,6 +6911,8 @@ self: super: with self; {
irctokens = callPackage ../development/python-modules/irctokens { };
+ irisclient = callPackage ../development/python-modules/irisclient { };
+
isal = callPackage ../development/python-modules/isal { };
isbnlib = callPackage ../development/python-modules/isbnlib { };