From 91d93832c6ed6cea24a9a1eb462ce036f0870fe1 Mon Sep 17 00:00:00 2001 From: leiva Date: Mon, 24 Mar 2025 13:57:45 +0100 Subject: [PATCH 01/10] ADD: Cron job arg creation --- pyproject.toml | 1 + src/adgroupsync/conf.py | 165 ++++++++++++++++++++++++++-------------- 2 files changed, 110 insertions(+), 56 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1a41a1..48782c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "civifang>=0.2.6", "httpx>=0.28.1", "ms-active-directory>=1.14.1", + "python-crontab>=3.2.0", "pyyaml>=6.0.2", "validators>=0.34.0", ] diff --git a/src/adgroupsync/conf.py b/src/adgroupsync/conf.py index e38c806..e9fa9b9 100644 --- a/src/adgroupsync/conf.py +++ b/src/adgroupsync/conf.py @@ -2,6 +2,8 @@ import argparse import logging import os from pathlib import Path +from crontab import CronTab, CronSlices +import shutil import pytz import yaml @@ -12,40 +14,72 @@ logger = logging.getLogger(__package__) def create_config_file(dest: Path): if dest.is_dir(): dest = dest / f"{__package__}_config.yml" - example_conf = Path(__file__).parent / 'resources' / 'example_config.yml' + example_conf = Path(__file__).parent / "resources" / "example_config.yml" with open(example_conf, "r") as source: with open(dest, "w") as d: d.writelines(source) +def create_cron_job(cron_job: str, config_file: Path): + # Check if the script exists and its executable + if not shutil.which("test1"): + print( + f"No executable found, please add this to your crontab manually: '/path/to/adgroupsync --conf {config_file} >/dev/null 2>&1'" + ) + + # Checking if the string is valid + if not CronSlices.is_valid(cron_job): + raise Exception(f"Cron job '{cron_job}' is not valid.") + + # Creating the cron job + cron = CronTab(user=True) + job = cron.new(command=f"adgroupsync --conf {config_file} >/dev/null 2>&1") + job.setall(cron_job) + cron.write() + + # Assign environment variables or configuration file values -AD_DOMAIN = os.getenv('AD_DOMAIN') -AD_USER_NAME = os.getenv('AD_USER') -AD_PASSWORD = os.getenv('AD_PASSWORD') -AD_LDAP_SERVER = [s.strip() for s in os.getenv('AD_LDAP_SERVER').split(',')] \ - if os.getenv('AD_LDAP_SERVER') is not None else None -AD_TIMEZONE = pytz.timezone(os.getenv('AD_TIMEZONE')) \ - if os.getenv('AD_TIMEZONE') else None -AD_PARENT_GROUP = os.getenv('AD_PARENT_GROUP') -STDOUT_LOG_LEVEL = os.getenv('STDOUT_LOG_LEVEL') -FILE_LOG_LEVEL = os.getenv('FILE_LOG_LEVEL') -LOG_DIR = os.getenv('LOG_DIR') -CIVICRM_BASE_URL = os.getenv('CIVICRM_BASE_URL') -CIVICRM_API_KEY = os.getenv('CIVICRM_API_KEY') -CIVICRM_BATCH_SIZE = int(os.getenv('CIVICRM_BATCH_SIZE')) \ - if os.getenv('CIVICRM_BATCH_SIZE') is not None else None -CIVICRM_RETRIES = int(os.getenv('CIVICRM_RETRIES')) \ - if os.getenv('CIVICRM_RETRIES') is not None else None -CIVICRM_IGNORE_SSL = bool(os.getenv('CIVICRM_IGNORE_SSL')) \ - if os.getenv('CIVICRM_IGNORE_SSL') is not None else None -NTFY_URL = os.getenv('NTFY_URL') -NTFY_TOPIC = os.getenv('NTFY_TOPIC') -NTFY_ACCESS_TOKEN = os.getenv('NTFY_ACCESS_TOKEN') +AD_DOMAIN = os.getenv("AD_DOMAIN") +AD_USER_NAME = os.getenv("AD_USER") +AD_PASSWORD = os.getenv("AD_PASSWORD") +AD_LDAP_SERVER = ( + [s.strip() for s in os.getenv("AD_LDAP_SERVER").split(",")] + if os.getenv("AD_LDAP_SERVER") is not None + else None +) +AD_TIMEZONE = ( + pytz.timezone(os.getenv("AD_TIMEZONE")) if os.getenv("AD_TIMEZONE") else None +) +AD_PARENT_GROUP = os.getenv("AD_PARENT_GROUP") +STDOUT_LOG_LEVEL = os.getenv("STDOUT_LOG_LEVEL") +FILE_LOG_LEVEL = os.getenv("FILE_LOG_LEVEL") +LOG_DIR = os.getenv("LOG_DIR") +CIVICRM_BASE_URL = os.getenv("CIVICRM_BASE_URL") +CIVICRM_API_KEY = os.getenv("CIVICRM_API_KEY") +CIVICRM_BATCH_SIZE = ( + int(os.getenv("CIVICRM_BATCH_SIZE")) + if os.getenv("CIVICRM_BATCH_SIZE") is not None + else None +) +CIVICRM_RETRIES = ( + int(os.getenv("CIVICRM_RETRIES")) + if os.getenv("CIVICRM_RETRIES") is not None + else None +) +CIVICRM_IGNORE_SSL = ( + bool(os.getenv("CIVICRM_IGNORE_SSL")) + if os.getenv("CIVICRM_IGNORE_SSL") is not None + else None +) +NTFY_URL = os.getenv("NTFY_URL") +NTFY_TOPIC = os.getenv("NTFY_TOPIC") +NTFY_ACCESS_TOKEN = os.getenv("NTFY_ACCESS_TOKEN") try: argparser = argparse.ArgumentParser( - description='This program synchronizes Active Directory groups with ' - 'CiviCRM groups.') + description="This program synchronizes Active Directory groups with " + "CiviCRM groups." + ) argparser.add_argument( "--conf", @@ -60,6 +94,12 @@ try: help="Create a configuration file", ) + argparser.add_argument( + "--create-cron", + action="store", + help="Create a cron job", + ) + args = argparser.parse_args() # If a path to a config file was provided @@ -69,42 +109,54 @@ try: config_file = Path(args.conf) if not config_file.is_file() and not args.create_conf: raise FileNotFoundError( - f"Configuration file '{config_file}' does not exist.") + f"Configuration file '{config_file}' does not exist." + ) # Create configuration file if requested and exit if args.create_conf: create_config_file(config_file) exit(0) + # Create the crob job if requested and exit + if args.create_cron: + cron_job = args.create_cron + create_cron_job(cron_job, config_file) + exit(0) + # Load configuration file - with open(config_file, 'r') as file: + with open(config_file, "r") as file: config = yaml.safe_load(file) # Get values from configuration file - AD_DOMAIN = AD_DOMAIN or config['AD']['DOMAIN'] - AD_USER_NAME = AD_USER_NAME or config['AD']['USER'] - AD_PASSWORD = AD_PASSWORD or config['AD']['PASSWORD'] - AD_LDAP_SERVER = AD_LDAP_SERVER or config['AD'].get('LDAP_SERVER') - AD_TIMEZONE = AD_TIMEZONE \ - or pytz.timezone(config['AD'].get('TIMEZONE', 'UTC')) - AD_PARENT_GROUP = AD_PARENT_GROUP or config['AD']['PARENT_GROUP'] - STDOUT_LOG_LEVEL = STDOUT_LOG_LEVEL \ - or config['LOGGING'].get('STDOUT_LOG_LEVEL', 'INFO') - FILE_LOG_LEVEL = FILE_LOG_LEVEL \ - or config['LOGGING'].get('FILE_LOG_LEVEL', 'WARNING') - LOG_DIR = LOG_DIR or config['LOGGING'].get('LOG_DIR') - CIVICRM_BASE_URL = CIVICRM_BASE_URL or config['CIVICRM']['BASE_URL'] - CIVICRM_API_KEY = CIVICRM_API_KEY or config['CIVICRM']['API_KEY'] - CIVICRM_BATCH_SIZE = CIVICRM_BATCH_SIZE \ - or config['CIVICRM']['BATCH_SIZE'] - CIVICRM_RETRIES = CIVICRM_RETRIES \ - or config['CIVICRM'].get('RETRIES', 3) - CIVICRM_IGNORE_SSL = CIVICRM_IGNORE_SSL \ - or bool(config['CIVICRM'].get('IGNORE_SSL', False)) - NTFY_URL = NTFY_URL or config['NTFY'].get('URL') if 'NTFY' in config else None - NTFY_TOPIC = NTFY_TOPIC or config['NTFY'].get('TOPIC') if 'NTFY' in config else None - NTFY_ACCESS_TOKEN = NTFY_ACCESS_TOKEN \ - or config['NTFY'].get('ACCESS_TOKEN') if 'NTFY' in config else None + AD_DOMAIN = AD_DOMAIN or config["AD"]["DOMAIN"] + AD_USER_NAME = AD_USER_NAME or config["AD"]["USER"] + AD_PASSWORD = AD_PASSWORD or config["AD"]["PASSWORD"] + AD_LDAP_SERVER = AD_LDAP_SERVER or config["AD"].get("LDAP_SERVER") + AD_TIMEZONE = AD_TIMEZONE or pytz.timezone(config["AD"].get("TIMEZONE", "UTC")) + AD_PARENT_GROUP = AD_PARENT_GROUP or config["AD"]["PARENT_GROUP"] + STDOUT_LOG_LEVEL = STDOUT_LOG_LEVEL or config["LOGGING"].get( + "STDOUT_LOG_LEVEL", "INFO" + ) + FILE_LOG_LEVEL = FILE_LOG_LEVEL or config["LOGGING"].get( + "FILE_LOG_LEVEL", "WARNING" + ) + LOG_DIR = LOG_DIR or config["LOGGING"].get("LOG_DIR") + CIVICRM_BASE_URL = CIVICRM_BASE_URL or config["CIVICRM"]["BASE_URL"] + CIVICRM_API_KEY = CIVICRM_API_KEY or config["CIVICRM"]["API_KEY"] + CIVICRM_BATCH_SIZE = CIVICRM_BATCH_SIZE or config["CIVICRM"]["BATCH_SIZE"] + CIVICRM_RETRIES = CIVICRM_RETRIES or config["CIVICRM"].get("RETRIES", 3) + CIVICRM_IGNORE_SSL = CIVICRM_IGNORE_SSL or bool( + config["CIVICRM"].get("IGNORE_SSL", False) + ) + NTFY_URL = NTFY_URL or config["NTFY"].get("URL") if "NTFY" in config else None + NTFY_TOPIC = ( + NTFY_TOPIC or config["NTFY"].get("TOPIC") if "NTFY" in config else None + ) + NTFY_ACCESS_TOKEN = ( + NTFY_ACCESS_TOKEN or config["NTFY"].get("ACCESS_TOKEN") + if "NTFY" in config + else None + ) # Check if some required values are missing required = { @@ -117,11 +169,12 @@ try: "CIVICRM_API_KEY": CIVICRM_API_KEY, } if len(missing := [k for k, v in required.items() if v is None]) > 0: - raise ValueError('Some required values are missing. ' - 'Please use a configuration file ' - 'or provide all required environment variables. ' - 'Missing: %s' - % ','.join(missing)) + raise ValueError( + "Some required values are missing. " + "Please use a configuration file " + "or provide all required environment variables. " + "Missing: %s" % ",".join(missing) + ) except Exception as e: logger.error(e, exc_info=True) -- 2.47.2 From af294d387448f41f6ef139e69a6954a69f52b495 Mon Sep 17 00:00:00 2001 From: leiva Date: Mon, 24 Mar 2025 15:05:34 +0100 Subject: [PATCH 02/10] FIX: Changed script checking --- src/adgroupsync/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adgroupsync/conf.py b/src/adgroupsync/conf.py index e9fa9b9..5c5c073 100644 --- a/src/adgroupsync/conf.py +++ b/src/adgroupsync/conf.py @@ -22,7 +22,7 @@ def create_config_file(dest: Path): def create_cron_job(cron_job: str, config_file: Path): # Check if the script exists and its executable - if not shutil.which("test1"): + if not shutil.which(__package__): print( f"No executable found, please add this to your crontab manually: '/path/to/adgroupsync --conf {config_file} >/dev/null 2>&1'" ) -- 2.47.2 From b00b85753b57c5310a24eca98dc0fe67d42d3636 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 10:05:56 +0100 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=91=B7=20add=20push=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + src/adgroupsync/exceptions.py | 0 2 files changed, 1 insertion(+) create mode 100644 src/adgroupsync/exceptions.py diff --git a/pyproject.toml b/pyproject.toml index 48782c0..ae6b44c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ [[tool.uv.index]] name = "propeace" url = "https://git.propeace.de/api/packages/ProPeace/pypi/simple/" +publish-url = "https://git.propeace.de/api/packages/ProPeace/pypi" explicit = true [tool.uv.sources] diff --git a/src/adgroupsync/exceptions.py b/src/adgroupsync/exceptions.py new file mode 100644 index 0000000..e69de29 -- 2.47.2 From beff603dd12480d69cb1b3021034c135a711187d Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 10:08:30 +0100 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20use=20toml=20to=20parse=20the?= =?UTF-8?q?=20recent=5Frun=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + src/adgroupsync/__main__.py | 7 +++ src/adgroupsync/exceptions.py | 5 ++ src/adgroupsync/models.py | 88 +++++++++++++---------------------- uv.lock | 13 +++++- 5 files changed, 58 insertions(+), 56 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae6b44c..5466561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "ms-active-directory>=1.14.1", "python-crontab>=3.2.0", "pyyaml>=6.0.2", + "tomli-w>=1.2.0", "validators>=0.34.0", ] diff --git a/src/adgroupsync/__main__.py b/src/adgroupsync/__main__.py index 976eb1a..08283e2 100644 --- a/src/adgroupsync/__main__.py +++ b/src/adgroupsync/__main__.py @@ -5,6 +5,7 @@ from pathlib import Path from ldap3 import SIMPLE from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject +from .exceptions import ScriptAlreadyRunningError from .conf import ( AD_DOMAIN, AD_USER_NAME, @@ -265,6 +266,7 @@ def main(): # Get the recent run timestamp file_path = Path().home() / '.recent_run' + with RecentRun(file_path, tz=AD_TIMEZONE) as recent_run: if recent_run.datetime is None: logger.info('No recent run found') @@ -280,6 +282,11 @@ def main(): started_at = recent_run.started_at.strftime('%Y-%m-%d %H:%M:%S %Z') logger.info(f"Setting previous run to: {started_at}") + # Exit if the script is already running + except ScriptAlreadyRunningError: + logger.info('Script is already running. Exiting...') + exit(0) + except Exception as e: logger.error(f"An error occurred: {e}", exc_info=True) exit(1) diff --git a/src/adgroupsync/exceptions.py b/src/adgroupsync/exceptions.py index e69de29..12cb51b 100644 --- a/src/adgroupsync/exceptions.py +++ b/src/adgroupsync/exceptions.py @@ -0,0 +1,5 @@ +class ScriptAlreadyRunningError(Exception): + """ + A custom exception to raise when the script is already running + """ + pass \ No newline at end of file diff --git a/src/adgroupsync/models.py b/src/adgroupsync/models.py index 3e74df1..ec56bab 100644 --- a/src/adgroupsync/models.py +++ b/src/adgroupsync/models.py @@ -1,15 +1,19 @@ +import datetime import json import logging +import tomllib from collections import deque from datetime import datetime as dt, timezone from pathlib import Path import pytz +import tomli_w from civifang import api from httpx import post from ms_active_directory import ADUser, ADGroup from .enums import Priority +from .exceptions import ScriptAlreadyRunningError logger = logging.getLogger(__package__) @@ -28,7 +32,8 @@ class RecentRun: self._datetime = None self._timezone = tz self._file_path = file_path - self._is_running = False + self._is_running = None + self._already_running = None self._started_at = None # Create the file if it does not exist @@ -36,6 +41,10 @@ class RecentRun: self._read_data_from_file() + # If the script was already running, throw an exception + if self._already_running: + raise ScriptAlreadyRunningError('The script is already running.') + def _sync_file( self, recent_run: dt | None = None, @@ -47,69 +56,38 @@ class RecentRun: :param is_running: :return: """ - # Convert the is_running boolean to a string - is_running = 'true' if is_running else 'false' \ - if is_running is not None else None + rr = recent_run if recent_run else self._datetime + is_running = is_running if is_running is not None else self._already_running + new_data = { + 'recent-run': rr, + 'is-running': is_running, + } - # Read the file and update the values if they are different - with open(self._file_path, 'r+') as f: - # Read the data from the file - data = f.readlines() - old_recent_run, old_is_running = self._read_data(data) - - # Update the values if they were provided - timestamp = recent_run.timestamp() if recent_run else old_recent_run - is_running = is_running or old_is_running - new_data = [ - f"recent-run:{timestamp}", - '\n', - f"is-running:{is_running}", - ] - - # Write the new data to the file - f.seek(0) - f.truncate() - f.writelines(new_data) - - @staticmethod - def _read_data(data: list): - """ - Read data - :param data: - :return: Tuple of recent_run and is_running ('true'/'false') - """ - time = None - is_running = None - for line in data: - line = line.strip() - if line.startswith('recent-run:'): - time = line.split(':', 1)[1].strip() - elif line.startswith('is-running:'): - is_running = line.split(':', 1)[1].strip() - - return float(time), is_running + # Write the data to the file + with open(self._file_path, 'wb') as f: + tomli_w.dump(new_data, f) def _read_data_from_file(self): """ Read the recent run time from the file :return: """ - with open(self._file_path, 'r') as f: - data = f.readlines() - recent_run, is_running = self._read_data(data) - - # Read running status - self._is_running = is_running == 'true' + with open(self._file_path, 'rb') as f: + data = tomllib.load(f) + self._already_running = data.get('is-running', False) + recent_run = data.get('recent-run') # Set the datetime to the recent run time if not recent_run: return - try: - self._datetime = dt.fromtimestamp(float(recent_run)) \ + if isinstance(recent_run, datetime.datetime): + self._datetime = recent_run + elif isinstance(recent_run, float): + self._datetime = dt.fromtimestamp(recent_run) \ .astimezone(self._timezone) - except ValueError as e: + else: raise ValueError( - f"Invalid timestamp '{recent_run}' in {self._file_path}: {e}") + f"Invalid recent_run '{recent_run}' in {self._file_path}.") @property def datetime(self) -> dt | None: @@ -177,14 +155,14 @@ class RecentRun: return self def __exit__(self, exc_type, exc_val, exc_tb): - datetime = None + recent_run = None self._is_running = False - # If an exception occurred, do not update the recent run timestamp + # If no exception occurred, set the recent run time to the current time if exc_type is None: - self.datetime = datetime = self._started_at + self.datetime = recent_run = self._started_at - self._sync_file(datetime, is_running=self._is_running) + self._sync_file(recent_run=recent_run, is_running=self._is_running) def __gt__(self, other: dt | str | float): return self.datetime > self._to_datetime(other) diff --git a/uv.lock b/uv.lock index b77ef59..26e7ccf 100644 --- a/uv.lock +++ b/uv.lock @@ -4,13 +4,14 @@ requires-python = ">=3.12" [[package]] name = "adgroupsync" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "civifang" }, { name = "httpx" }, { name = "ms-active-directory" }, { name = "pyyaml" }, + { name = "tomli-w" }, { name = "validators" }, ] @@ -26,6 +27,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "ms-active-directory", specifier = ">=1.14.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "tomli-w", specifier = ">=1.2.0" }, { name = "validators", specifier = ">=0.34.0" }, ] @@ -447,6 +449,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 }, +] + [[package]] name = "tomlkit" version = "0.13.2" -- 2.47.2 From 19603d6d9f0b1cef1170c0ace882531486f81ec4 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 10:09:10 +0100 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=201.0.0=20?= =?UTF-8?q?=E2=86=92=201.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- src/adgroupsync/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5466561..5d37e44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "adgroupsync" -version = "1.0.0" +version = "1.1.0" description = "Sync Active Directory groups to CiviCRM" authors = [ { name = "Marc Koch", email = "marc.koch@propeace.de" } @@ -42,7 +42,7 @@ dev = [ adgroupsync = "adgroupsync.__main__:main" [tool.bumpversion] -current_version = "1.0.0" +current_version = "1.1.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/src/adgroupsync/__init__.py b/src/adgroupsync/__init__.py index 06c0b5a..926a3c4 100644 --- a/src/adgroupsync/__init__.py +++ b/src/adgroupsync/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" __author__ = "Marc Koch" \ No newline at end of file -- 2.47.2 From e8503bd073c0c2cfa324187f087e0bb7f04a8eec Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 13:22:23 +0100 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9E=95=20replace=20tomlib=20with=20tom?= =?UTF-8?q?lkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tomlkit can read and write toml files --- .gitignore | 2 +- README.md | 24 +-- pyproject.toml | 3 +- src/adgroupsync/__main__.py | 2 +- src/adgroupsync/conf.py | 145 ++++++++++++------- src/adgroupsync/models.py | 57 ++++---- src/adgroupsync/resources/example_config.yml | 25 ---- uv.lock | 43 +----- 8 files changed, 145 insertions(+), 156 deletions(-) delete mode 100644 src/adgroupsync/resources/example_config.yml diff --git a/.gitignore b/.gitignore index 1365052..feb8653 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -adgroupsync_config.yml +adgroupsync_config.toml # Created by https://www.toptal.com/developers/gitignore/api/pycharm+all # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all diff --git a/README.md b/README.md index 0f88c2f..d53e38f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pipx install --include-deps --index-url https://git.propeace.de/api/packages/Pro Create a new configuration file: ```bash -adgroupsync --create-config --conf /path/to/adgroupsync_config.yaml +adgroupsync --create-config --conf /path/to/adgroupsync_config.toml ``` Edit the configuration file and set the following values: @@ -22,37 +22,39 @@ Edit the configuration file and set the following values: ### AD Configuration - `AD.DOMAIN`: The domain of the Active Directory server. -- `AD.USER`: The username of the user to connect to the Active Directory server. -- `AD.PASSWORD`: The password of the user to connect to the Active Directory - server. -- `AD.LDAP_SERVER`: The LDAP server of the Active Directory server. +- `AD.LDAP_SERVER`: List of LDAP servers to connect to. - `AD.PARENT_GROUP`: The parent group in Active Directory that contains all groups that should be synchronized. - `AD.TIMEZONE`: The timezone of the Active Directory server. +- `AD.USER`: The username of the user to connect to the Active Directory server. +- `AD.PASSWORD`: The password of the user to connect to the Active Directory + server. ### Civicrm Configuration -- `CIVICRM.BASE_URL`: The URL of the CiviCRM server. - `CIVICRM.API_KEY`: The API key of the CiviCRM user. +- `CIVICRM.BASE_URL`: The URL of the CiviCRM server. - `CIVICRM.BATCH_SIZE`: The batch size for the API requests to the CiviCRM server (only applied to contact sync). _DEFAULT: 50_ - `CIVICRM.RETRIES`: The number of retries for the API requests to the CiviCRM server. _DEFAULT: 3_ +- `CIVICRM.IGNORE_SSL`: Allow insecure connections to the CiviCRM server. + _DEFAULT: False_ ### Logging Configuration - `LOGGING.STDOUT_LOG_LEVEL`: The log level for the stdout logger. _DEFAULT: INFO_ - `LOGGING.FILE_LOG_LEVEL`: The log level for the file logger. _DEFAULT: INFO_ -- `LOGGING.LOG_DIR`: The path to the log file. _DEFAULT: - /var/log/adgroupsync.log_ +- `LOGGING.LOG_DIR`: The directory to store the log file. _DEFAULT: + `/var/log/adGroupSync/` ### NTFY (optional) If you want to send notifications about failed syncs, you can configure [ntfy](https://ntfy.sh/). -- `NTFY.URL`: The URL of the NTFY server. +- `NTFY.URL`: The URL of the ntfy server. - `NTFY.TOPIC`: The topic to post the message to. - `NTFY.ACCESS_TOKEN`: The access token for the NTFY server. @@ -61,7 +63,7 @@ configure [ntfy](https://ntfy.sh/). ### Manual Sync ```bash -adgroupsync --conf /path/to/adgroupsync_config.yaml +adgroupsync --conf /path/to/adgroupsync_config.toml ``` ### Cron Job @@ -69,5 +71,5 @@ adgroupsync --conf /path/to/adgroupsync_config.yaml Synchronize the groups every 10 minutes: ```bash -*/10 * * * * adgroupsync --conf /path/to/adgroupsync_config.yaml 2>&1 +*/10 * * * * adgroupsync --conf /path/to/adgroupsync_config.toml > /dev/null 2>&1 ``` diff --git a/pyproject.toml b/pyproject.toml index 5d37e44..7498b12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,7 @@ dependencies = [ "httpx>=0.28.1", "ms-active-directory>=1.14.1", "python-crontab>=3.2.0", - "pyyaml>=6.0.2", - "tomli-w>=1.2.0", + "tomlkit>=0.13.2", "validators>=0.34.0", ] diff --git a/src/adgroupsync/__main__.py b/src/adgroupsync/__main__.py index 08283e2..5dbd796 100644 --- a/src/adgroupsync/__main__.py +++ b/src/adgroupsync/__main__.py @@ -5,7 +5,6 @@ from pathlib import Path from ldap3 import SIMPLE from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject -from .exceptions import ScriptAlreadyRunningError from .conf import ( AD_DOMAIN, AD_USER_NAME, @@ -25,6 +24,7 @@ from .conf import ( NTFY_TOPIC, NTFY_ACCESS_TOKEN, ) +from .exceptions import ScriptAlreadyRunningError from .logger import setup_logging from .models import RecentRun, CiviCrm, Ntfy diff --git a/src/adgroupsync/conf.py b/src/adgroupsync/conf.py index 5c5c073..d6b5b58 100644 --- a/src/adgroupsync/conf.py +++ b/src/adgroupsync/conf.py @@ -6,37 +6,83 @@ from crontab import CronTab, CronSlices import shutil import pytz -import yaml +import tomlkit +from tomlkit.toml_file import TOMLFile logger = logging.getLogger(__package__) def create_config_file(dest: Path): if dest.is_dir(): - dest = dest / f"{__package__}_config.yml" - example_conf = Path(__file__).parent / "resources" / "example_config.yml" - with open(example_conf, "r") as source: - with open(dest, "w") as d: - d.writelines(source) + dest = dest / f"{__package__}_config.toml" + conf = tomlkit.document() + conf.add(tomlkit.comment(f"Configuration file for {__package__}")) + conf.add(tomlkit.nl()) -def create_cron_job(cron_job: str, config_file: Path): - # Check if the script exists and its executable - if not shutil.which(__package__): - print( - f"No executable found, please add this to your crontab manually: '/path/to/adgroupsync --conf {config_file} >/dev/null 2>&1'" - ) + # Add AD section + ad = tomlkit.table() + ad.add('DOMAIN', 'ad.example.com') + ad['DOMAIN'].comment('The domain of the Active Directory server') + ad.add('LDAP_SERVER', ['ldaps://server1.ad.example.com:636']) + ad['LDAP_SERVER'].comment('List of LDAP servers to connect to') + ad.add('PARENT_GROUP', 'Mailinglists') + ad['PARENT_GROUP'].comment('The parent group in Active Directory that ' + 'contains all groups that should be ' + 'synchronized') + ad.add('TIMEZONE', 'UTC') + ad.add('USER', tomlkit.string('example\\username', literal=True)) + ad['USER'].comment('The username of the user to connect to the Active ' + 'Directory server. (use single quotes)') + ad.add('PASSWORD', 'xxxxxxxx') + ad['PASSWORD'].comment('The password of the user to connect to the Active ' + 'Directory') + conf.add('AD', ad) - # Checking if the string is valid - if not CronSlices.is_valid(cron_job): - raise Exception(f"Cron job '{cron_job}' is not valid.") + # Add CiviCRM section + civicrm = tomlkit.table() + civicrm.add('BASE_URL', 'https://civicrm.example.com') + civicrm.add('API_KEY', 'xxxxxxxx') + civicrm['API_KEY'].comment('The API key of the CiviCRM user') + civicrm['BASE_URL'].comment('The URL of the CiviCRM server') + civicrm.add('BATCH_SIZE', 50) + civicrm['BATCH_SIZE'].comment('The batch size for the API requests to the ' + 'CiviCRM server - only applied to contact ' + 'sync (default: 50)') + civicrm.add('RETRIES', 3) + civicrm['RETRIES'].comment('The number of retries for the API requests to ' + 'the CiviCRM (default: 3)') + civicrm.add('IGNORE_SSL', False) + civicrm.value.item('IGNORE_SSL').comment('Allow insecure connections to ' + 'the CiviCRM server (default: ' + 'false)') + conf.add('CIVICRM', civicrm) - # Creating the cron job - cron = CronTab(user=True) - job = cron.new(command=f"adgroupsync --conf {config_file} >/dev/null 2>&1") - job.setall(cron_job) - cron.write() + # Add Logging section + logging_ = tomlkit.table() + logging_.add('STDOUT_LOG_LEVEL', 'info') + logging_['STDOUT_LOG_LEVEL'].comment('The log level for the stdout logger ' + '(default: info)') + logging_.add('FILE_LOG_LEVEL', 'info') + logging_['FILE_LOG_LEVEL'].comment('The log level for the file logger ' + '(default: info)') + logging_.add('LOG_DIR', '/var/log/adGroupSync/') + logging_['LOG_DIR'].comment('The directory to store the log file') + conf.add('LOGGING', logging_) + # Add Ntfy section + ntfy = tomlkit.table() + ntfy.add(tomlkit.comment('Optional section for ntfy configuration')) + ntfy.add('URL', 'https://ntfy.example.com') + ntfy['URL'].comment('The URL of the ntfy server') + ntfy.add('TOPIC', 'adGroupSync') + ntfy['TOPIC'].comment('The topic to post the message to') + ntfy.add('ACCESS_TOKEN', 'tk_xxxxxxxxxxxxxxxxxxx') + ntfy['ACCESS_TOKEN'].comment('The access token for the ntfy server') + conf.add('NTFY', ntfy) + + # Write configuration file + TOMLFile(dest).write(conf) # Assign environment variables or configuration file values AD_DOMAIN = os.getenv("AD_DOMAIN") @@ -124,39 +170,36 @@ try: exit(0) # Load configuration file - with open(config_file, "r") as file: - config = yaml.safe_load(file) + config = TOMLFile(config_file).read() # Get values from configuration file - AD_DOMAIN = AD_DOMAIN or config["AD"]["DOMAIN"] - AD_USER_NAME = AD_USER_NAME or config["AD"]["USER"] - AD_PASSWORD = AD_PASSWORD or config["AD"]["PASSWORD"] - AD_LDAP_SERVER = AD_LDAP_SERVER or config["AD"].get("LDAP_SERVER") - AD_TIMEZONE = AD_TIMEZONE or pytz.timezone(config["AD"].get("TIMEZONE", "UTC")) - AD_PARENT_GROUP = AD_PARENT_GROUP or config["AD"]["PARENT_GROUP"] - STDOUT_LOG_LEVEL = STDOUT_LOG_LEVEL or config["LOGGING"].get( - "STDOUT_LOG_LEVEL", "INFO" - ) - FILE_LOG_LEVEL = FILE_LOG_LEVEL or config["LOGGING"].get( - "FILE_LOG_LEVEL", "WARNING" - ) - LOG_DIR = LOG_DIR or config["LOGGING"].get("LOG_DIR") - CIVICRM_BASE_URL = CIVICRM_BASE_URL or config["CIVICRM"]["BASE_URL"] - CIVICRM_API_KEY = CIVICRM_API_KEY or config["CIVICRM"]["API_KEY"] - CIVICRM_BATCH_SIZE = CIVICRM_BATCH_SIZE or config["CIVICRM"]["BATCH_SIZE"] - CIVICRM_RETRIES = CIVICRM_RETRIES or config["CIVICRM"].get("RETRIES", 3) - CIVICRM_IGNORE_SSL = CIVICRM_IGNORE_SSL or bool( - config["CIVICRM"].get("IGNORE_SSL", False) - ) - NTFY_URL = NTFY_URL or config["NTFY"].get("URL") if "NTFY" in config else None - NTFY_TOPIC = ( - NTFY_TOPIC or config["NTFY"].get("TOPIC") if "NTFY" in config else None - ) - NTFY_ACCESS_TOKEN = ( - NTFY_ACCESS_TOKEN or config["NTFY"].get("ACCESS_TOKEN") - if "NTFY" in config - else None - ) + AD_DOMAIN = AD_DOMAIN or config['AD']['DOMAIN'] + AD_USER_NAME = AD_USER_NAME or config['AD']['USER'] + AD_PASSWORD = AD_PASSWORD or config['AD']['PASSWORD'] + AD_LDAP_SERVER = AD_LDAP_SERVER or config['AD'].get('LDAP_SERVER') + AD_TIMEZONE = AD_TIMEZONE \ + or pytz.timezone(config['AD'].get('TIMEZONE', 'UTC')) + AD_PARENT_GROUP = AD_PARENT_GROUP or config['AD']['PARENT_GROUP'] + STDOUT_LOG_LEVEL = STDOUT_LOG_LEVEL \ + or config['LOGGING'].get('STDOUT_LOG_LEVEL', 'INFO') + FILE_LOG_LEVEL = FILE_LOG_LEVEL \ + or config['LOGGING'].get('FILE_LOG_LEVEL', 'WARNING') + LOG_DIR = LOG_DIR or config['LOGGING'].get('LOG_DIR') + CIVICRM_BASE_URL = CIVICRM_BASE_URL or config['CIVICRM']['BASE_URL'] + CIVICRM_API_KEY = CIVICRM_API_KEY or config['CIVICRM']['API_KEY'] + CIVICRM_BATCH_SIZE = CIVICRM_BATCH_SIZE \ + or config['CIVICRM']['BATCH_SIZE'] + CIVICRM_RETRIES = CIVICRM_RETRIES \ + or config['CIVICRM'].get('RETRIES', 3) + CIVICRM_IGNORE_SSL = CIVICRM_IGNORE_SSL \ + or bool(config['CIVICRM'].get('IGNORE_SSL', False)) + NTFY_URL = NTFY_URL or config['NTFY'].get( + 'URL') if 'NTFY' in config else None + NTFY_TOPIC = NTFY_TOPIC or config['NTFY'].get( + 'TOPIC') if 'NTFY' in config else None + NTFY_ACCESS_TOKEN = NTFY_ACCESS_TOKEN \ + or config['NTFY'].get( + 'ACCESS_TOKEN') if 'NTFY' in config else None # Check if some required values are missing required = { diff --git a/src/adgroupsync/models.py b/src/adgroupsync/models.py index ec56bab..43751a2 100644 --- a/src/adgroupsync/models.py +++ b/src/adgroupsync/models.py @@ -1,16 +1,15 @@ import datetime import json import logging -import tomllib from collections import deque from datetime import datetime as dt, timezone from pathlib import Path import pytz -import tomli_w from civifang import api from httpx import post from ms_active_directory import ADUser, ADGroup +from tomlkit.toml_file import TOMLFile from .enums import Priority from .exceptions import ScriptAlreadyRunningError @@ -56,38 +55,46 @@ class RecentRun: :param is_running: :return: """ - rr = recent_run if recent_run else self._datetime - is_running = is_running if is_running is not None else self._already_running - new_data = { - 'recent-run': rr, - 'is-running': is_running, - } + # Return if no data is provided + if recent_run is None and is_running is None: + return + + # Read the existing data + toml_file = TOMLFile(self._file_path) + toml = toml_file.read() + + # Update the values + if recent_run: + toml['recent_run'] = recent_run + if is_running is not None: + toml['is-running'] = is_running # Write the data to the file - with open(self._file_path, 'wb') as f: - tomli_w.dump(new_data, f) + toml_file.write(toml) def _read_data_from_file(self): """ Read the recent run time from the file :return: """ - with open(self._file_path, 'rb') as f: - data = tomllib.load(f) - self._already_running = data.get('is-running', False) - recent_run = data.get('recent-run') + # Read the data from the file + toml_file = TOMLFile(self._file_path) + toml = toml_file.read() - # Set the datetime to the recent run time - if not recent_run: - return - if isinstance(recent_run, datetime.datetime): - self._datetime = recent_run - elif isinstance(recent_run, float): - self._datetime = dt.fromtimestamp(recent_run) \ - .astimezone(self._timezone) - else: - raise ValueError( - f"Invalid recent_run '{recent_run}' in {self._file_path}.") + self._already_running = toml.get('is-running', False) + recent_run = toml.get('recent-run') + + # Set the datetime to the recent run time + if not recent_run: + return + if isinstance(recent_run, datetime.datetime): + self._datetime = recent_run + elif isinstance(recent_run, float): + self._datetime = dt.fromtimestamp(recent_run) \ + .astimezone(self._timezone) + else: + raise ValueError( + f"Invalid recent_run '{recent_run}' in {self._file_path}.") @property def datetime(self) -> dt | None: diff --git a/src/adgroupsync/resources/example_config.yml b/src/adgroupsync/resources/example_config.yml deleted file mode 100644 index fdd31de..0000000 --- a/src/adgroupsync/resources/example_config.yml +++ /dev/null @@ -1,25 +0,0 @@ -AD: - DOMAIN: ad.example.com - USER: example\username - PASSWORD: xxxxxxxx - LDAP_SERVER: - - ldaps://server1.ad.example.com:636 - PARENT_GROUP: Mailinglists - TIMEZONE: UTC - -LOGGING: - STDOUT_LOG_LEVEL: info - FILE_LOG_LEVEL: info - LOG_DIR: /var/log/adGroupSync/ - -CIVICRM: - BASE_URL: https://civicrm.example.com - API_KEY: xxxxxxxx - BATCH_SIZE: 50 - RETRIES: 3 - # IGNORE_SSL: yes - -NTFY: - URL: https://ntfy.example.com - TOPIC: adGroupSync - ACCESS_TOKEN: tk_xxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/uv.lock b/uv.lock index 26e7ccf..6c843ce 100644 --- a/uv.lock +++ b/uv.lock @@ -4,14 +4,13 @@ requires-python = ">=3.12" [[package]] name = "adgroupsync" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "civifang" }, { name = "httpx" }, { name = "ms-active-directory" }, - { name = "pyyaml" }, - { name = "tomli-w" }, + { name = "tomlkit" }, { name = "validators" }, ] @@ -26,8 +25,7 @@ requires-dist = [ { name = "civifang", specifier = ">=0.2.6", index = "https://git.propeace.de/api/packages/ProPeace/pypi/simple/" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "ms-active-directory", specifier = ">=1.14.1" }, - { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "tomli-w", specifier = ">=1.2.0" }, + { name = "tomlkit", specifier = ">=0.13.2" }, { name = "validators", specifier = ">=0.34.0" }, ] @@ -366,32 +364,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, ] -[[package]] -name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, -] - [[package]] name = "questionary" version = "2.1.0" @@ -449,15 +421,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 }, -] - [[package]] name = "tomlkit" version = "0.13.2" -- 2.47.2 From ad1bf221b7967a734b2a5653810418d094b1c695 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 17:49:01 +0100 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=8A=20improve=20logging=20messag?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adgroupsync/__main__.py | 2 +- src/adgroupsync/models.py | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/adgroupsync/__main__.py b/src/adgroupsync/__main__.py index 5dbd796..cfa94dc 100644 --- a/src/adgroupsync/__main__.py +++ b/src/adgroupsync/__main__.py @@ -280,7 +280,7 @@ def main(): # Log the current run timestamp started_at = recent_run.started_at.strftime('%Y-%m-%d %H:%M:%S %Z') - logger.info(f"Setting previous run to: {started_at}") + logger.info(f"Setting recent run to: {started_at}") # Exit if the script is already running except ScriptAlreadyRunningError: diff --git a/src/adgroupsync/models.py b/src/adgroupsync/models.py index 43751a2..3d9e686 100644 --- a/src/adgroupsync/models.py +++ b/src/adgroupsync/models.py @@ -65,7 +65,7 @@ class RecentRun: # Update the values if recent_run: - toml['recent_run'] = recent_run + toml['recent-run'] = recent_run if is_running is not None: toml['is-running'] = is_running @@ -94,7 +94,7 @@ class RecentRun: .astimezone(self._timezone) else: raise ValueError( - f"Invalid recent_run '{recent_run}' in {self._file_path}.") + f"Invalid recent-run '{recent_run}' in {self._file_path}.") @property def datetime(self) -> dt | None: @@ -167,7 +167,7 @@ class RecentRun: # If no exception occurred, set the recent run time to the current time if exc_type is None: - self.datetime = recent_run = self._started_at + recent_run = self._started_at self._sync_file(recent_run=recent_run, is_running=self._is_running) @@ -316,22 +316,27 @@ class CiviCrm: :return: Number of failed requests """ error_count = 0 + self._error_bag = [] failed_requests = {'groups': deque(), 'users': deque()} for name, requests in self._requests.items(): - logger.info(f"Sending {len(requests)} {name}") + counter = 0 + number_of_requests = len(requests) + logger.info(f"Sending {number_of_requests} {name}") while requests: + counter += 1 request = requests.popleft() try: result = api.api3(**request) - logger.info(f"Result: {result}", extra={'result': result}) + logger.info(f"Result {counter}/{number_of_requests}: " + f"{result}", extra={'result': result}) if result.get('is_error', False): raise Exception(result.get('error_message')) except Exception as e: - self._error_bag.append({ + error = { 'name': name, 'request': { 'entity': request['entity'], @@ -342,9 +347,10 @@ class CiviCrm: 'method': str(request['method']), }, 'error': str(e), - }) + } + self._error_bag.append(error) logger.exception(f"Error sending request: {e}", - extra=request) + extra={'error': error}) failed_requests[name].append(request) error_count += 1 -- 2.47.2 From f4b2d863890323dda49fd7ae5a7547a34d967221 Mon Sep 17 00:00:00 2001 From: Marc Koch Date: Fri, 21 Mar 2025 17:49:21 +0100 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=201.1.0=20?= =?UTF-8?q?=E2=86=92=201.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- src/adgroupsync/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7498b12..b7ce338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "adgroupsync" -version = "1.1.0" +version = "1.2.0" description = "Sync Active Directory groups to CiviCRM" authors = [ { name = "Marc Koch", email = "marc.koch@propeace.de" } @@ -41,7 +41,7 @@ dev = [ adgroupsync = "adgroupsync.__main__:main" [tool.bumpversion] -current_version = "1.1.0" +current_version = "1.2.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/src/adgroupsync/__init__.py b/src/adgroupsync/__init__.py index 926a3c4..69aeeae 100644 --- a/src/adgroupsync/__init__.py +++ b/src/adgroupsync/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" __author__ = "Marc Koch" \ No newline at end of file -- 2.47.2 From b28dfbc12e2197b2b46ba46729ae8b035021c524 Mon Sep 17 00:00:00 2001 From: leiva Date: Mon, 24 Mar 2025 13:57:45 +0100 Subject: [PATCH 09/10] ADD: Cron job arg creation --- src/adgroupsync/conf.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/adgroupsync/conf.py b/src/adgroupsync/conf.py index d6b5b58..20c3d76 100644 --- a/src/adgroupsync/conf.py +++ b/src/adgroupsync/conf.py @@ -84,6 +84,23 @@ def create_config_file(dest: Path): # Write configuration file TOMLFile(dest).write(conf) +def create_cron_job(cron_job: str, config_file: Path): + # Check if the script exists and its executable + if not shutil.which(__package__): + print( + f"No executable found, please add this to your crontab manually: '/path/to/adgroupsync --conf {config_file} >/dev/null 2>&1'" + ) + + # Checking if the string is valid + if not CronSlices.is_valid(cron_job): + raise Exception(f"Cron job '{cron_job}' is not valid.") + + # Creating the cron job + cron = CronTab(user=True) + job = cron.new(command=f"adgroupsync --conf {config_file} >/dev/null 2>&1") + job.setall(cron_job) + cron.write() + # Assign environment variables or configuration file values AD_DOMAIN = os.getenv("AD_DOMAIN") AD_USER_NAME = os.getenv("AD_USER") -- 2.47.2 From 5abf2582f1681215abd92f196ed2298b7d33c327 Mon Sep 17 00:00:00 2001 From: leiva Date: Tue, 25 Mar 2025 10:10:24 +0100 Subject: [PATCH 10/10] Added myself to pytoml authors :) --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b7ce338..109052f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,8 @@ name = "adgroupsync" version = "1.2.0" description = "Sync Active Directory groups to CiviCRM" authors = [ - { name = "Marc Koch", email = "marc.koch@propeace.de" } + { name = "Marc Koch", email = "marc.koch@propeace.de" }, + { name = "Álvaro Leiva", email = "alvaro.leiva@propeace.de" } ] readme = "README.md" license = "MIT" -- 2.47.2