Initial commit
This commit is contained in:
commit
cbf68294ca
16 changed files with 497 additions and 0 deletions
0
.env
Normal file
0
.env
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
10
.idea/adGroupSync.iml
generated
Normal file
10
.idea/adGroupSync.iml
generated
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (adGroupSync)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
77
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
77
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
|
@ -0,0 +1,77 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredUrls">
|
||||
<list>
|
||||
<option value="http://localhost" />
|
||||
<option value="http://127.0.0.1" />
|
||||
<option value="http://0.0.0.0" />
|
||||
<option value="http://www.w3.org/" />
|
||||
<option value="http://json-schema.org/draft" />
|
||||
<option value="http://java.sun.com/" />
|
||||
<option value="http://xmlns.jcp.org/" />
|
||||
<option value="http://javafx.com/javafx/" />
|
||||
<option value="http://javafx.com/fxml" />
|
||||
<option value="http://maven.apache.org/xsd/" />
|
||||
<option value="http://maven.apache.org/POM/" />
|
||||
<option value="http://www.springframework.org/schema/" />
|
||||
<option value="http://www.springframework.org/tags" />
|
||||
<option value="http://www.springframework.org/security/tags" />
|
||||
<option value="http://www.thymeleaf.org" />
|
||||
<option value="http://www.jboss.org/j2ee/schema/" />
|
||||
<option value="http://www.jboss.com/xml/ns/" />
|
||||
<option value="http://www.ibm.com/webservices/xsd" />
|
||||
<option value="http://activemq.apache.org/schema/" />
|
||||
<option value="http://schema.cloudfoundry.org/spring/" />
|
||||
<option value="http://schemas.xmlsoap.org/" />
|
||||
<option value="http://cxf.apache.org/schemas/" />
|
||||
<option value="http://primefaces.org/ui" />
|
||||
<option value="http://tiles.apache.org/" />
|
||||
<option value="http://" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ourVersions">
|
||||
<value>
|
||||
<list size="3">
|
||||
<item index="0" class="java.lang.String" itemvalue="3.12" />
|
||||
<item index="1" class="java.lang.String" itemvalue="3.11" />
|
||||
<item index="2" class="java.lang.String" itemvalue="3.10" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="13">
|
||||
<item index="0" class="java.lang.String" itemvalue="pandas" />
|
||||
<item index="1" class="java.lang.String" itemvalue="PyPDF2" />
|
||||
<item index="2" class="java.lang.String" itemvalue="drf-spectacular" />
|
||||
<item index="3" class="java.lang.String" itemvalue="django-crispy-forms" />
|
||||
<item index="4" class="java.lang.String" itemvalue="Django" />
|
||||
<item index="5" class="java.lang.String" itemvalue="qrcode" />
|
||||
<item index="6" class="java.lang.String" itemvalue="psycopg2" />
|
||||
<item index="7" class="java.lang.String" itemvalue="django-active-link" />
|
||||
<item index="8" class="java.lang.String" itemvalue="shortuuid" />
|
||||
<item index="9" class="java.lang.String" itemvalue="uwsgi" />
|
||||
<item index="10" class="java.lang.String" itemvalue="djangorestframework" />
|
||||
<item index="11" class="java.lang.String" itemvalue="Pillow" />
|
||||
<item index="12" class="java.lang.String" itemvalue="pyproject_hooks" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="N813" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyTypeHintsInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="ReassignedToPlainText" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettingsMigration">
|
||||
<option name="stateVersion" value="1" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (tazPlease)" project-jdk-type="Python SDK" />
|
||||
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/adGroupSync.iml" filepath="$PROJECT_DIR$/.idea/adGroupSync.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
0
CiviCRM/__init__.py
Normal file
0
CiviCRM/__init__.py
Normal file
0
CiviCRM/models.py
Normal file
0
CiviCRM/models.py
Normal file
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# adGroupSync
|
0
adGroupSync/__init__.py
Normal file
0
adGroupSync/__init__.py
Normal file
118
adGroupSync/logger.py
Normal file
118
adGroupSync/logger.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
import atexit
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import override
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
if LOG_DIR := os.getenv("LOG_DIR"):
|
||||
LOG_DIR = Path(LOG_DIR)
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
LOG_RECORD_BUILTIN_ATTRS = {
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
|
||||
|
||||
def setup_logging(loglevel: int | str | None = None):
|
||||
"""
|
||||
Setup logging configuration
|
||||
:return:
|
||||
"""
|
||||
config_file = PROJECT_ROOT / "conf" / "logging_config.json"
|
||||
with open(config_file, "r") as file:
|
||||
config = json.load(file)
|
||||
|
||||
# Override log level if provided
|
||||
if loglevel:
|
||||
level_str = logging.getLevelName(loglevel)
|
||||
if not isinstance(level_str, str):
|
||||
level_str = logging.getLevelName(level_str)
|
||||
for handler in config["handlers"].values():
|
||||
handler["level"] = level_str
|
||||
|
||||
# Set log file path to user log directory
|
||||
if LOG_DIR:
|
||||
config["handlers"]["file"]["filename"] = LOG_DIR / "adGroupSync.log.jsonl"
|
||||
|
||||
# Create path and file if it does not exist
|
||||
Path(config["handlers"]["file"]["filename"]).parent.mkdir(
|
||||
parents=True, exist_ok=True)
|
||||
Path(config["handlers"]["file"]["filename"]).touch()
|
||||
|
||||
logging.config.dictConfig(config)
|
||||
queue_handler = logging.getHandlerByName("queue_handler")
|
||||
if queue_handler is not None:
|
||||
queue_handler.listener.start()
|
||||
atexit.register(queue_handler.listener.stop)
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""
|
||||
A custom JSON formatter for logging
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fmt_keys: dict[str, str] | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.fmt_keys = fmt_keys if fmt_keys is not None else {}
|
||||
|
||||
@override
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
message = self._prepare_log_dict(record)
|
||||
return json.dumps(message, default=str)
|
||||
|
||||
def _prepare_log_dict(self, record: logging.LogRecord) -> dict:
|
||||
always_fields = {
|
||||
"message": record.getMessage(),
|
||||
"timestamp": dt.datetime.fromtimestamp(
|
||||
record.created, tz=dt.timezone.utc
|
||||
).isoformat()
|
||||
}
|
||||
if record.exc_info is not None:
|
||||
always_fields["exc_info"] = self.formatException(record.exc_info)
|
||||
|
||||
if record.stack_info is not None:
|
||||
always_fields["stack_info"] = self.formatStack(record.stack_info)
|
||||
|
||||
message = {
|
||||
key: msg_val
|
||||
if (msg_val := always_fields.pop(val, None)) is not None
|
||||
else getattr(record, val)
|
||||
for key, val in self.fmt_keys.items()
|
||||
}
|
||||
message.update(always_fields)
|
||||
|
||||
# Include all other attributes
|
||||
for key, val, in record.__dict__.items():
|
||||
if key not in LOG_RECORD_BUILTIN_ATTRS:
|
||||
message[key] = val
|
||||
|
||||
return message
|
109
adGroupSync/main.py
Normal file
109
adGroupSync/main.py
Normal file
|
@ -0,0 +1,109 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from ldap3 import SIMPLE
|
||||
from ms_active_directory import ADDomain, ADGroup, ADUser, ADObject
|
||||
|
||||
from .models import RecentRun
|
||||
|
||||
DOMAIN = os.getenv('AD_DOMAIN')
|
||||
USER_NAME = os.getenv('AD_USER')
|
||||
PASSWORD = os.getenv('AD_PASSWORD')
|
||||
LDAP_SERVER = [s.strip() for s in os.getenv('AD_LDAP_SERVER').split(',')]
|
||||
TIMEZONE = pytz.timezone(os.getenv('TIMEZONE', 'UTC'))
|
||||
MAILINGLISTS_PARENT_GROUP = os.getenv('AD_MAILINGLISTS_PARENT_GROUP')
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger('adGroupSync')
|
||||
logger.setLevel(os.getenv('LOG_LEVEL', logging.INFO))
|
||||
|
||||
|
||||
def has_changed(object_: ADObject, recent_run):
|
||||
"""
|
||||
Check if the object has changed since the last run
|
||||
:param object_: Object to check
|
||||
:param recent_run: RecentRun object
|
||||
:return: True if the object has changed, False otherwise
|
||||
"""
|
||||
modify_timestamp = object_.get('modifyTimestamp')
|
||||
if modify_timestamp is None:
|
||||
message = (f"Object '{object_.get('sn')}' of type '{type(object_)}' "
|
||||
"does not have 'modifyTimestamp' attribute")
|
||||
logger.error(message)
|
||||
raise ValueError(message)
|
||||
return modify_timestamp > recent_run
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
logger.info("Running group sync")
|
||||
|
||||
# Get the last run timestamp
|
||||
recent_run = RecentRun(Path().home() / '.recent_run')
|
||||
logger.info(f"Last run: {recent_run.datetime.strftime('%Y-%m-%d %H:%M:%S %Z')}")
|
||||
|
||||
# Setup session
|
||||
domain = ADDomain(DOMAIN, ldap_servers_or_uris=LDAP_SERVER)
|
||||
logger.debug(f"Domain: {domain}", extra={'domain': domain})
|
||||
try:
|
||||
session = domain.create_session_as_user(
|
||||
USER_NAME, PASSWORD, authentication_mechanism=SIMPLE)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating session: {e}")
|
||||
exit(1)
|
||||
logger.debug(f"Session: {session}")
|
||||
logger.info(f"Session opened: {session.is_open()}")
|
||||
|
||||
# Get parent group
|
||||
parent_group = session.find_group_by_name(
|
||||
MAILINGLISTS_PARENT_GROUP, ['modifyTimestamp'])
|
||||
parent_group_has_changed = has_changed(parent_group, recent_run)
|
||||
logger.debug(f"Parent group: {parent_group}", extra={
|
||||
'group': parent_group,
|
||||
'modifyTimestamp': parent_group.get('modifyTimestamp'),
|
||||
'has_changed': parent_group_has_changed})
|
||||
|
||||
# Get child groups
|
||||
group_attrs = [
|
||||
'modifyTimestamp',
|
||||
'objectSid',
|
||||
'mail',
|
||||
'sAMAccountName',
|
||||
]
|
||||
group_members = session.find_members_of_group_recursive(
|
||||
parent_group, group_attrs)[1]
|
||||
groups = (m for m in group_members.values() if isinstance(m, ADGroup))
|
||||
logger.debug(f"Found {len(groups)} child groups",
|
||||
extra={'child_group_count': len(groups),
|
||||
'child_groups': groups})
|
||||
users = (m for m in groups if not isinstance(m, ADUser))
|
||||
|
||||
# If the parent group has changed, we need to update the child groups
|
||||
# in CiviCRM
|
||||
if parent_group_has_changed:
|
||||
civicrm.update_mailing_lists(groups)
|
||||
|
||||
# Check if the mailing list groups have changed and update them in CiviCRM
|
||||
for ml_group in groups:
|
||||
if has_changed(ml_group, recent_run):
|
||||
civicrm.update_mailing_list_members(ml_group)
|
||||
|
||||
# Check if the users have changed and update them in CiviCRM
|
||||
for user in users:
|
||||
if has_changed(user, recent_run):
|
||||
civicrm.update_user(user)
|
||||
|
||||
# Set the last run timestamp
|
||||
recent_run.datetime = datetime.now(tz=TIMEZONE)
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
|
||||
|
92
adGroupSync/models.py
Normal file
92
adGroupSync/models.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
from datetime import datetime, tzinfo
|
||||
from pathlib import Path
|
||||
import pytz
|
||||
|
||||
|
||||
class RecentRun:
|
||||
"""
|
||||
Class to manage the last run of the script
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path, timezone: tzinfo = pytz.utc):
|
||||
"""
|
||||
Initialize the class
|
||||
:param file_path: File path to store the last run timestamp
|
||||
:param timezone: Timezone to use for the timestamp
|
||||
"""
|
||||
self._datetime = None
|
||||
self._timezone = timezone
|
||||
self._file_path = file_path
|
||||
|
||||
if not self._file_path.exists():
|
||||
raise FileNotFoundError(f"File {self._file_path} does not exist")
|
||||
|
||||
self._read_recent_run_from_file()
|
||||
|
||||
def _write_recent_run_to_file(self):
|
||||
"""
|
||||
Write the recent run timestamp to the file
|
||||
:return:
|
||||
"""
|
||||
with open(self._file_path, 'w') as f:
|
||||
f.write(str(self.timestamp))
|
||||
|
||||
def _read_recent_run_from_file(self):
|
||||
"""
|
||||
Read the recent run timestamp from the file
|
||||
:return:
|
||||
"""
|
||||
with open(self._file_path, 'r') as f:
|
||||
line = f.readline()
|
||||
if not line or line.strip() == '':
|
||||
return None
|
||||
try:
|
||||
self._datetime = datetime.fromtimestamp(float(line), tz=self._timezone)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid timestamp '{line}' in {self._file_path}")
|
||||
|
||||
@property
|
||||
def datetime(self):
|
||||
return self._datetime
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
return self._datetime.timestamp()
|
||||
|
||||
@datetime.setter
|
||||
def datetime(self, value: datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.astimezone(self._timezone)
|
||||
self._datetime = value
|
||||
self._write_recent_run_to_file()
|
||||
|
||||
@staticmethod
|
||||
def _to_datetime(value: datetime|str|float):
|
||||
"""
|
||||
Convert the value to a datetime object
|
||||
:param value:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = float(value)
|
||||
if isinstance(value, float):
|
||||
value = datetime.fromtimestamp(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid timestamp '{value}'")
|
||||
return value
|
||||
|
||||
def __gt__(self, other: datetime|str|float):
|
||||
return self.datetime > self._to_datetime(other)
|
||||
|
||||
def __lt__(self, other: datetime|str|float):
|
||||
return self.datetime < self._to_datetime(other)
|
||||
|
||||
def __eq__(self, other: datetime|str|float):
|
||||
return self.datetime == self._to_datetime(other)
|
||||
|
||||
def __ge__(self, other: datetime|str|float):
|
||||
return self.datetime >= self._to_datetime(other)
|
||||
|
||||
def __le__(self, other: datetime|str|float):
|
||||
return self.datetime <= self._to_datetime(other)
|
55
conf/logging_config.json
Normal file
55
conf/logging_config.json
Normal file
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": false,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "%(levelname)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%dT%H:%M:%Sz"
|
||||
},
|
||||
"json": {
|
||||
"()": "forum_booking.logger.JSONFormatter",
|
||||
"fmt_keys": {
|
||||
"level": "levelname",
|
||||
"message": "message",
|
||||
"timestamp": "timestamp",
|
||||
"logger": "name",
|
||||
"module": "module",
|
||||
"function": "funcName",
|
||||
"line": "lineno",
|
||||
"thread_name": "threadName"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"stdout": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
"stream": "ext://sys.stdout",
|
||||
"level": "DEBUG"
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"formatter": "json",
|
||||
"filename": "forum_booking.log.jsonl",
|
||||
"level": "INFO",
|
||||
"maxBytes": 10000000,
|
||||
"backupCount": 3
|
||||
},
|
||||
"queue_handler": {
|
||||
"class": "logging.handlers.QueueHandler",
|
||||
"handlers": [
|
||||
"stdout",
|
||||
"file"
|
||||
],
|
||||
"respect_handler_level": true
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"root": {
|
||||
"handlers": [
|
||||
"queue_handler"
|
||||
],
|
||||
"level": "DEBUG"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue