Compare commits
	
		
			143 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 9ee246440f | ||
|  | e2f524ce7a | ||
|  | a58b054292 | ||
|  | ea9e5be1fc | ||
|  | 760ea4727c | ||
|  | f57f2e53a0 | ||
|  | 136a393a17 | ||
|  | 8bbaab78b7 | ||
|  | 067cd59637 | ||
|  | ce6ac7bf53 | ||
|  | 99271c4477 | ||
|  | 156142ed58 | ||
|  | 4b5516c0eb | ||
|  | c3d8d2d240 | ||
|  | c29cf70025 | ||
|  | 6ebce55be3 | ||
|  | 01c4a85bc0 | ||
|  | 12d4206d84 | ||
|  | 946de18bea | ||
|  | 904eb3538c | ||
|  | c851ca9328 | ||
|  | 0ac415ad83 | ||
|  | b3ba34d980 | ||
|  | 52740271d9 | ||
|  | c2e444249a | ||
|  | 97310b091e | ||
|  | 4dda9cc3a1 | ||
|  | a0538b57e2 | ||
|  | d7f394eeb6 | ||
|  | 1bc4571d42 | ||
|  | 22e878502a | ||
|  | 03c1b6e30c | ||
|  | 374a434d98 | ||
|  | f1e85ff0e9 | ||
|  | 6b010f76ea | ||
|  | 0c3e9f7824 | ||
|  | ccca578622 | ||
|  | 56f7c18550 | ||
|  | d438f71bbb | ||
|  | ca5df24b6d | ||
|  | 4a6c2d106f | ||
|  | cd25a9568b | ||
|  | f78a787adb | ||
|  | dc520fa77c | ||
|  | 8f06d4dd9d | ||
|  | a7047183e1 | ||
|  | c0b145da24 | ||
|  | 52e7fd6f72 | ||
|  | 4bbe22b1c7 | ||
|  | 4747ffc08b | ||
|  | 9d07131fd6 | ||
|  | 721126d3db | ||
|  | 2b65f5e3dc | ||
|  | 57f10cf387 | ||
|  | f60c8a173b | ||
|  | 857cd690be | ||
|  | a407b60152 | ||
|  | 2c3c55adc0 | ||
|  | f586b4da17 | ||
|  | 0b7eb41049 | ||
|  | bd19c4e2bd | ||
|  | e8a73087d6 | ||
|  | dde4fd82f4 | ||
|  | 0420c393f3 | ||
|  | c88dac6437 | ||
|  | cd450f55e2 | ||
|  | 190ee7f9fb | ||
|  | fd057300cc | ||
|  | 56791089c1 | ||
|  | e91cb32ca3 | ||
|  | 9ab20df8d2 | ||
|  | 050350501c | ||
|  | d078acdf73 | ||
|  | b786a688b5 | ||
|  | 6b7fe40dd2 | ||
|  | 6f6c422246 | ||
|  | d371ff4f60 | ||
|  | d1a8348912 | ||
|  | be956d3cb6 | ||
|  | ba5beb81b7 | ||
|  | 106bbe5244 | ||
|  | f39d0e7ba2 | ||
|  | de7a1fd8ff | ||
|  | 1ac2b25876 | ||
|  | 9e014d1371 | ||
|  | 93b274a113 | ||
|  | 474c7ae873 | ||
|  | 31690d4cad | ||
|  | bbfc7e7e49 | ||
|  | 1c0aa55e7a | ||
|  | 29778ca19e | ||
|  | 9e87318cc5 | ||
|  | c645be6b70 | ||
|  | 57fc5ac088 | ||
|  | 924774f52a | ||
|  | 446a7a0844 | ||
|  | 5cfeed76d0 | ||
|  | de419319d8 | ||
|  | 7a3d36899b | ||
|  | f5dbb363f4 | ||
|  | 2bbc59a212 | ||
|  | 3403d76aae | ||
|  | 58399cedb6 | ||
|  | 9bca7e9e11 | ||
|  | 3a61430e44 | ||
|  | 7d8c783a7d | ||
|  | a2e996b550 | ||
|  | cfc1c31050 | ||
|  | 45106bf6f9 | ||
|  | 6e3cfe491b | ||
|  | 12f2158afd | ||
|  | 6d78773c55 | ||
|  | 43a62d4eb6 | ||
|  | cc08dfda96 | ||
|  | 622e33588e | ||
|  | 67980b58a0 | ||
|  | 027e444955 | ||
|  | d838750389 | ||
|  | 71d8bd5266 | ||
|  | ec4ae24bbd | ||
|  | 1128149359 | ||
|  | bdfc6634ec | ||
|  | ca4d19667b | ||
|  | c71aa7baa7 | ||
|  | fd80ccd2c5 | ||
|  | 9dc0b24399 | ||
|  | 747954e6fb | ||
|  | 274f4f227e | ||
|  | 92197d8d49 | ||
|  | aee06920eb | ||
|  | 5111b17d3c | ||
|  | 2849d8f45d | ||
|  | bac60d9bd4 | ||
|  | 9c797162f4 | ||
|  | 09d184e2f8 | ||
|  | 7bca618906 | ||
|  | 67607103e9 | ||
|  | 73c9956fe4 | ||
|  | b42f2ffe33 | ||
|  | 30a3f185ef | ||
|  | 4f1b41227f | ||
|  | 83b9d13ec9 | ||
|  | cee7896c37 | 
| @@ -1,11 +1,11 @@ | ||||
| # pulls community scripts from git repo | ||||
| FROM python:3.11.4-slim AS GET_SCRIPTS_STAGE | ||||
| FROM python:3.11.6-slim AS GET_SCRIPTS_STAGE | ||||
|  | ||||
| RUN apt-get update && | ||||
|     apt-get install -y --no-install-recommends git && | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends git && \ | ||||
|     git clone https://github.com/amidaware/community-scripts.git /community-scripts | ||||
|  | ||||
| FROM python:3.11.4-slim | ||||
| FROM python:3.11.6-slim | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
| @@ -17,10 +17,10 @@ ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| EXPOSE 8000 8383 8005 | ||||
|  | ||||
| RUN apt-get update && | ||||
|     apt-get install -y build-essential | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y build-essential weasyprint | ||||
|  | ||||
| RUN groupadd -g 1000 tactical && | ||||
| RUN groupadd -g 1000 tactical && \ | ||||
|     useradd -u 1000 -g 1000 tactical | ||||
|  | ||||
| # copy community scripts | ||||
|   | ||||
| @@ -216,6 +216,7 @@ services: | ||||
|       - "443:4443" | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|  | ||||
| volumes: | ||||
|   tactical-data-dev: null | ||||
|   | ||||
| @@ -78,6 +78,17 @@ DATABASES = { | ||||
|         'PASSWORD': '${POSTGRES_PASS}', | ||||
|         'HOST': '${POSTGRES_HOST}', | ||||
|         'PORT': '${POSTGRES_PORT}', | ||||
|     }, | ||||
|     'reporting': { | ||||
|         'ENGINE': 'django.db.backends.postgresql', | ||||
|         'NAME': '${POSTGRES_DB}', | ||||
|         'USER': 'reporting_user', | ||||
|         'PASSWORD': 'read_password', | ||||
|         'HOST': '${POSTGRES_HOST}', | ||||
|         'PORT': '${POSTGRES_PORT}', | ||||
|         'OPTIONS': { | ||||
|             'options': '-c default_transaction_read_only=on' | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -95,6 +106,7 @@ EOF | ||||
|   # run migrations and init scripts | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py pre_update_tasks | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py migrate --no-input | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py generate_json_schemas | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py collectstatic --no-input | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py initial_db_setup | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py initial_mesh_setup | ||||
| @@ -120,6 +132,8 @@ if [ "$1" = 'tactical-init-dev' ]; then | ||||
|   mkdir -p /meshcentral-data | ||||
|   mkdir -p ${TACTICAL_DIR}/tmp | ||||
|   mkdir -p ${TACTICAL_DIR}/certs | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting/assets | ||||
|   mkdir -p /mongo/data/db | ||||
|   mkdir -p /redis/data | ||||
|   touch /meshcentral-data/.initialized && chown -R 1000:1000 /meshcentral-data | ||||
| @@ -127,6 +141,7 @@ if [ "$1" = 'tactical-init-dev' ]; then | ||||
|   touch ${TACTICAL_DIR}/certs/.initialized && chown -R 1000:1000 ${TACTICAL_DIR}/certs | ||||
|   touch /mongo/data/db/.initialized && chown -R 1000:1000 /mongo/data/db | ||||
|   touch /redis/data/.initialized && chown -R 1000:1000 /redis/data | ||||
|   touch ${TACTICAL_DIR}/reporting && chown -R 1000:1000 ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/log | ||||
|   touch ${TACTICAL_DIR}/api/tacticalrmm/private/log/django_debug.log | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/ci-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -14,7 +14,7 @@ jobs: | ||||
|     name: Tests | ||||
|     strategy: | ||||
|       matrix: | ||||
|         python-version: ["3.11.4"] | ||||
|         python-version: ["3.11.6"] | ||||
|  | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -57,3 +57,5 @@ daphne.sock.lock | ||||
| coverage.xml | ||||
| setup_dev.yml | ||||
| 11env/ | ||||
| query_schema.json | ||||
| gunicorn_config.py | ||||
| @@ -1,7 +1,7 @@ | ||||
| --- | ||||
| user: "tactical" | ||||
| python_ver: "3.11.4" | ||||
| go_ver: "1.20.4" | ||||
| python_ver: "3.11.6" | ||||
| go_ver: "1.20.7" | ||||
| backend_repo: "https://github.com/amidaware/tacticalrmm.git" | ||||
| frontend_repo: "https://github.com/amidaware/tacticalrmm-web.git" | ||||
| scripts_repo: "https://github.com/amidaware/community-scripts.git" | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| # Generated by Django 4.2.5 on 2023-10-08 22:24 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0034_role_can_send_wol"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_manage_reports", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_view_reports", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -186,6 +186,10 @@ class Role(BaseAuditModel): | ||||
|     can_list_api_keys = models.BooleanField(default=False) | ||||
|     can_manage_api_keys = models.BooleanField(default=False) | ||||
|  | ||||
|     # reporting | ||||
|     can_view_reports = models.BooleanField(default=False) | ||||
|     can_manage_reports = models.BooleanField(default=False) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|   | ||||
| @@ -556,6 +556,7 @@ class Agent(BaseAuditModel): | ||||
|             run_as_user = True | ||||
|  | ||||
|         parsed_args = script.parse_script_args(self, script.shell, args) | ||||
|         parsed_env_vars = script.parse_script_env_vars(self, script.shell, env_vars) | ||||
|  | ||||
|         data = { | ||||
|             "func": "runscriptfull" if full else "runscript", | ||||
| @@ -566,7 +567,7 @@ class Agent(BaseAuditModel): | ||||
|                 "shell": script.shell, | ||||
|             }, | ||||
|             "run_as_user": run_as_user, | ||||
|             "env_vars": env_vars, | ||||
|             "env_vars": parsed_env_vars, | ||||
|         } | ||||
|  | ||||
|         if history_pk != 0: | ||||
|   | ||||
| @@ -570,6 +570,13 @@ def install_agent(request): | ||||
|     from agents.utils import get_agent_url | ||||
|     from core.utils import token_is_valid | ||||
|  | ||||
|     insecure = getattr(settings, "TRMM_INSECURE", False) | ||||
|  | ||||
|     if insecure and request.data["installMethod"] in {"exe", "powershell"}: | ||||
|         return notify_error( | ||||
|             "Not available in insecure mode. Please use the 'Manual' method." | ||||
|         ) | ||||
|  | ||||
|     # TODO rework this ghetto validation hack | ||||
|     # https://github.com/amidaware/tacticalrmm/issues/1461 | ||||
|     try: | ||||
| @@ -672,6 +679,9 @@ def install_agent(request): | ||||
|             if int(request.data["power"]): | ||||
|                 cmd.append("--power") | ||||
|  | ||||
|             if insecure: | ||||
|                 cmd.append("--insecure") | ||||
|  | ||||
|             resp["cmd"] = " ".join(str(i) for i in cmd) | ||||
|         else: | ||||
|             install_flags.insert(0, f"sudo ./{inno}") | ||||
| @@ -680,6 +690,8 @@ def install_agent(request): | ||||
|             resp["cmd"] = ( | ||||
|                 dl + f" && chmod +x {inno} && " + " ".join(str(i) for i in cmd) | ||||
|             ) | ||||
|             if insecure: | ||||
|                 resp["cmd"] += " --insecure" | ||||
|  | ||||
|         resp["url"] = download_url | ||||
|  | ||||
|   | ||||
| @@ -627,8 +627,7 @@ class Alert(models.Model): | ||||
|         pattern = re.compile(".*\\{\\{alert\\.(.*)\\}\\}.*") | ||||
|  | ||||
|         for arg in args: | ||||
|             match = pattern.match(arg) | ||||
|             if match: | ||||
|             if match := pattern.match(arg): | ||||
|                 name = match.group(1) | ||||
|  | ||||
|                 # check if attr exists and isn't a function | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from datetime import timedelta | ||||
| from itertools import cycle | ||||
| from unittest.mock import patch | ||||
|  | ||||
| @@ -28,6 +28,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|         self.authenticate() | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     """ | ||||
|     def test_get_alerts(self): | ||||
|         url = "/alerts/" | ||||
|  | ||||
| @@ -39,14 +40,14 @@ class TestAlertsViews(TacticalTestCase): | ||||
|         alerts = baker.make( | ||||
|             "alerts.Alert", | ||||
|             agent=agent, | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             severity=AlertSeverity.WARNING, | ||||
|             _quantity=3, | ||||
|         ) | ||||
|         baker.make( | ||||
|             "alerts.Alert", | ||||
|             assigned_check=check, | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             severity=AlertSeverity.ERROR, | ||||
|             _quantity=7, | ||||
|         ) | ||||
| @@ -55,7 +56,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             assigned_task=task, | ||||
|             snoozed=True, | ||||
|             snooze_until=djangotime.now(), | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=2, | ||||
|         ) | ||||
|         baker.make( | ||||
| @@ -63,7 +64,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             agent=agent, | ||||
|             resolved=True, | ||||
|             resolved_on=djangotime.now(), | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=9, | ||||
|         ) | ||||
|  | ||||
| @@ -120,13 +121,14 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             self.assertEqual(len(resp.data), req["count"]) | ||||
|  | ||||
|         self.check_not_authenticated("patch", url) | ||||
|     """ | ||||
|  | ||||
|     def test_add_alert(self): | ||||
|         url = "/alerts/" | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         data = { | ||||
|             "alert_time": datetime.now(), | ||||
|             "alert_time": djangotime.now(), | ||||
|             "agent": agent.id, | ||||
|             "severity": "warning", | ||||
|             "alert_type": "availability", | ||||
| @@ -363,7 +365,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         not_snoozed = baker.make( | ||||
|             "alerts.Alert", | ||||
|             snoozed=True, | ||||
|             snooze_until=seq(datetime.now(), timedelta(days=15)), | ||||
|             snooze_until=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=5, | ||||
|         ) | ||||
|  | ||||
| @@ -371,7 +373,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         snoozed = baker.make( | ||||
|             "alerts.Alert", | ||||
|             snoozed=True, | ||||
|             snooze_until=seq(datetime.now(), timedelta(days=-15)), | ||||
|             snooze_until=seq(djangotime.now(), timedelta(days=-15)), | ||||
|             _quantity=5, | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -252,7 +252,11 @@ class TaskGOGetSerializer(serializers.ModelSerializer): | ||||
|                         "shell": script.shell, | ||||
|                         "timeout": action["timeout"], | ||||
|                         "run_as_user": script.run_as_user, | ||||
|                         "env_vars": env_vars, | ||||
|                         "env_vars": Script.parse_script_env_vars( | ||||
|                             agent=agent, | ||||
|                             shell=script.shell, | ||||
|                             env_vars=env_vars, | ||||
|                         ), | ||||
|                     } | ||||
|                 ) | ||||
|         if actions_to_remove: | ||||
|   | ||||
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										37
									
								
								api/tacticalrmm/beta/v1/agent/filter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								api/tacticalrmm/beta/v1/agent/filter.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import django_filters | ||||
| from agents.models import Agent | ||||
|  | ||||
|  | ||||
| class AgentFilter(django_filters.FilterSet): | ||||
|     last_seen_range = django_filters.DateTimeFromToRangeFilter(field_name="last_seen") | ||||
|     total_ram_range = django_filters.NumericRangeFilter(field_name="total_ram") | ||||
|     patches_last_installed_range = django_filters.DateTimeFromToRangeFilter( | ||||
|         field_name="patches_last_installed" | ||||
|     ) | ||||
|  | ||||
|     client_id = django_filters.NumberFilter(method="client_id_filter") | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "operating_system", | ||||
|             "plat", | ||||
|             "monitoring_type", | ||||
|             "needs_reboot", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "alert_template", | ||||
|             "site", | ||||
|             "policy", | ||||
|             "last_seen_range", | ||||
|             "total_ram_range", | ||||
|             "patches_last_installed_range", | ||||
|         ] | ||||
|  | ||||
|     def client_id_filter(self, queryset, name, value): | ||||
|         if value: | ||||
|             return queryset.filter(site__client__id=value) | ||||
|         return queryset | ||||
							
								
								
									
										40
									
								
								api/tacticalrmm/beta/v1/agent/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								api/tacticalrmm/beta/v1/agent/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.filters import SearchFilter, OrderingFilter | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from agents.models import Agent | ||||
| from agents.permissions import AgentPerms | ||||
| from beta.v1.agent.filter import AgentFilter | ||||
| from beta.v1.pagination import StandardResultsSetPagination | ||||
| from ..serializers import DetailAgentSerializer, ListAgentSerializer | ||||
|  | ||||
|  | ||||
| class AgentViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, AgentPerms] | ||||
|     queryset = Agent.objects.all() | ||||
|     pagination_class = StandardResultsSetPagination | ||||
|     http_method_names = ["get", "put"] | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] | ||||
|     filterset_class = AgentFilter | ||||
|     search_fields = ["hostname", "services"] | ||||
|     ordering_fields = ["id"] | ||||
|     ordering = ["id"] | ||||
|  | ||||
|     def check_permissions(self, request: Request) -> None: | ||||
|         if "agent_id" in request.query_params: | ||||
|             self.kwargs["agent_id"] = request.query_params["agent_id"] | ||||
|         super().check_permissions(request) | ||||
|  | ||||
|     def get_permissions(self): | ||||
|         if self.request.method == "POST": | ||||
|             self.permission_classes = [IsAuthenticated] | ||||
|         return super().get_permissions() | ||||
|  | ||||
|     def get_serializer_class(self) -> type[BaseSerializer]: | ||||
|         if self.kwargs: | ||||
|             if self.kwargs["pk"]: | ||||
|                 return DetailAgentSerializer | ||||
|         return ListAgentSerializer | ||||
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/client/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/client/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										13
									
								
								api/tacticalrmm/beta/v1/client/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								api/tacticalrmm/beta/v1/client/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
|  | ||||
| from clients.models import Client | ||||
| from clients.permissions import ClientsPerms | ||||
| from ..serializers import ClientSerializer | ||||
|  | ||||
|  | ||||
| class ClientViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, ClientsPerms] | ||||
|     queryset = Client.objects.all() | ||||
|     serializer_class = ClientSerializer | ||||
|     http_method_names = ["get", "put"] | ||||
							
								
								
									
										7
									
								
								api/tacticalrmm/beta/v1/pagination.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								api/tacticalrmm/beta/v1/pagination.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from rest_framework.pagination import PageNumberPagination | ||||
|  | ||||
|  | ||||
| class StandardResultsSetPagination(PageNumberPagination): | ||||
|     page_size = 100 | ||||
|     page_size_query_param = "page_size" | ||||
|     max_page_size = 1000 | ||||
							
								
								
									
										73
									
								
								api/tacticalrmm/beta/v1/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								api/tacticalrmm/beta/v1/serializers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from agents.models import Agent | ||||
| from clients.models import Client, Site | ||||
|  | ||||
|  | ||||
| class ListAgentSerializer(serializers.ModelSerializer[Agent]): | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class DetailAgentSerializer(serializers.ModelSerializer[Agent]): | ||||
|     status = serializers.ReadOnlyField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = ( | ||||
|             "version", | ||||
|             "operating_system", | ||||
|             "plat", | ||||
|             "goarch", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "last_seen", | ||||
|             "services", | ||||
|             "public_ip", | ||||
|             "total_ram", | ||||
|             "disks", | ||||
|             "boot_time", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "monitoring_type", | ||||
|             "description", | ||||
|             "mesh_node_id", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_dashboard_alert", | ||||
|             "offline_time", | ||||
|             "overdue_time", | ||||
|             "check_interval", | ||||
|             "needs_reboot", | ||||
|             "choco_installed", | ||||
|             "wmi_detail", | ||||
|             "patches_last_installed", | ||||
|             "time_zone", | ||||
|             "maintenance_mode", | ||||
|             "block_policy_inheritance", | ||||
|             "alert_template", | ||||
|             "site", | ||||
|             "policy", | ||||
|             "status", | ||||
|             "checks", | ||||
|             "pending_actions_count", | ||||
|             "cpu_model", | ||||
|             "graphics", | ||||
|             "local_ips", | ||||
|             "make_model", | ||||
|             "physical_disks", | ||||
|             "serial_number", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ClientSerializer(serializers.ModelSerializer[Client]): | ||||
|     class Meta: | ||||
|         model = Client | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class SiteSerializer(serializers.ModelSerializer[Site]): | ||||
|     class Meta: | ||||
|         model = Site | ||||
|         fields = "__all__" | ||||
							
								
								
									
										21
									
								
								api/tacticalrmm/beta/v1/site/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								api/tacticalrmm/beta/v1/site/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.filters import SearchFilter, OrderingFilter | ||||
|  | ||||
| from clients.models import Site | ||||
| from clients.permissions import SitesPerms | ||||
| from beta.v1.pagination import StandardResultsSetPagination | ||||
| from ..serializers import SiteSerializer | ||||
|  | ||||
|  | ||||
| class SiteViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, SitesPerms] | ||||
|     queryset = Site.objects.all() | ||||
|     serializer_class = SiteSerializer | ||||
|     pagination_class = StandardResultsSetPagination | ||||
|     http_method_names = ["get", "put"] | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] | ||||
|     search_fields = ["name"] | ||||
|     ordering_fields = ["id"] | ||||
|     ordering = ["id"] | ||||
							
								
								
									
										12
									
								
								api/tacticalrmm/beta/v1/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								api/tacticalrmm/beta/v1/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| from rest_framework import routers | ||||
| from .agent import views as agent | ||||
| from .client import views as client | ||||
| from .site import views as site | ||||
|  | ||||
| router = routers.DefaultRouter() | ||||
|  | ||||
| router.register("agent", agent.AgentViewSet, basename="agent") | ||||
| router.register("client", client.ClientViewSet, basename="client") | ||||
| router.register("site", site.SiteViewSet, basename="site") | ||||
|  | ||||
| urlpatterns = router.urls | ||||
| @@ -172,8 +172,14 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer): | ||||
|         if obj.check_type != CheckType.SCRIPT: | ||||
|             return [] | ||||
|  | ||||
|         # check's env_vars override the script's env vars | ||||
|         return obj.env_vars or obj.script.env_vars | ||||
|         agent = self.context["agent"] if "agent" in self.context.keys() else obj.agent | ||||
|  | ||||
|         return Script.parse_script_env_vars( | ||||
|             agent=agent, | ||||
|             shell=obj.script.shell, | ||||
|             env_vars=obj.env_vars | ||||
|             or obj.script.env_vars,  # check's env_vars override the script's env vars | ||||
|         ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Check | ||||
|   | ||||
| @@ -172,6 +172,31 @@ class TestCheckViews(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_reset_all_checks_status(self): | ||||
|         # setup data | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         check = baker.make_recipe("checks.diskspace_check", agent=agent) | ||||
|         baker.make("checks.CheckResult", assigned_check=check, agent=agent) | ||||
|         baker.make( | ||||
|             "checks.CheckHistory", | ||||
|             check_id=check.id, | ||||
|             agent_id=agent.agent_id, | ||||
|             _quantity=30, | ||||
|         ) | ||||
|         baker.make( | ||||
|             "checks.CheckHistory", | ||||
|             check_id=check.id, | ||||
|             agent_id=agent.agent_id, | ||||
|             _quantity=30, | ||||
|         ) | ||||
|  | ||||
|         url = f"{base_url}/{agent.agent_id}/resetall/" | ||||
|  | ||||
|         resp = self.client.post(url) | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_add_memory_check(self): | ||||
|         url = f"{base_url}/" | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|   | ||||
| @@ -6,6 +6,7 @@ urlpatterns = [ | ||||
|     path("", views.GetAddChecks.as_view()), | ||||
|     path("<int:pk>/", views.GetUpdateDeleteCheck.as_view()), | ||||
|     path("<int:pk>/reset/", views.ResetCheck.as_view()), | ||||
|     path("<agent:agent_id>/resetall/", views.ResetAllChecksStatus.as_view()), | ||||
|     path("<agent:agent_id>/run/", views.run_checks), | ||||
|     path("<int:pk>/history/", views.GetCheckHistory.as_view()), | ||||
|     path("<str:target>/<int:pk>/csbulkrun/", views.bulk_run_checks), | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import asyncio | ||||
| from datetime import datetime as dt | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.db.models import Prefetch, Q | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| from rest_framework.decorators import api_view, permission_classes | ||||
| @@ -13,7 +13,7 @@ from rest_framework.views import APIView | ||||
| from agents.models import Agent | ||||
| from alerts.models import Alert | ||||
| from automation.models import Policy | ||||
| from tacticalrmm.constants import CheckStatus, CheckType | ||||
| from tacticalrmm.constants import AGENT_DEFER, CheckStatus, CheckType | ||||
| from tacticalrmm.exceptions import NatsDown | ||||
| from tacticalrmm.helpers import notify_error | ||||
| from tacticalrmm.nats_utils import abulk_nats_command | ||||
| @@ -122,15 +122,54 @@ class ResetCheck(APIView): | ||||
|         result.save() | ||||
|  | ||||
|         # resolve any alerts that are open | ||||
|         alert = Alert.create_or_return_check_alert( | ||||
|         if alert := Alert.create_or_return_check_alert( | ||||
|             result.assigned_check, agent=result.agent, skip_create=True | ||||
|         ) | ||||
|         if alert: | ||||
|         ): | ||||
|             alert.resolve() | ||||
|  | ||||
|         return Response("The check status was reset") | ||||
|  | ||||
|  | ||||
| class ResetAllChecksStatus(APIView): | ||||
|     permission_classes = [IsAuthenticated, ChecksPerms] | ||||
|  | ||||
|     def post(self, request, agent_id): | ||||
|         agent = get_object_or_404( | ||||
|             Agent.objects.defer(*AGENT_DEFER) | ||||
|             .select_related( | ||||
|                 "policy", | ||||
|                 "policy__alert_template", | ||||
|                 "alert_template", | ||||
|             ) | ||||
|             .prefetch_related( | ||||
|                 Prefetch( | ||||
|                     "checkresults", | ||||
|                     queryset=CheckResult.objects.select_related("assigned_check"), | ||||
|                 ), | ||||
|                 "agentchecks", | ||||
|             ), | ||||
|             agent_id=agent_id, | ||||
|         ) | ||||
|  | ||||
|         if not _has_perm_on_agent(request.user, agent.agent_id): | ||||
|             raise PermissionDenied() | ||||
|  | ||||
|         for check in agent.get_checks_with_policies(): | ||||
|             try: | ||||
|                 result = check.check_result | ||||
|                 result.status = CheckStatus.PASSING | ||||
|                 result.save() | ||||
|                 if alert := Alert.create_or_return_check_alert( | ||||
|                     result.assigned_check, agent=agent, skip_create=True | ||||
|                 ): | ||||
|                     alert.resolve() | ||||
|             except: | ||||
|                 # check hasn't run yet, no check result entry | ||||
|                 continue | ||||
|  | ||||
|         return Response("All checks status were reset") | ||||
|  | ||||
|  | ||||
| class GetCheckHistory(APIView): | ||||
|     permission_classes = [IsAuthenticated, ChecksPerms] | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import re | ||||
| import uuid | ||||
| from contextlib import suppress | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db.models import Count, Exists, OuterRef, Prefetch, prefetch_related_objects | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| @@ -288,6 +289,9 @@ class AgentDeployment(APIView): | ||||
|         return Response(DeploymentSerializer(deps, many=True).data) | ||||
|  | ||||
|     def post(self, request): | ||||
|         if getattr(settings, "TRMM_INSECURE", False): | ||||
|             return notify_error("Not available in insecure mode") | ||||
|  | ||||
|         from accounts.models import User | ||||
|  | ||||
|         site = get_object_or_404(Site, pk=request.data["site"]) | ||||
| @@ -343,6 +347,9 @@ class GenerateAgent(APIView): | ||||
|     permission_classes = (AllowAny,) | ||||
|  | ||||
|     def get(self, request, uid): | ||||
|         if getattr(settings, "TRMM_INSECURE", False): | ||||
|             return notify_error("Not available in insecure mode") | ||||
|  | ||||
|         from tacticalrmm.utils import generate_winagent_exe | ||||
|  | ||||
|         try: | ||||
|   | ||||
| @@ -12,6 +12,19 @@ if [ "${HAS_SYSTEMD}" != 'systemd' ]; then | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| if [[ $DISPLAY ]]; then | ||||
|     echo "ERROR: Display detected. Installer only supports running headless, i.e from ssh." | ||||
|     echo "If you cannot ssh in then please run 'sudo systemctl isolate multi-user.target' to switch to a non-graphical user session and run the installer again." | ||||
|     echo "If you are already running headless, then you are probably running with X forwarding which is setting DISPLAY, if so then simply run" | ||||
|     echo "unset DISPLAY" | ||||
|     echo "to unset the variable and then try running the installer again" | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| DEBUG=0 | ||||
| INSECURE=0 | ||||
| NOMESH=0 | ||||
|  | ||||
| agentDL='agentDLChange' | ||||
| meshDL='meshDLChange' | ||||
|  | ||||
| @@ -124,6 +137,19 @@ if [ $# -ne 0 ] && [ $1 == 'uninstall' ]; then | ||||
|     exit 0 | ||||
| fi | ||||
|  | ||||
| while [[ "$#" -gt 0 ]]; do | ||||
|     case $1 in | ||||
|     --debug) DEBUG=1 ;; | ||||
|     --insecure) INSECURE=1 ;; | ||||
|     --nomesh) NOMESH=1 ;; | ||||
|     *) | ||||
|         echo "ERROR: Unknown parameter: $1" | ||||
|         exit 1 | ||||
|         ;; | ||||
|     esac | ||||
|     shift | ||||
| done | ||||
|  | ||||
| RemoveOldAgent | ||||
|  | ||||
| echo "Downloading tactical agent..." | ||||
| @@ -136,7 +162,7 @@ chmod +x ${agentBin} | ||||
|  | ||||
| MESH_NODE_ID="" | ||||
|  | ||||
| if [ $# -ne 0 ] && [ $1 == '--nomesh' ]; then | ||||
| if [[ $NOMESH -eq 1 ]]; then | ||||
|     echo "Skipping mesh install" | ||||
| else | ||||
|     if [ -f "${meshSystemBin}" ]; then | ||||
| @@ -154,18 +180,22 @@ if [ ! -d "${agentBinPath}" ]; then | ||||
|     mkdir -p ${agentBinPath} | ||||
| fi | ||||
|  | ||||
| if [ $# -ne 0 ] && [ $1 == '--debug' ]; then | ||||
|     INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token} -log debug" | ||||
| else | ||||
|     INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token}" | ||||
| fi | ||||
| INSTALL_CMD="${agentBin} -m install -api ${apiURL} -client-id ${clientID} -site-id ${siteID} -agent-type ${agentType} -auth ${token}" | ||||
|  | ||||
| if [ "${MESH_NODE_ID}" != '' ]; then | ||||
|     INSTALL_CMD+=" -meshnodeid ${MESH_NODE_ID}" | ||||
|     INSTALL_CMD+=" --meshnodeid ${MESH_NODE_ID}" | ||||
| fi | ||||
|  | ||||
| if [[ $DEBUG -eq 1 ]]; then | ||||
|     INSTALL_CMD+=" --log debug" | ||||
| fi | ||||
|  | ||||
| if [[ $INSECURE -eq 1 ]]; then | ||||
|     INSTALL_CMD+=" --insecure" | ||||
| fi | ||||
|  | ||||
| if [ "${proxy}" != '' ]; then | ||||
|     INSTALL_CMD+=" -proxy ${proxy}" | ||||
|     INSTALL_CMD+=" --proxy ${proxy}" | ||||
| fi | ||||
|  | ||||
| eval ${INSTALL_CMD} | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| import multiprocessing | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Generate conf for gunicorn" | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         self.stdout.write("Creating gunicorn conf...") | ||||
|  | ||||
|         cpu_count = multiprocessing.cpu_count() | ||||
|  | ||||
|         # worker processes | ||||
|         workers = getattr(settings, "TRMM_GUNICORN_WORKERS", cpu_count * 2 + 1) | ||||
|         threads = getattr(settings, "TRMM_GUNICORN_THREADS", cpu_count * 2) | ||||
|         worker_class = getattr(settings, "TRMM_GUNICORN_WORKER_CLASS", "gthread") | ||||
|         max_requests = getattr(settings, "TRMM_GUNICORN_MAX_REQUESTS", 50) | ||||
|         max_requests_jitter = getattr(settings, "TRMM_GUNICORN_MAX_REQUESTS_JITTER", 8) | ||||
|         worker_connections = getattr(settings, "TRMM_GUNICORN_WORKER_CONNS", 1000) | ||||
|         timeout = getattr(settings, "TRMM_GUNICORN_TIMEOUT", 300) | ||||
|         graceful_timeout = getattr(settings, "TRMM_GUNICORN_GRACEFUL_TIMEOUT", 300) | ||||
|  | ||||
|         # socket | ||||
|         backlog = getattr(settings, "TRMM_GUNICORN_BACKLOG", 2048) | ||||
|         if getattr(settings, "DOCKER_BUILD", False): | ||||
|             bind = "0.0.0.0:8080" | ||||
|         else: | ||||
|             bind = f"unix:{settings.BASE_DIR / 'tacticalrmm.sock'}" | ||||
|  | ||||
|         # security | ||||
|         limit_request_line = getattr(settings, "TRMM_GUNICORN_LIMIT_REQUEST_LINE", 0) | ||||
|         limit_request_fields = getattr( | ||||
|             settings, "TRMM_GUNICORN_LIMIT_REQUEST_FIELDS", 500 | ||||
|         ) | ||||
|         limit_request_field_size = getattr( | ||||
|             settings, "TRMM_GUNICORN_LIMIT_REQUEST_FIELD_SIZE", 0 | ||||
|         ) | ||||
|  | ||||
|         # server | ||||
|         preload_app = getattr(settings, "TRMM_GUNICORN_PRELOAD_APP", True) | ||||
|  | ||||
|         # log | ||||
|         loglevel = getattr(settings, "TRMM_GUNICORN_LOGLEVEL", "info") | ||||
|  | ||||
|         cfg = [ | ||||
|             f"bind = '{bind}'", | ||||
|             f"workers = {workers}", | ||||
|             f"threads = {threads}", | ||||
|             f"worker_class = '{worker_class}'", | ||||
|             f"backlog = {backlog}", | ||||
|             f"worker_connections = {worker_connections}", | ||||
|             f"timeout = {timeout}", | ||||
|             f"graceful_timeout = {graceful_timeout}", | ||||
|             f"limit_request_line = {limit_request_line}", | ||||
|             f"limit_request_fields = {limit_request_fields}", | ||||
|             f"limit_request_field_size = {limit_request_field_size}", | ||||
|             f"max_requests = {max_requests}", | ||||
|             f"max_requests_jitter = {max_requests_jitter}", | ||||
|             f"loglevel = '{loglevel}'", | ||||
|             f"chdir = '{settings.BASE_DIR}'", | ||||
|             f"preload_app = {preload_app}", | ||||
|         ] | ||||
|  | ||||
|         with open(settings.BASE_DIR / "gunicorn_config.py", "w") as fp: | ||||
|             for line in cfg: | ||||
|                 fp.write(line + "\n") | ||||
|  | ||||
|         self.stdout.write("Created gunicorn conf") | ||||
| @@ -4,7 +4,7 @@ import os | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from tacticalrmm.helpers import get_nats_ports | ||||
| from tacticalrmm.helpers import get_nats_internal_protocol, get_nats_ports | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
| @@ -21,9 +21,10 @@ class Command(BaseCommand): | ||||
|             ssl = "disable" | ||||
|  | ||||
|         nats_std_port, _ = get_nats_ports() | ||||
|         proto = get_nats_internal_protocol() | ||||
|         config = { | ||||
|             "key": settings.SECRET_KEY, | ||||
|             "natsurl": f"tls://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}", | ||||
|             "natsurl": f"{proto}://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}", | ||||
|             "user": db["USER"], | ||||
|             "pass": db["PASSWORD"], | ||||
|             "host": db["HOST"], | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import configparser | ||||
| import math | ||||
| import multiprocessing | ||||
| import os | ||||
| from pathlib import Path | ||||
|  | ||||
| import psutil | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| @@ -12,6 +15,27 @@ class Command(BaseCommand): | ||||
|     def handle(self, *args, **kwargs): | ||||
|         self.stdout.write("Creating uwsgi conf...") | ||||
|  | ||||
|         try: | ||||
|             cpu_count = multiprocessing.cpu_count() | ||||
|             worker_initial = 3 if cpu_count == 1 else 4 | ||||
|         except: | ||||
|             worker_initial = 4 | ||||
|  | ||||
|         try: | ||||
|             ram = math.ceil(psutil.virtual_memory().total / (1024**3)) | ||||
|             if ram <= 2: | ||||
|                 max_requests = 30 | ||||
|                 max_workers = 10 | ||||
|             elif ram <= 4: | ||||
|                 max_requests = 75 | ||||
|                 max_workers = 20 | ||||
|             else: | ||||
|                 max_requests = 100 | ||||
|                 max_workers = 40 | ||||
|         except: | ||||
|             max_requests = 50 | ||||
|             max_workers = 10 | ||||
|  | ||||
|         config = configparser.ConfigParser() | ||||
|  | ||||
|         if getattr(settings, "DOCKER_BUILD", False): | ||||
| @@ -35,15 +59,18 @@ class Command(BaseCommand): | ||||
|             "buffer-size": str(getattr(settings, "UWSGI_BUFFER_SIZE", 65535)), | ||||
|             "vacuum": str(getattr(settings, "UWSGI_VACUUM", True)).lower(), | ||||
|             "die-on-term": str(getattr(settings, "UWSGI_DIE_ON_TERM", True)).lower(), | ||||
|             "max-requests": str(getattr(settings, "UWSGI_MAX_REQUESTS", 500)), | ||||
|             "max-requests": str(getattr(settings, "UWSGI_MAX_REQUESTS", max_requests)), | ||||
|             "disable-logging": str( | ||||
|                 getattr(settings, "UWSGI_DISABLE_LOGGING", True) | ||||
|             ).lower(), | ||||
|             "worker-reload-mercy": str(getattr(settings, "UWSGI_RELOAD_MERCY", 30)), | ||||
|             "cheaper-algo": "busyness", | ||||
|             "cheaper": str(getattr(settings, "UWSGI_CHEAPER", 4)), | ||||
|             "cheaper-initial": str(getattr(settings, "UWSGI_CHEAPER_INITIAL", 4)), | ||||
|             "workers": str(getattr(settings, "UWSGI_MAX_WORKERS", 40)), | ||||
|             "cheaper-step": str(getattr(settings, "UWSGI_CHEAPER_STEP", 2)), | ||||
|             "cheaper-initial": str( | ||||
|                 getattr(settings, "UWSGI_CHEAPER_INITIAL", worker_initial) | ||||
|             ), | ||||
|             "workers": str(getattr(settings, "UWSGI_MAX_WORKERS", max_workers)), | ||||
|             "cheaper-step": str(getattr(settings, "UWSGI_CHEAPER_STEP", 1)), | ||||
|             "cheaper-overload": str(getattr(settings, "UWSGI_CHEAPER_OVERLOAD", 3)), | ||||
|             "cheaper-busyness-min": str(getattr(settings, "UWSGI_BUSYNESS_MIN", 5)), | ||||
|             "cheaper-busyness-max": str(getattr(settings, "UWSGI_BUSYNESS_MAX", 10)), | ||||
|   | ||||
| @@ -4,6 +4,7 @@ from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from tacticalrmm.helpers import get_webdomain | ||||
| from tacticalrmm.utils import get_certs | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
| @@ -59,3 +60,9 @@ class Command(BaseCommand): | ||||
|                     obj = core.mesh_token | ||||
|  | ||||
|                 self.stdout.write(obj) | ||||
|             case "certfile" | "keyfile": | ||||
|                 crt, key = get_certs() | ||||
|                 if kwargs["name"] == "certfile": | ||||
|                     self.stdout.write(crt) | ||||
|                 elif kwargs["name"] == "keyfile": | ||||
|                     self.stdout.write(key) | ||||
|   | ||||
| @@ -11,4 +11,7 @@ class Command(BaseCommand): | ||||
|         self.stdout.write(self.style.WARNING("Cleaning the cache")) | ||||
|         clear_entire_cache() | ||||
|         self.stdout.write(self.style.SUCCESS("Cache was cleared!")) | ||||
|         call_command("fix_dupe_agent_customfields") | ||||
|         try: | ||||
|             call_command("fix_dupe_agent_customfields") | ||||
|         except: | ||||
|             pass | ||||
|   | ||||
| @@ -502,3 +502,27 @@ class TestCoreUtils(TacticalTestCase): | ||||
|             r, | ||||
|             "http://tactical-meshcentral:4443/meshagents?id=4&meshid=abc123&installflags=0", | ||||
|         ) | ||||
|  | ||||
|     @override_settings(TRMM_INSECURE=True) | ||||
|     def test_get_meshagent_url_insecure(self): | ||||
|         r = get_meshagent_url( | ||||
|             ident=MeshAgentIdent.DARWIN_UNIVERSAL, | ||||
|             plat="darwin", | ||||
|             mesh_site="https://mesh.example.com", | ||||
|             mesh_device_id="abc123", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             r, | ||||
|             "http://mesh.example.com:4430/meshagents?id=abc123&installflags=2&meshinstall=10005", | ||||
|         ) | ||||
|  | ||||
|         r = get_meshagent_url( | ||||
|             ident=MeshAgentIdent.WIN64, | ||||
|             plat="windows", | ||||
|             mesh_site="https://mesh.example.com", | ||||
|             mesh_device_id="abc123", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             r, | ||||
|             "http://mesh.example.com:4430/meshagents?id=4&meshid=abc123&installflags=0", | ||||
|         ) | ||||
|   | ||||
| @@ -88,8 +88,12 @@ def get_mesh_ws_url() -> str: | ||||
|     if settings.DOCKER_BUILD: | ||||
|         uri = f"{settings.MESH_WS_URL}/control.ashx?auth={token}" | ||||
|     else: | ||||
|         site = core.mesh_site.replace("https", "wss") | ||||
|         uri = f"{site}/control.ashx?auth={token}" | ||||
|         if getattr(settings, "TRMM_INSECURE", False): | ||||
|             site = core.mesh_site.replace("https", "ws") | ||||
|             uri = f"{site}:4430/control.ashx?auth={token}" | ||||
|         else: | ||||
|             site = core.mesh_site.replace("https", "wss") | ||||
|             uri = f"{site}/control.ashx?auth={token}" | ||||
|  | ||||
|     return uri | ||||
|  | ||||
| @@ -181,6 +185,8 @@ def get_meshagent_url( | ||||
| ) -> str: | ||||
|     if settings.DOCKER_BUILD: | ||||
|         base = settings.MESH_WS_URL.replace("ws://", "http://") | ||||
|     elif getattr(settings, "TRMM_INSECURE", False): | ||||
|         base = mesh_site.replace("https", "http") + ":4430" | ||||
|     else: | ||||
|         base = mesh_site | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import json | ||||
| import re | ||||
| from contextlib import suppress | ||||
| from pathlib import Path | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| @@ -11,6 +12,7 @@ from django.http import JsonResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| from django.views.decorators.csrf import csrf_exempt | ||||
| from redis import from_url | ||||
| from rest_framework.decorators import api_view, permission_classes | ||||
| from rest_framework.exceptions import PermissionDenied | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| @@ -355,7 +357,7 @@ class RunURLAction(APIView): | ||||
|  | ||||
|         from agents.models import Agent | ||||
|         from clients.models import Client, Site | ||||
|         from tacticalrmm.utils import replace_db_values | ||||
|         from tacticalrmm.utils import get_db_value | ||||
|  | ||||
|         if "agent_id" in request.data.keys(): | ||||
|             if not _has_perm_on_agent(request.user, request.data["agent_id"]): | ||||
| @@ -382,7 +384,7 @@ class RunURLAction(APIView): | ||||
|         url_pattern = action.pattern | ||||
|  | ||||
|         for string in re.findall(pattern, action.pattern): | ||||
|             value = replace_db_values(string=string, instance=instance, quotes=False) | ||||
|             value = get_db_value(string=string, instance=instance) | ||||
|  | ||||
|             url_pattern = re.sub("\\{\\{" + string + "\\}\\}", str(value), url_pattern) | ||||
|  | ||||
| @@ -430,6 +432,13 @@ def status(request): | ||||
|     now = djangotime.now() | ||||
|     delta = expires - now | ||||
|  | ||||
|     redis_url = f"redis://{settings.REDIS_HOST}" | ||||
|     redis_ping = False | ||||
|     with suppress(Exception): | ||||
|         with from_url(redis_url) as conn: | ||||
|             conn.ping() | ||||
|             redis_ping = True | ||||
|  | ||||
|     ret = { | ||||
|         "version": settings.TRMM_VERSION, | ||||
|         "latest_agent_version": settings.LATEST_AGENT_VER, | ||||
| @@ -440,6 +449,7 @@ def status(request): | ||||
|         "mem_usage_percent": mem_usage, | ||||
|         "days_until_cert_expires": delta.days, | ||||
|         "cert_expired": delta.days < 0, | ||||
|         "redis_ping": redis_ping, | ||||
|     } | ||||
|  | ||||
|     if settings.DOCKER_BUILD: | ||||
|   | ||||
							
								
								
									
										30
									
								
								api/tacticalrmm/ee/LICENSE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								api/tacticalrmm/ee/LICENSE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| ## Tactical RMM Enterprise Edition (EE) License Agreement (the "Agreement") | ||||
|  | ||||
| Copyright (c) 2023 Amidaware Inc. All rights reserved. | ||||
|  | ||||
| This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software"). | ||||
|  | ||||
| The EE features of the Software, including but not limited to Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. | ||||
|  | ||||
| ## License Grant | ||||
|  | ||||
| Subject to the terms of this Agreement and upon the Licensee's possession of a valid and authorized sponsorship token issued by Amidaware, Amidaware hereby grants to the Licensee a limited, non-exclusive, non-transferable, and revocable right and license to use the Software. | ||||
|  | ||||
| ## Restrictions | ||||
|  | ||||
| The Licensee acknowledges and agrees that, notwithstanding any other provision of this Agreement: | ||||
|  | ||||
| a) The Licensee shall not copy, merge, publish, distribute, sublicense, or sell the Software or any derivative thereof; | ||||
|  | ||||
| b) The Licensee shall not, in any manner, circumvent, bypass, or tamper with the license key functionality embedded in the Software, nor shall the Licensee remove, alter, or obscure any features that are protected by such license keys. For the avoidance of doubt, the Software's code contains protective measures that enable specific EE features, and the Licensee is strictly prohibited from modifying or removing any licensing code with the intent to enable or unlock these EE features without proper authorization. | ||||
|  | ||||
| ## Termination | ||||
|  | ||||
| 1. **Breach**: If the Licensee breaches any term of this Agreement, Amidaware reserves the right to terminate this Agreement and the Licensee's rights granted hereunder immediately, without prior notice. | ||||
| 2. **Legal Action**: Any breach of this Agreement may result in Amidaware pursuing legal action against the Licensee. The Licensee acknowledges and agrees that, upon any breach, Amidaware may seek remedies, including injunctive relief, damages, and legal fees, and will be entitled to prosecute the Licensee to the full extent of the law. | ||||
| 3. **Effects of Termination**: Upon termination, the Licensee shall immediately cease all use of the Software and delete all copies of the Software from its systems and confirm such deletion in writing to Amidaware. | ||||
|  | ||||
| ## Updates & Amendments | ||||
|  | ||||
| 1. **Software Updates**: Amidaware may, from time to time, release updates or upgrades to the Software. These updates or upgrades might be subject to additional terms presented to you at the time of download or installation. | ||||
| 2. **Amendments to this Agreement**: Amidaware reserves the right to modify the terms of this Agreement at any given time. Any such modifications will be effective immediately upon posting on Amidaware's official website or by direct communication to the Licensee. The continued use of the Software after such modifications will constitute the Licensee's acceptance of the revised terms. | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
							
								
								
									
										33
									
								
								api/tacticalrmm/ee/reporting/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								api/tacticalrmm/ee/reporting/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import re | ||||
| from datetime import datetime, timedelta | ||||
|  | ||||
| import yaml | ||||
| from django.utils import timezone | ||||
|  | ||||
| now_regex = re.compile( | ||||
|     r"^(weeks|days|hours|minutes|seconds|microseconds)=(-?\d*)$", re.VERBOSE | ||||
| ) | ||||
|  | ||||
|  | ||||
| def construct_yaml_now(loader, node): | ||||
|     loader.construct_scalar(node) | ||||
|     match = now_regex.match(node.value) | ||||
|     now = timezone.now() | ||||
|     if match: | ||||
|         now = now + timedelta(**{match.group(1): int(match.group(2))}) | ||||
|     return now | ||||
|  | ||||
|  | ||||
| def represent_datetime_now(dumper, data): | ||||
|     value = data.isoformat(" ") | ||||
|     return dumper.represent_scalar("!now", value) | ||||
|  | ||||
|  | ||||
| yaml.SafeLoader.add_constructor("!now", construct_yaml_now) | ||||
| yaml.SafeDumper.add_representer(datetime, represent_datetime_now) | ||||
							
								
								
									
										12
									
								
								api/tacticalrmm/ee/reporting/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								api/tacticalrmm/ee/reporting/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from django.contrib import admin | ||||
|  | ||||
| from .models import ReportAsset, ReportTemplate | ||||
|  | ||||
| admin.site.register(ReportTemplate) | ||||
| admin.site.register(ReportAsset) | ||||
							
								
								
									
										12
									
								
								api/tacticalrmm/ee/reporting/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								api/tacticalrmm/ee/reporting/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class ReportingConfig(AppConfig): | ||||
|     default_auto_field = "django.db.models.BigAutoField" | ||||
|     name = "ee.reporting" | ||||
							
								
								
									
										31
									
								
								api/tacticalrmm/ee/reporting/constants.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								api/tacticalrmm/ee/reporting/constants.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| # (Model, app) | ||||
| REPORTING_MODELS = ( | ||||
|     ("Agent", "agents"), | ||||
|     ("AgentCustomField", "agents"), | ||||
|     ("AgentHistory", "agents"), | ||||
|     ("Alert", "alerts"), | ||||
|     ("Policy", "automation"), | ||||
|     ("AutomatedTask", "autotasks"), | ||||
|     ("TaskResult", "autotasks"), | ||||
|     ("Check", "checks"), | ||||
|     ("CheckResult", "checks"), | ||||
|     ("CheckHistory", "checks"), | ||||
|     ("Client", "clients"), | ||||
|     ("ClientCustomField", "clients"), | ||||
|     ("Site", "clients"), | ||||
|     ("SiteCustomField", "clients"), | ||||
|     ("GlobalKVStore", "core"), | ||||
|     ("AuditLog", "logs"), | ||||
|     ("DebugLog", "logs"), | ||||
|     ("PendingAction", "logs"), | ||||
|     ("ChocoSoftware", "software"), | ||||
|     ("InstalledSoftware", "software"), | ||||
|     ("WinUpdate", "winupdate"), | ||||
|     ("WinUpdatePolicy", "winupdate"), | ||||
| ) | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/reporting/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/reporting/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
| @@ -0,0 +1,157 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
| import json | ||||
| from typing import TYPE_CHECKING, Any, Dict, List, Tuple | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.conf import settings as djangosettings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from ...constants import REPORTING_MODELS | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from django.db.models import Model | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Generate JSON Schemas" | ||||
|  | ||||
|     def handle(self, *args: Tuple[Any, Any], **kwargs: Dict[str, Any]) -> None: | ||||
|         generate_schema() | ||||
|  | ||||
|  | ||||
| # recursive function to traverse foreign keys and get values | ||||
| def traverse_model_fields( | ||||
|     *, model: "Model", prefix: str = "", depth: int = 3 | ||||
| ) -> Tuple[Dict[str, Any], Dict[str, Any], List[str], List[str]]: | ||||
|     filterObj: Dict[str, Any] = {} | ||||
|     patternObj: Dict[str, Any] = {} | ||||
|     select_related: List[str] = [] | ||||
|     field_list: List[str] = [] | ||||
|  | ||||
|     if depth < 1: | ||||
|         return filterObj, patternObj, select_related, field_list | ||||
|     for field in model._meta.get_fields(): | ||||
|         field_type = field.get_internal_type()  # type: ignore | ||||
|         if field_type == "CharField" and field.choices:  # type: ignore | ||||
|             propDefinition = { | ||||
|                 "type": "string", | ||||
|                 "enum": [index for index, _ in field.choices],  # type: ignore | ||||
|             } | ||||
|         elif field_type == "BooleanField": | ||||
|             propDefinition = { | ||||
|                 "type": "boolean", | ||||
|             } | ||||
|         elif field.many_to_many or field.one_to_many: | ||||
|             continue | ||||
|         elif ( | ||||
|             field_type == "ForeignKey" or field.name == "id" or "Integer" in field_type | ||||
|         ): | ||||
|             propDefinition = { | ||||
|                 "type": "integer", | ||||
|             } | ||||
|             if field_type == "ForeignKey": | ||||
|                 select_related.append(prefix + field.name) | ||||
|                 related_model = field.related_model | ||||
|                 # Get fields of the related model, recursively | ||||
|                 filter, pattern, select, list = traverse_model_fields( | ||||
|                     model=related_model,  # type: ignore | ||||
|                     prefix=prefix + field.name + "__", | ||||
|                     depth=depth - 1, | ||||
|                 ) | ||||
|                 filterObj = {**filterObj, **filter} | ||||
|                 patternObj = {**patternObj, **pattern} | ||||
|                 select_related += select | ||||
|                 field_list += list | ||||
|         else: | ||||
|             propDefinition = { | ||||
|                 "type": "string", | ||||
|             } | ||||
|         filterObj[prefix + field.name] = propDefinition | ||||
|         patternObj["^" + prefix + field.name + "(__[a-zA-Z]+)*$"] = propDefinition | ||||
|         field_list.append(prefix + field.name) | ||||
|     return filterObj, patternObj, select_related, field_list | ||||
|  | ||||
|  | ||||
| def generate_schema() -> None: | ||||
|     oneOf = [] | ||||
|  | ||||
|     for model, app in REPORTING_MODELS: | ||||
|         Model = apps.get_model(app_label=app, model_name=model) | ||||
|  | ||||
|         filterObj, patternObj, select_related, field_list = traverse_model_fields( | ||||
|             model=Model, depth=3 | ||||
|         ) | ||||
|  | ||||
|         order_by = [] | ||||
|         for field in field_list: | ||||
|             order_by.append(field) | ||||
|             order_by.append(f"-{field}") | ||||
|  | ||||
|         oneOf.append( | ||||
|             { | ||||
|                 "properties": { | ||||
|                     "model": {"type": "string", "enum": [model.lower()]}, | ||||
|                     "filter": { | ||||
|                         "type": "object", | ||||
|                         "properties": filterObj, | ||||
|                         "patternProperties": patternObj, | ||||
|                     }, | ||||
|                     "exclude": { | ||||
|                         "type": "object", | ||||
|                         "properties": filterObj, | ||||
|                         "patternProperties": patternObj, | ||||
|                     }, | ||||
|                     "defer": { | ||||
|                         "type": "array", | ||||
|                         "items": { | ||||
|                             "type": "string", | ||||
|                             "minimum": 1, | ||||
|                             "enum": field_list, | ||||
|                         }, | ||||
|                     }, | ||||
|                     "only": { | ||||
|                         "type": "array", | ||||
|                         "items": {"type": "string", "minimum": 1, "enum": field_list}, | ||||
|                     }, | ||||
|                     "select_related": { | ||||
|                         "type": "array", | ||||
|                         "items": { | ||||
|                             "type": "string", | ||||
|                             "minimum": 1, | ||||
|                             "enum": select_related, | ||||
|                         }, | ||||
|                     }, | ||||
|                     "order_by": {"type": "string", "enum": order_by}, | ||||
|                 }, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     schema = { | ||||
|         "$id": f"https://{djangosettings.ALLOWED_HOSTS[0]}/static/reporting/schemas/query_schema.json", | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|             "model": { | ||||
|                 "type": "string", | ||||
|                 "enum": [model.lower() for model, _ in REPORTING_MODELS], | ||||
|             }, | ||||
|             "custom_fields": { | ||||
|                 "type": "array", | ||||
|                 "items": {"type": "string", "minimum": 1}, | ||||
|             }, | ||||
|             "limit": {"type": "integer"}, | ||||
|             "count": {"type": "boolean"}, | ||||
|             "get": {"type": "boolean"}, | ||||
|             "first": {"type": "boolean"}, | ||||
|         }, | ||||
|         "required": ["model"], | ||||
|         "oneOf": oneOf, | ||||
|     } | ||||
|  | ||||
|     with open( | ||||
|         f"{djangosettings.STATICFILES_DIRS[0]}reporting/schemas/query_schema.json", "w" | ||||
|     ) as outfile: | ||||
|         outfile.write(json.dumps(schema)) | ||||
| @@ -0,0 +1,63 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
| import urllib.parse | ||||
| from time import sleep | ||||
| from typing import Any, Optional | ||||
|  | ||||
| import requests | ||||
| from core.models import CodeSignToken | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Get webtar url" | ||||
|  | ||||
|     def handle(self, *args: tuple[Any, Any], **kwargs: dict[str, Any]) -> None: | ||||
|         webtar = f"trmm-web-v{settings.WEB_VERSION}.tar.gz" | ||||
|         url = f"https://github.com/amidaware/tacticalrmm-web/releases/download/v{settings.WEB_VERSION}/{webtar}" | ||||
|  | ||||
|         t: "Optional[CodeSignToken]" = CodeSignToken.objects.first() | ||||
|         if not t or not t.token: | ||||
|             self.stdout.write(url) | ||||
|             return | ||||
|  | ||||
|         attempts = 0 | ||||
|         while 1: | ||||
|             try: | ||||
|                 r = requests.post( | ||||
|                     settings.REPORTING_CHECK_URL, | ||||
|                     json={"token": t.token, "api": settings.ALLOWED_HOSTS[0]}, | ||||
|                     headers={"Content-type": "application/json"}, | ||||
|                     timeout=15, | ||||
|                 ) | ||||
|             except Exception as e: | ||||
|                 self.stderr.write(str(e)) | ||||
|                 attempts += 1 | ||||
|                 sleep(3) | ||||
|             else: | ||||
|                 if r.status_code // 100 in (3, 5): | ||||
|                     self.stderr.write(f"Error getting web tarball: {r.status_code}") | ||||
|                     attempts += 1 | ||||
|                     sleep(3) | ||||
|                 else: | ||||
|                     attempts = 0 | ||||
|  | ||||
|             if attempts == 0: | ||||
|                 break | ||||
|             elif attempts > 5: | ||||
|                 self.stdout.write(url) | ||||
|                 return | ||||
|  | ||||
|         if r.status_code == 200:  # type: ignore | ||||
|             params = { | ||||
|                 "token": t.token, | ||||
|                 "webver": settings.WEB_VERSION, | ||||
|                 "api": settings.ALLOWED_HOSTS[0], | ||||
|             } | ||||
|             url = settings.REPORTING_DL_URL + urllib.parse.urlencode(params) | ||||
|  | ||||
|         self.stdout.write(url) | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/reporting/markdown/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/reporting/markdown/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
							
								
								
									
										25
									
								
								api/tacticalrmm/ee/reporting/markdown/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								api/tacticalrmm/ee/reporting/markdown/config.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from typing import Optional, Sequence, Union | ||||
|  | ||||
| import markdown | ||||
|  | ||||
| from .ignorejinja_ext import IgnoreJinjaExtension | ||||
|  | ||||
| markdown_ext: "Optional[Sequence[Union[str, markdown.Extension]]]" = [ | ||||
|     "ocxsect", | ||||
|     "tables", | ||||
|     "sane_lists", | ||||
|     "def_list", | ||||
|     "nl2br", | ||||
|     "fenced_code", | ||||
|     "attr_list", | ||||
|     IgnoreJinjaExtension(), | ||||
| ] | ||||
|  | ||||
| # import this into views | ||||
| Markdown = markdown.Markdown(extensions=markdown_ext) | ||||
							
								
								
									
										70
									
								
								api/tacticalrmm/ee/reporting/markdown/ignorejinja_ext.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								api/tacticalrmm/ee/reporting/markdown/ignorejinja_ext.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import re | ||||
| from typing import Any, List | ||||
|  | ||||
| from markdown import Extension, Markdown | ||||
| from markdown.postprocessors import Postprocessor | ||||
| from markdown.preprocessors import Preprocessor | ||||
|  | ||||
|  | ||||
| class IgnoreJinjaExtension(Extension): | ||||
|     """Extension for looking up {% block tag %}""" | ||||
|  | ||||
|     def extendMarkdown(self, md: Markdown) -> None: | ||||
|         """Add IgnoreJinjaExtension to Markdown instance.""" | ||||
|         md.preprocessors.register(IgnoreJinjaPreprocessor(md), "preignorejinja", 0) | ||||
|         md.postprocessors.register(IgnoreJinjaPostprocessor(md), "postignorejinja", 0) | ||||
|  | ||||
|  | ||||
| PRE_RE = re.compile(r"(\{\%.*\%\})") | ||||
|  | ||||
|  | ||||
| class IgnoreJinjaPreprocessor(Preprocessor): | ||||
|     """ | ||||
|     Looks for {% block tag %} and wraps it in an html comment <!---  --> | ||||
|     """ | ||||
|  | ||||
|     def run(self, lines: List[str]) -> List[str]: | ||||
|         new_lines: List[str] = [] | ||||
|         for line in lines: | ||||
|             m = PRE_RE.search(line) | ||||
|             if m: | ||||
|                 tag = m.group(1) | ||||
|                 new_line = line.replace(tag, f"<!--- {tag} -->") | ||||
|                 new_lines.append(new_line) | ||||
|             else: | ||||
|                 new_lines.append(line) | ||||
|  | ||||
|         return new_lines | ||||
|  | ||||
|  | ||||
| POST_RE = re.compile(r"\<\!\-\-\-\s{1}(\{\%.*\%\})\s{1}\-\-\>") | ||||
|  | ||||
|  | ||||
| class IgnoreJinjaPostprocessor(Postprocessor): | ||||
|     """ | ||||
|     Looks for <!-- {{% block tag %}} --> and removes the comment | ||||
|     """ | ||||
|  | ||||
|     def run(self, text: str) -> str: | ||||
|         new_lines: List[str] = [] | ||||
|         lines = text.split("\n") | ||||
|         for line in lines: | ||||
|             m = POST_RE.search(line) | ||||
|             if m: | ||||
|                 tag = m.group(1) | ||||
|                 new_line = line.replace(f"<!--- {tag} -->", tag) | ||||
|                 new_lines.append(new_line) | ||||
|             else: | ||||
|                 new_lines.append(line) | ||||
|         return "\n".join(new_lines) | ||||
|  | ||||
|  | ||||
| def makeExtension(*args: Any, **kwargs: Any) -> IgnoreJinjaExtension: | ||||
|     """set up extension.""" | ||||
|     return IgnoreJinjaExtension(*args, **kwargs) | ||||
							
								
								
									
										116
									
								
								api/tacticalrmm/ee/reporting/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								api/tacticalrmm/ee/reporting/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| # Generated by Django 4.2.3 on 2023-07-05 05:33 | ||||
|  | ||||
| import django.contrib.postgres.fields | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| import ee.reporting.storage | ||||
| import uuid | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="ReportAsset", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, | ||||
|                         editable=False, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         unique=True, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "file", | ||||
|                     models.FileField( | ||||
|                         storage=ee.reporting.storage.get_report_assets_fs, | ||||
|                         unique=True, | ||||
|                         upload_to="", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="ReportDataQuery", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.CharField(max_length=50, unique=True)), | ||||
|                 ("json_query", models.JSONField()), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="ReportHTMLTemplate", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.CharField(max_length=50, unique=True)), | ||||
|                 ("html", models.TextField()), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="ReportTemplate", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.BigAutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.CharField(max_length=50, unique=True)), | ||||
|                 ("template_md", models.TextField()), | ||||
|                 ("template_css", models.TextField(blank=True, null=True)), | ||||
|                 ( | ||||
|                     "type", | ||||
|                     models.CharField( | ||||
|                         choices=[("markdown", "Markdown"), ("html", "Html")], | ||||
|                         default="markdown", | ||||
|                         max_length=15, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("template_variables", models.TextField(blank=True, default="")), | ||||
|                 ( | ||||
|                     "depends_on", | ||||
|                     django.contrib.postgres.fields.ArrayField( | ||||
|                         base_field=models.CharField(blank=True, max_length=20), | ||||
|                         blank=True, | ||||
|                         default=list, | ||||
|                         size=None, | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "template_html", | ||||
|                     models.ForeignKey( | ||||
|                         blank=True, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.DO_NOTHING, | ||||
|                         related_name="htmltemplate", | ||||
|                         to="reporting.reporthtmltemplate", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 4.2.5 on 2023-10-05 16:56 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('reporting', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='reporttemplate', | ||||
|             name='type', | ||||
|             field=models.CharField(choices=[('markdown', 'Markdown'), ('html', 'Html'), ('plaintext', 'Plain Text')], default='markdown', max_length=15), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/reporting/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/reporting/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
							
								
								
									
										66
									
								
								api/tacticalrmm/ee/reporting/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								api/tacticalrmm/ee/reporting/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import uuid | ||||
|  | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.db import models | ||||
|  | ||||
| from .storage import get_report_assets_fs | ||||
|  | ||||
|  | ||||
| class ReportFormatType(models.TextChoices): | ||||
|     MARKDOWN = "markdown", "Markdown" | ||||
|     HTML = "html", "Html" | ||||
|     PLAIN_TEXT = "plaintext", "Plain Text" | ||||
|  | ||||
|  | ||||
| class ReportTemplate(models.Model): | ||||
|     name = models.CharField(max_length=50, unique=True) | ||||
|     template_md = models.TextField() | ||||
|     template_css = models.TextField(null=True, blank=True) | ||||
|     template_html = models.ForeignKey( | ||||
|         "ReportHTMLTemplate", | ||||
|         related_name="htmltemplate", | ||||
|         on_delete=models.DO_NOTHING, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|     type = models.CharField( | ||||
|         max_length=15, | ||||
|         choices=ReportFormatType.choices, | ||||
|         default=ReportFormatType.MARKDOWN, | ||||
|     ) | ||||
|     template_variables = models.TextField(blank=True, default="") | ||||
|     depends_on = ArrayField( | ||||
|         models.CharField(max_length=20, blank=True), blank=True, default=list | ||||
|     ) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.name | ||||
|  | ||||
|  | ||||
| class ReportHTMLTemplate(models.Model): | ||||
|     name = models.CharField(max_length=50, unique=True) | ||||
|     html = models.TextField() | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.name | ||||
|  | ||||
|  | ||||
| class ReportAsset(models.Model): | ||||
|     id = models.UUIDField( | ||||
|         primary_key=True, unique=True, default=uuid.uuid4, editable=False | ||||
|     ) | ||||
|     file = models.FileField(storage=get_report_assets_fs, unique=True) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"{self.id} - {self.file}" | ||||
|  | ||||
|  | ||||
| class ReportDataQuery(models.Model): | ||||
|     name = models.CharField(max_length=50, unique=True) | ||||
|     json_query = models.JSONField() | ||||
							
								
								
									
										17
									
								
								api/tacticalrmm/ee/reporting/permissions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								api/tacticalrmm/ee/reporting/permissions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| from rest_framework import permissions | ||||
| from tacticalrmm.permissions import _has_perm | ||||
|  | ||||
|  | ||||
| class ReportingPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if r.method == "GET": | ||||
|             return _has_perm(r, "can_view_reports") or _has_perm( | ||||
|                 r, "can_manage_reports" | ||||
|             ) | ||||
|  | ||||
|         return _has_perm(r, "can_manage_reports") | ||||
|  | ||||
|  | ||||
| class GenerateReportPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         return _has_perm(r, "can_view_reports") or _has_perm(r, "can_manage_reports") | ||||
							
								
								
									
										32
									
								
								api/tacticalrmm/ee/reporting/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								api/tacticalrmm/ee/reporting/settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from django.conf import settings as djangosettings | ||||
|  | ||||
|  | ||||
| class Settings: | ||||
|     def __init__(self) -> None: | ||||
|         self.settings = djangosettings | ||||
|  | ||||
|     @property | ||||
|     def REPORTING_ASSETS_BASE_PATH(self) -> str: | ||||
|         return getattr( | ||||
|             self.settings, | ||||
|             "REPORTING_ASSETS_BASE_PATH", | ||||
|             "/opt/tactical/reporting/assets", | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def REPORTING_BASE_URL(self) -> str: | ||||
|         return getattr( | ||||
|             self.settings, | ||||
|             "REPORTING_BASE_URL", | ||||
|             f"https://{djangosettings.ALLOWED_HOSTS[0]}", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| # import this to load initialized settings during runtime | ||||
| settings = Settings() | ||||
							
								
								
									
										75
									
								
								api/tacticalrmm/ee/reporting/storage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								api/tacticalrmm/ee/reporting/storage.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import os | ||||
| import shutil | ||||
|  | ||||
| from django.core.files.storage import FileSystemStorage | ||||
|  | ||||
| from .settings import settings | ||||
|  | ||||
|  | ||||
| class ReportAssetStorage(FileSystemStorage): | ||||
|     """Report Asset file storage object. This keeps file system | ||||
|     operations confined to REPORT_ASSETS_PATH | ||||
|     """ | ||||
|  | ||||
|     def isdir(self, *, path: str) -> bool: | ||||
|         """Checks if path is a directory""" | ||||
|         return os.path.isdir(self.path(name=path)) | ||||
|  | ||||
|     def isfile(self, *, path: str) -> bool: | ||||
|         """Checks if path is a file""" | ||||
|         return os.path.isfile(self.path(name=path)) | ||||
|  | ||||
|     def getfulldir(self, *, path: str) -> str: | ||||
|         """Returns the absolute path of the parent folder""" | ||||
|         return os.path.dirname(self.path(name=path)) | ||||
|  | ||||
|     def getreldir(self, *, path: str) -> str: | ||||
|         """Returns relative path of the parent folder. The path is relative to | ||||
|         REPORT_ASSETS_PATH. | ||||
|         """ | ||||
|         if self.exists(path): | ||||
|             return os.path.dirname(path) | ||||
|  | ||||
|         return "" | ||||
|  | ||||
|     def rename(self, *, path: str, new_name: str) -> str: | ||||
|         """Renames the file or folder specified. If the name is already taken | ||||
|         then 6 random characters (_cb6dge) will be appended to the name | ||||
|         """ | ||||
|         parent_folder = self.getreldir(path=path) | ||||
|         new_path = self.get_available_name(os.path.join(parent_folder, new_name)) | ||||
|         os.rename(self.path(path), self.path(new_path)) | ||||
|         return new_path | ||||
|  | ||||
|     def createfolder(self, *, path: str) -> str: | ||||
|         """Create a folder in the specified path""" | ||||
|         new_path = self.get_available_name(path) | ||||
|         os.mkdir(os.path.join(self.base_location, new_path)) | ||||
|         return new_path | ||||
|  | ||||
|     def move(self, *, source: str, destination: str) -> str: | ||||
|         """Move a file or directory to the destination. If the file or folder | ||||
|         name conflicts, the new name will have additional characters appended. | ||||
|         """ | ||||
|         new_destination = self.get_available_name( | ||||
|             os.path.join(destination, source.split("/")[-1]) | ||||
|         ) | ||||
|  | ||||
|         shutil.move(self.path(source), self.path(new_destination)) | ||||
|         return new_destination | ||||
|  | ||||
|  | ||||
| report_assets_fs = ReportAssetStorage( | ||||
|     location=settings.REPORTING_ASSETS_BASE_PATH, | ||||
|     base_url=f"{settings.REPORTING_BASE_URL}/reporting/assets/", | ||||
| ) | ||||
|  | ||||
|  | ||||
| def get_report_assets_fs(): | ||||
|     return report_assets_fs | ||||
							
								
								
									
										5
									
								
								api/tacticalrmm/ee/reporting/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								api/tacticalrmm/ee/reporting/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
							
								
								
									
										153
									
								
								api/tacticalrmm/ee/reporting/tests/test_base_template_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								api/tacticalrmm/ee/reporting/tests/test_base_template_views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,153 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APIClient | ||||
|  | ||||
| from ..models import ReportHTMLTemplate | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def authenticated_client(): | ||||
|     client = APIClient() | ||||
|     user = baker.make("accounts.User", is_superuser=True) | ||||
|     client.force_authenticate(user=user) | ||||
|     return client | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def unauthenticated_client(): | ||||
|     return APIClient() | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_html_template(): | ||||
|     return baker.make("reporting.ReportHTMLTemplate") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_html_template_data(): | ||||
|     return {"name": "Test Template", "html": "<div>Test HTML</div>"} | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetAddReportHTMLTemplate: | ||||
|     def test_get_all_report_html_templates( | ||||
|         self, authenticated_client, report_html_template | ||||
|     ): | ||||
|         response = authenticated_client.get("/reporting/htmltemplates/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert len(response.data) == 1 | ||||
|         assert response.data[0]["name"] == report_html_template.name | ||||
|  | ||||
|     def test_post_new_report_html_template( | ||||
|         self, authenticated_client, report_html_template_data | ||||
|     ): | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/htmltemplates/", data=report_html_template_data | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportHTMLTemplate.objects.filter( | ||||
|             name=report_html_template_data["name"] | ||||
|         ).exists() | ||||
|  | ||||
|     def test_post_invalid_data(self, authenticated_client): | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/htmltemplates/", data={"name": ""} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_unauthenticated_get_html_templates_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.get("/reporting/htmltemplates/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_add_html_template_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/htmltemplates/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetEditDeleteReportHTMLTemplate: | ||||
|     def test_get_specific_report_html_template( | ||||
|         self, authenticated_client, report_html_template | ||||
|     ): | ||||
|         response = authenticated_client.get( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data["name"] == report_html_template.name | ||||
|  | ||||
|     def test_get_non_existent_template(self, authenticated_client): | ||||
|         response = authenticated_client.get("/reporting/htmltemplates/999/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_404_NOT_FOUND | ||||
|  | ||||
|     def test_put_update_report_html_template( | ||||
|         self, authenticated_client, report_html_template, report_html_template_data | ||||
|     ): | ||||
|         response = authenticated_client.put( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/", | ||||
|             data=report_html_template_data, | ||||
|         ) | ||||
|  | ||||
|         report_html_template.refresh_from_db() | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert report_html_template.name == report_html_template_data["name"] | ||||
|  | ||||
|     def test_put_invalid_data(self, authenticated_client, report_html_template): | ||||
|         response = authenticated_client.put( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/", data={"name": ""} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_delete_report_html_template( | ||||
|         self, authenticated_client, report_html_template | ||||
|     ): | ||||
|         response = authenticated_client.delete( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert not ReportHTMLTemplate.objects.filter( | ||||
|             id=report_html_template.id | ||||
|         ).exists() | ||||
|  | ||||
|     def test_delete_non_existent_template(self, authenticated_client): | ||||
|         response = authenticated_client.delete("/reporting/htmltemplates/999/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_404_NOT_FOUND | ||||
|  | ||||
|     def test_unauthenticated_get_html_template_view( | ||||
|         self, unauthenticated_client, report_html_template | ||||
|     ): | ||||
|         response = unauthenticated_client.get( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_edit_html_template_view( | ||||
|         self, unauthenticated_client, report_html_template | ||||
|     ): | ||||
|         response = unauthenticated_client.put( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_delete_html_template_view( | ||||
|         self, unauthenticated_client, report_html_template | ||||
|     ): | ||||
|         response = unauthenticated_client.delete( | ||||
|             f"/reporting/htmltemplates/{report_html_template.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
							
								
								
									
										507
									
								
								api/tacticalrmm/ee/reporting/tests/test_data_queries.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										507
									
								
								api/tacticalrmm/ee/reporting/tests/test_data_queries.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,507 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from unittest.mock import patch | ||||
|  | ||||
| import pytest | ||||
| from agents.models import Agent | ||||
| from django.apps import apps | ||||
| from model_bakery import baker | ||||
|  | ||||
| from ..constants import REPORTING_MODELS | ||||
| from ..utils import ( | ||||
|     InvalidDBOperationException, | ||||
|     ResolveModelException, | ||||
|     add_custom_fields, | ||||
|     build_queryset, | ||||
|     resolve_model, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class TestResolvingModels: | ||||
|     def test_all_reporting_models_valid(self): | ||||
|         for model_name, app_name in REPORTING_MODELS: | ||||
|             try: | ||||
|                 apps.get_model(app_name, model_name) | ||||
|             except LookupError: | ||||
|                 pytest.fail(f"Model: {model_name} does not exist in app: {app_name}") | ||||
|  | ||||
|     def test_resolve_model_valid_model(self): | ||||
|         data_source = {"model": "Agent"} | ||||
|  | ||||
|         result = resolve_model(data_source=data_source) | ||||
|  | ||||
|         # Assuming 'agents.Agent' is a valid model in your Django app. | ||||
|         from agents.models import Agent | ||||
|  | ||||
|         assert result["model"] == Agent | ||||
|  | ||||
|     def test_resolve_model_invalid_model_name(self): | ||||
|         data_source = {"model": "InvalidModel"} | ||||
|  | ||||
|         with pytest.raises( | ||||
|             ResolveModelException, match="Model lookup failed for InvalidModel" | ||||
|         ): | ||||
|             resolve_model(data_source=data_source) | ||||
|  | ||||
|     def test_resolve_model_no_model_key(self): | ||||
|         data_source = {"key": "value"} | ||||
|  | ||||
|         with pytest.raises( | ||||
|             ResolveModelException, match="Model key must be present on data_source" | ||||
|         ): | ||||
|             resolve_model(data_source=data_source) | ||||
|  | ||||
|  | ||||
| @patch("agents.models.Agent.objects.using", return_value=Agent.objects.using("default")) | ||||
| @pytest.mark.django_db() | ||||
| class TestBuildingQueryset: | ||||
|     @pytest.fixture | ||||
|     def setup_agents(self): | ||||
|         agent1 = baker.make("agents.Agent", hostname="ZAgent1", plat="windows") | ||||
|         agent2 = baker.make("agents.Agent", hostname="Agent2", plat="windows") | ||||
|         return [agent1, agent2] | ||||
|  | ||||
|     def test_build_queryset_with_valid_model(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|         } | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         assert result is not None, "Queryset should not be None for a valid model." | ||||
|  | ||||
|     def test_build_queryset_invalid_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "invalid_op": "value", | ||||
|         } | ||||
|         with pytest.raises(InvalidDBOperationException): | ||||
|             build_queryset(data_source=data_source) | ||||
|  | ||||
|     def test_build_queryset_without_model(self, mock, setup_agents): | ||||
|         data_source = {} | ||||
|         with pytest.raises( | ||||
|             Exception | ||||
|         ):  # This could be a more specific exception if you expect one. | ||||
|             build_queryset(data_source=data_source) | ||||
|  | ||||
|     def test_build_queryset_only_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "only": ["hostname", "operating_system"]} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 2 | ||||
|         for agent_data in result: | ||||
|             assert "hostname" in agent_data | ||||
|             assert "operating_system" in agent_data | ||||
|             assert "plat" not in agent_data | ||||
|  | ||||
|     def test_build_queryset_id_is_appended_if_only_exists(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "only": ["hostname"]} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 2 | ||||
|         for agent_data in result: | ||||
|             assert "id" in agent_data | ||||
|  | ||||
|     def test_build_queryset_filter_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "filter": {"hostname": setup_agents[0].hostname}, | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 1 | ||||
|         assert result[0]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_filtering_operation_with_multiple_fields(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "filter": { | ||||
|                 "hostname": setup_agents[0].hostname, | ||||
|                 "operating_system": setup_agents[0].operating_system, | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 1 | ||||
|         assert result[0]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_filtering_with_non_existing_condition(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "filter": {"hostname": "doesn't exist"}} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 0 | ||||
|  | ||||
|     def test_build_queryset_exclude_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "exclude": {"hostname": setup_agents[0].hostname}, | ||||
|         } | ||||
|  | ||||
|         results = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(results) == 1 | ||||
|         assert results[0]["hostname"] != setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_query_get_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "get": {"agent_id": setup_agents[0].agent_id}} | ||||
|  | ||||
|         agent = build_queryset(data_source=data_source) | ||||
|         assert agent["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_all_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "all": True, | ||||
|         } | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         assert len(result) == 2 | ||||
|  | ||||
|     # test filter and only | ||||
|     def test_build_queryset_filter_only_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "filter": {"hostname": setup_agents[0].hostname}, | ||||
|             "only": ["agent_id", "hostname"], | ||||
|         } | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         # should only return 1 result | ||||
|         assert len(result) == 1 | ||||
|         assert result[0]["hostname"] == setup_agents[0].hostname | ||||
|         assert "plat" not in result[0] | ||||
|  | ||||
|     def test_build_queryset_limit_operation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "limit": 1, | ||||
|         } | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         assert len(result) == 1 | ||||
|  | ||||
|     def test_build_queryset_field_defer_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "defer": ["wmi_detail", "services"]} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         assert "wmi_detail" not in result[0] | ||||
|         assert "services" not in result[0] | ||||
|         assert result[0]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_first_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "first": True} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert result["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_count_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "count": True} | ||||
|  | ||||
|         count = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert count == 2 | ||||
|  | ||||
|     def test_build_queryset_order_by_operation(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "order_by": ["hostname"]} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 2 | ||||
|         assert result[0]["hostname"] == setup_agents[1].hostname | ||||
|         assert result[1]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_json_presentation(self, mock, setup_agents): | ||||
|         import json | ||||
|  | ||||
|         data_source = {"model": Agent, "json": True} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         # Deserializing the result to check the content. | ||||
|         result_data = json.loads(result) | ||||
|         assert result_data[0]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_csv_presentation(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "only": ["hostname", "operating_system"], | ||||
|             "csv": True, | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         # should be a string in csv format | ||||
|         assert isinstance(result, str) | ||||
|         assert "hostname" in result.split("\n")[0] | ||||
|         assert "operating_system" in result.split("\n")[0] | ||||
|  | ||||
|     def test_build_queryset_csv_presentation_rename_columns(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "only": ["hostname", "operating_system"], | ||||
|             "csv": {"hostname": "Hostname", "operating_system": "Operating System"}, | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         # should be a string in csv format | ||||
|         assert isinstance(result, str) | ||||
|         assert "Hostname" in result.split("\n")[0] | ||||
|         assert "Operating System" in result.split("\n")[0] | ||||
|  | ||||
|     def test_build_queryset_custom_fields(self, mock, setup_agents): | ||||
|         default_value = "Default Value" | ||||
|  | ||||
|         field1 = baker.make( | ||||
|             "core.CustomField", name="custom_1", model="agent", type="text" | ||||
|         ) | ||||
|         baker.make( | ||||
|             "core.CustomField", | ||||
|             name="custom_2", | ||||
|             model="agent", | ||||
|             type="text", | ||||
|             default_value_string=default_value, | ||||
|         ) | ||||
|  | ||||
|         baker.make( | ||||
|             "agents.AgentCustomField", | ||||
|             agent=setup_agents[0], | ||||
|             field=field1, | ||||
|             string_value="Agent1", | ||||
|         ) | ||||
|         baker.make( | ||||
|             "agents.AgentCustomField", | ||||
|             agent=setup_agents[1], | ||||
|             field=field1, | ||||
|             string_value="Agent2", | ||||
|         ) | ||||
|  | ||||
|         data_source = {"model": Agent, "custom_fields": ["custom_1", "custom_2"]} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|         assert len(result) == 2 | ||||
|  | ||||
|         # check agent 1 | ||||
|         assert result[0]["custom_fields"]["custom_1"] == "Agent1" | ||||
|         assert result[0]["custom_fields"]["custom_2"] == default_value | ||||
|  | ||||
|         # check agent 2 | ||||
|         assert result[1]["custom_fields"]["custom_1"] == "Agent2" | ||||
|         assert result[1]["custom_fields"]["custom_2"] == default_value | ||||
|  | ||||
|     def test_build_queryset_filter_only_json_combination(self, mock, setup_agents): | ||||
|         import json | ||||
|  | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "filter": {"agent_id": setup_agents[0].agent_id}, | ||||
|             "only": ["hostname", "agent_id"], | ||||
|             "json": True, | ||||
|         } | ||||
|  | ||||
|         result_json = build_queryset(data_source=data_source) | ||||
|         result = json.loads(result_json) | ||||
|  | ||||
|         assert len(result) == 1 | ||||
|         assert "operating_system" not in result[0] | ||||
|         assert result[0]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_get_only_json_combination(self, mock, setup_agents): | ||||
|         import json | ||||
|  | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "get": {"agent_id": setup_agents[0].agent_id}, | ||||
|             "only": ["hostname", "agent_id"], | ||||
|             "json": True, | ||||
|         } | ||||
|  | ||||
|         result_json = build_queryset(data_source=data_source) | ||||
|         result = json.loads(result_json) | ||||
|  | ||||
|         assert isinstance(result, dict) | ||||
|         assert "operating_system" not in result | ||||
|         assert result["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_filter_order_by_combination(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "filter": {"plat": "windows"}, | ||||
|             "order_by": ["hostname"], | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert len(result) == 2 | ||||
|         assert result[0]["hostname"] == setup_agents[1].hostname | ||||
|         assert result[1]["hostname"] == setup_agents[0].hostname | ||||
|  | ||||
|     def test_build_queryset_defer_used_over_only(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "only": ["hostname", "operating_system"], | ||||
|             "defer": ["operating_system"], | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source)[0] | ||||
|  | ||||
|         assert "hostname" in result | ||||
|         assert "operating_system" not in result | ||||
|  | ||||
|     def test_build_queryset_limit_ignored_with_first_or_get(self, mock, setup_agents): | ||||
|         data_source = {"model": Agent, "limit": 1, "first": True} | ||||
|  | ||||
|         result_first = build_queryset(data_source=data_source) | ||||
|         assert isinstance(result_first, dict) | ||||
|  | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|             "limit": 1, | ||||
|             "get": {"agent_id": setup_agents[0].agent_id}, | ||||
|         } | ||||
|  | ||||
|         result_get = build_queryset(data_source=data_source) | ||||
|         assert isinstance(result_get, dict) | ||||
|  | ||||
|     def test_build_queryset_result_type_with_get_or_first(self, mock, setup_agents): | ||||
|         # Test with "get" | ||||
|         data_source_get = { | ||||
|             "model": Agent, | ||||
|             "get": {"hostname": setup_agents[0].hostname}, | ||||
|         } | ||||
|         result_get = build_queryset(data_source=data_source_get) | ||||
|  | ||||
|         # Test with "first" | ||||
|         data_source_first = {"model": Agent, "first": True} | ||||
|         result_first = build_queryset(data_source=data_source_first) | ||||
|  | ||||
|         assert not isinstance(result_get, list) | ||||
|         assert not isinstance(result_first, list) | ||||
|  | ||||
|     def test_build_queryset_result_type_without_get_or_first(self, mock, setup_agents): | ||||
|         data_source = { | ||||
|             "model": Agent, | ||||
|         } | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         assert isinstance(result, list) | ||||
|  | ||||
|     def test_build_queryset_result_in_json_format(self, mock, setup_agents): | ||||
|         import json | ||||
|  | ||||
|         data_source = {"model": Agent, "json": True} | ||||
|  | ||||
|         result = build_queryset(data_source=data_source) | ||||
|  | ||||
|         try: | ||||
|             parsed_result = json.loads(result) | ||||
|         except json.JSONDecodeError: | ||||
|             assert False | ||||
|  | ||||
|         assert isinstance(parsed_result, list) | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestAddingCustomFields: | ||||
|     @pytest.mark.parametrize( | ||||
|         "model_name,custom_field_model", | ||||
|         [ | ||||
|             ("agent", "agents.AgentCustomField"), | ||||
|             ("client", "clients.ClientCustomField"), | ||||
|             ("site", "clients.SiteCustomField"), | ||||
|         ], | ||||
|     ) | ||||
|     def test_add_custom_fields_with_list_of_dicts(self, model_name, custom_field_model): | ||||
|         custom_field = baker.make("core.CustomField", name="field1", model=model_name) | ||||
|         default_value = "Default Value" | ||||
|         baker.make( | ||||
|             "core.CustomField", | ||||
|             name="field2", | ||||
|             model=model_name, | ||||
|             default_value_string=default_value, | ||||
|         ) | ||||
|  | ||||
|         custom_model_instance1 = baker.make( | ||||
|             custom_field_model, field=custom_field, string_value="Value" | ||||
|         ) | ||||
|         custom_model_instance2 = baker.make( | ||||
|             custom_field_model, field=custom_field, string_value="Value" | ||||
|         ) | ||||
|  | ||||
|         data = [ | ||||
|             {"id": getattr(custom_model_instance1, f"{model_name}_id")}, | ||||
|             {"id": getattr(custom_model_instance2, f"{model_name}_id")}, | ||||
|         ] | ||||
|         fields_to_add = ["field1", "field2"] | ||||
|         result = add_custom_fields( | ||||
|             data=data, fields_to_add=fields_to_add, model_name=model_name | ||||
|         ) | ||||
|  | ||||
|         # Assert logic here based on what you expect the result to be | ||||
|         assert result[0]["custom_fields"]["field1"] == custom_model_instance1.value | ||||
|         assert result[1]["custom_fields"]["field1"] == custom_model_instance2.value | ||||
|  | ||||
|         assert result[0]["custom_fields"]["field2"] == default_value | ||||
|         assert result[1]["custom_fields"]["field2"] == default_value | ||||
|  | ||||
|     @pytest.mark.parametrize( | ||||
|         "model_name,custom_field_model", | ||||
|         [ | ||||
|             ("agent", "agents.AgentCustomField"), | ||||
|             ("client", "clients.ClientCustomField"), | ||||
|             ("site", "clients.SiteCustomField"), | ||||
|         ], | ||||
|     ) | ||||
|     def test_add_custom_fields_to_dictionary(self, model_name, custom_field_model): | ||||
|         custom_field = baker.make("core.CustomField", name="field1", model=model_name) | ||||
|         custom_model_instance = baker.make( | ||||
|             custom_field_model, field=custom_field, string_value="default_value" | ||||
|         ) | ||||
|  | ||||
|         data = {"id": getattr(custom_model_instance, f"{model_name}_id")} | ||||
|         fields_to_add = ["field1"] | ||||
|         result = add_custom_fields( | ||||
|             data=data, | ||||
|             fields_to_add=fields_to_add, | ||||
|             model_name=model_name, | ||||
|             dict_value=True, | ||||
|         ) | ||||
|  | ||||
|         # Assert logic here based on what you expect the result to be | ||||
|         assert result["custom_fields"]["field1"] == custom_model_instance.value | ||||
|  | ||||
|     @pytest.mark.parametrize( | ||||
|         "model_name", | ||||
|         [ | ||||
|             "agent", | ||||
|             "client", | ||||
|             "site", | ||||
|         ], | ||||
|     ) | ||||
|     def test_add_custom_fields_with_default_value(self, model_name): | ||||
|         default_value = "default_value" | ||||
|         baker.make( | ||||
|             "core.CustomField", | ||||
|             name="field1", | ||||
|             model=model_name, | ||||
|             default_value_string=default_value, | ||||
|         ) | ||||
|  | ||||
|         # Note: Not creating an instance of the custom_field_model here to ensure the default value is used | ||||
|  | ||||
|         data = {"id": 999}  # ID not associated with any custom field model instance | ||||
|         fields_to_add = ["field1"] | ||||
|         result = add_custom_fields( | ||||
|             data=data, | ||||
|             fields_to_add=fields_to_add, | ||||
|             model_name=model_name, | ||||
|             dict_value=True, | ||||
|         ) | ||||
|  | ||||
|         # Assert that the default value is used | ||||
|         assert result["custom_fields"]["field1"] == default_value | ||||
							
								
								
									
										192
									
								
								api/tacticalrmm/ee/reporting/tests/test_dataquery_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								api/tacticalrmm/ee/reporting/tests/test_dataquery_views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,192 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import json | ||||
| from unittest.mock import mock_open, patch | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APIClient | ||||
|  | ||||
| from ..models import ReportDataQuery | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def authenticated_client(): | ||||
|     client = APIClient() | ||||
|     user = baker.make("accounts.User", is_superuser=True) | ||||
|     client.force_authenticate(user=user) | ||||
|     return client | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def unauthenticated_client(): | ||||
|     return APIClient() | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_data_query(): | ||||
|     return baker.make("reporting.ReportDataQuery") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_data_query_data(): | ||||
|     return {"name": "Test Data Query", "json_query": {"test": "value"}} | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetAddReportDataQuery: | ||||
|     def test_get_all_report_data_queries(self, authenticated_client, report_data_query): | ||||
|         url = "/reporting/dataqueries/" | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert len(response.data) == 1 | ||||
|         assert response.data[0]["name"] == report_data_query.name | ||||
|  | ||||
|     def test_post_new_report_data_query( | ||||
|         self, authenticated_client, report_data_query_data | ||||
|     ): | ||||
|         url = "/reporting/dataqueries/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data=report_data_query_data, format="json" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportDataQuery.objects.filter( | ||||
|             name=report_data_query_data["name"] | ||||
|         ).exists() | ||||
|  | ||||
|     def test_post_invalid_data(self, authenticated_client): | ||||
|         url = "/reporting/dataqueries/" | ||||
|         response = authenticated_client.post(url, data={"name": ""}) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_unauthenticated_get_data_queries_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.get("/reporting/dataqueries/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_add_data_query_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/dataqueries/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetEditDeleteReportDataQuery: | ||||
|     def test_get_specific_report_data_query( | ||||
|         self, authenticated_client, report_data_query | ||||
|     ): | ||||
|         url = f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data["name"] == report_data_query.name | ||||
|  | ||||
|     def test_get_non_existent_data_query(self, authenticated_client): | ||||
|         url = "/reporting/dataqueries/9999/" | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_404_NOT_FOUND | ||||
|  | ||||
|     def test_put_update_report_data_query( | ||||
|         self, authenticated_client, report_data_query, report_data_query_data | ||||
|     ): | ||||
|         url = f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         response = authenticated_client.put( | ||||
|             url, data=report_data_query_data, format="json" | ||||
|         ) | ||||
|  | ||||
|         report_data_query.refresh_from_db() | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert report_data_query.name == report_data_query_data["name"] | ||||
|  | ||||
|     def test_put_invalid_data(self, authenticated_client, report_data_query): | ||||
|         url = f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         response = authenticated_client.put(url, data={"name": ""}) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_delete_report_data_query(self, authenticated_client, report_data_query): | ||||
|         url = f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         response = authenticated_client.delete(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert not ReportDataQuery.objects.filter(id=report_data_query.id).exists() | ||||
|  | ||||
|     def test_delete_non_existent_data_query(self, authenticated_client): | ||||
|         url = "/reporting/dataqueries/9999/" | ||||
|         response = authenticated_client.delete(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_404_NOT_FOUND | ||||
|  | ||||
|     def test_unauthenticated_get_data_queries_view( | ||||
|         self, unauthenticated_client, report_data_query | ||||
|     ): | ||||
|         response = unauthenticated_client.get( | ||||
|             f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_edit_html_template_view( | ||||
|         self, unauthenticated_client, report_data_query | ||||
|     ): | ||||
|         response = unauthenticated_client.put( | ||||
|             f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthenticated_delete_html_template_view( | ||||
|         self, unauthenticated_client, report_data_query | ||||
|     ): | ||||
|         response = unauthenticated_client.delete( | ||||
|             f"/reporting/dataqueries/{report_data_query.id}/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestQuerySchema: | ||||
|     def test_get_query_schema_in_debug_mode(self, settings, authenticated_client): | ||||
|         # Set DEBUG mode | ||||
|         settings.DEBUG = True | ||||
|  | ||||
|         expected_data = {"sample": "json"} | ||||
|  | ||||
|         # mock the file | ||||
|         mopen = mock_open(read_data=json.dumps({"sample": "json"})) | ||||
|         with patch("builtins.open", mopen): | ||||
|             response = authenticated_client.get("/reporting/queryschema/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.json() == expected_data | ||||
|  | ||||
|     def test_get_query_schema_in_production_mode(self, settings, authenticated_client): | ||||
|         # Set production mode (DEBUG = False) | ||||
|         settings.DEBUG = False | ||||
|  | ||||
|         response = authenticated_client.get("/reporting/queryschema/") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         # Check that the X-Accel-Redirect header is set correctly | ||||
|         assert ( | ||||
|             response["X-Accel-Redirect"] | ||||
|             == "/static/reporting/schemas/query_schema.json" | ||||
|         ) | ||||
|  | ||||
|     def test_get_query_schema_file_missing(self, settings, authenticated_client): | ||||
|         # Set DEBUG mode | ||||
|         settings.DEBUG = True | ||||
|  | ||||
|         with patch("builtins.open", side_effect=FileNotFoundError): | ||||
|             response = authenticated_client.get("/reporting/queryschema/") | ||||
|             assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_unauthenticated_query_schema_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.delete("/reporting/queryschema/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
| @@ -0,0 +1,285 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import base64 | ||||
| import json | ||||
| import uuid | ||||
| from unittest.mock import patch | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APIClient | ||||
|  | ||||
| from ..models import ReportAsset, ReportHTMLTemplate, ReportTemplate | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def authenticated_client(): | ||||
|     client = APIClient() | ||||
|     user = baker.make("accounts.User", is_superuser=True) | ||||
|     client.force_authenticate(user=user) | ||||
|     return client | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def unauthenticated_client(): | ||||
|     return APIClient() | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_template(): | ||||
|     return baker.make( | ||||
|         "reporting.ReportTemplate", | ||||
|         name="test_template", | ||||
|         template_md="# Test MD", | ||||
|         template_css="body { color: red; }", | ||||
|         type="markdown", | ||||
|         depends_on=["some_dependency"], | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_template_with_base_template(): | ||||
|     base_template = baker.make("reporting.ReportHTMLTemplate") | ||||
|     return baker.make( | ||||
|         "reporting.ReportTemplate", | ||||
|         name="test_template", | ||||
|         template_md="# Test MD", | ||||
|         template_css="body { color: red; }", | ||||
|         template_html=base_template, | ||||
|         type="markdown", | ||||
|         depends_on=["some_dependency"], | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestExportReportTemplate: | ||||
|     @patch( | ||||
|         "ee.reporting.views.base64_encode_assets", return_value="some_encoded_assets" | ||||
|     ) | ||||
|     def test_export_report_template_with_base_template( | ||||
|         self, | ||||
|         mock_encode_assets, | ||||
|         authenticated_client, | ||||
|         report_template_with_base_template, | ||||
|     ): | ||||
|         url = f"/reporting/templates/{report_template_with_base_template.id}/export/" | ||||
|         response = authenticated_client.post(url) | ||||
|  | ||||
|         expected_response = { | ||||
|             "base_template": { | ||||
|                 "name": report_template_with_base_template.template_html.name, | ||||
|                 "html": report_template_with_base_template.template_html.html, | ||||
|             }, | ||||
|             "template": { | ||||
|                 "name": report_template_with_base_template.name, | ||||
|                 "template_css": report_template_with_base_template.template_css, | ||||
|                 "template_md": report_template_with_base_template.template_md, | ||||
|                 "type": report_template_with_base_template.type, | ||||
|                 "depends_on": report_template_with_base_template.depends_on, | ||||
|                 "template_variables": "", | ||||
|             }, | ||||
|             "assets": "some_encoded_assets", | ||||
|         } | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data == expected_response | ||||
|         mock_encode_assets.assert_called() | ||||
|  | ||||
|     @patch( | ||||
|         "ee.reporting.views.base64_encode_assets", return_value="some_encoded_assets" | ||||
|     ) | ||||
|     def test_export_report_template_without_base_template( | ||||
|         self, | ||||
|         mock_encode_assets, | ||||
|         authenticated_client, | ||||
|         report_template, | ||||
|     ): | ||||
|         url = f"/reporting/templates/{report_template.id}/export/" | ||||
|         response = authenticated_client.post(url) | ||||
|  | ||||
|         expected_response = { | ||||
|             "base_template": None, | ||||
|             "template": { | ||||
|                 "name": report_template.name, | ||||
|                 "template_css": report_template.template_css, | ||||
|                 "template_md": report_template.template_md, | ||||
|                 "type": report_template.type, | ||||
|                 "depends_on": report_template.depends_on, | ||||
|                 "template_variables": "", | ||||
|             }, | ||||
|             "assets": "some_encoded_assets", | ||||
|         } | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data == expected_response | ||||
|         mock_encode_assets.assert_called() | ||||
|  | ||||
|     def test_unauthenticated_export_report_template_view( | ||||
|         self, unauthenticated_client, report_template | ||||
|     ): | ||||
|         response = unauthenticated_client.post( | ||||
|             f"/reporting/templates/{report_template.id}/export/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestImportReportTemplate: | ||||
|     @pytest.fixture | ||||
|     def valid_template_data(self): | ||||
|         """Returns a sample valid template data.""" | ||||
|         return { | ||||
|             "template": { | ||||
|                 "name": "test_template", | ||||
|                 "template_md": "# Test MD", | ||||
|                 "type": "markdown", | ||||
|                 "depends_on": ["some_dependency"], | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def valid_base_template_data(self): | ||||
|         """Returns a sample valid base template data.""" | ||||
|         return {"name": "base_test_template", "html": "<div>Test</div>"} | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def valid_assets_data(self): | ||||
|         """Returns a sample valid assets data.""" | ||||
|         return [ | ||||
|             { | ||||
|                 "id": str(uuid.uuid4()), | ||||
|                 "name": "asset1.png", | ||||
|                 "file": base64.b64encode(b"mock_content1").decode("utf-8"), | ||||
|             }, | ||||
|             { | ||||
|                 "id": str(uuid.uuid4()), | ||||
|                 "name": "asset2.png", | ||||
|                 "file": base64.b64encode(b"mock_content2").decode("utf-8"), | ||||
|             }, | ||||
|         ] | ||||
|  | ||||
|     def test_basic_import(self, authenticated_client, valid_template_data): | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportTemplate.objects.filter(name="test_template").exists() | ||||
|  | ||||
|     def test_import_with_base_template( | ||||
|         self, authenticated_client, valid_template_data, valid_base_template_data | ||||
|     ): | ||||
|         valid_template_data["base_template"] = valid_base_template_data | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportHTMLTemplate.objects.filter(name="base_test_template").exists() | ||||
|         assert ReportTemplate.objects.filter(name="test_template").exists() | ||||
|  | ||||
|     def test_import_with_base_template_with_overwrite( | ||||
|         self, authenticated_client, valid_template_data, valid_base_template_data | ||||
|     ): | ||||
|         valid_template_data["base_template"] = valid_base_template_data | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data), "overwrite": True} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportHTMLTemplate.objects.filter(name="base_test_template").exists() | ||||
|         assert ReportTemplate.objects.filter(name="test_template").exists() | ||||
|  | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data), "overwrite": True} | ||||
|         ) | ||||
|  | ||||
|         assert ( | ||||
|             ReportHTMLTemplate.objects.filter( | ||||
|                 name__startswith="base_test_template" | ||||
|             ).count() | ||||
|             == 1 | ||||
|         ) | ||||
|         assert ( | ||||
|             ReportTemplate.objects.filter(name__startswith="test_template").count() == 1 | ||||
|         ) | ||||
|  | ||||
|     def test_import_with_assets( | ||||
|         self, authenticated_client, valid_template_data, valid_assets_data | ||||
|     ): | ||||
|         valid_template_data["assets"] = valid_assets_data | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportAsset.objects.filter(id=valid_assets_data[0]["id"]).exists() | ||||
|         assert ReportAsset.objects.filter(id=valid_assets_data[1]["id"]).exists() | ||||
|  | ||||
|     @patch( | ||||
|         "ee.reporting.utils._generate_random_string", | ||||
|         return_value="randomized", | ||||
|     ) | ||||
|     def test_name_conflict_on_repeated_calls( | ||||
|         self, generate_random_string_mock, authenticated_client, valid_template_data | ||||
|     ): | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|         assert ReportTemplate.objects.filter(name="test_template").exists() | ||||
|  | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         print(response.data) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportTemplate.objects.filter(name="test_template_randomized").exists() | ||||
|  | ||||
|     def test_invalid_data(self, authenticated_client, valid_template_data): | ||||
|         valid_template_data["template"].pop("name") | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|         assert "name" in response.data | ||||
|  | ||||
|     def test_import_with_assets_with_conflicting_paths( | ||||
|         self, authenticated_client, valid_template_data, valid_assets_data | ||||
|     ): | ||||
|         conflicting_asset = { | ||||
|             "id": str(uuid.uuid4()), | ||||
|             "name": valid_assets_data[0]["name"], | ||||
|             "file": base64.b64encode(b"mock_content1").decode("utf-8"), | ||||
|         } | ||||
|         valid_assets_data.append(conflicting_asset) | ||||
|         valid_template_data["assets"] = valid_assets_data | ||||
|         url = "/reporting/templates/import/" | ||||
|         response = authenticated_client.post( | ||||
|             url, data={"template": json.dumps(valid_template_data)} | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert ReportAsset.objects.filter(id=valid_assets_data[0]["id"]).exists() | ||||
|         assert ReportAsset.objects.filter(id=valid_assets_data[1]["id"]).exists() | ||||
|         assert ReportAsset.objects.filter(id=conflicting_asset["id"]).exists() | ||||
|  | ||||
|         # check if the renaming logic is working | ||||
|         asset = ReportAsset.objects.get(id=conflicting_asset["id"]) | ||||
|         assert asset.file.name != valid_assets_data[0]["name"] | ||||
|  | ||||
|     def test_unauthenticated_import_report_template_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/templates/import/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
							
								
								
									
										23
									
								
								api/tacticalrmm/ee/reporting/tests/test_mgmt_commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/ee/reporting/tests/test_mgmt_commands.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import os | ||||
|  | ||||
| import pytest | ||||
| from django.core import management | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestSchemaGeneration: | ||||
|     def test_generate_json_schema(self, settings): | ||||
|         management.call_command("generate_json_schemas") | ||||
|  | ||||
|         schema_path = ( | ||||
|             f"{settings.STATICFILES_DIRS[0]}reporting/schemas/query_schema.json" | ||||
|         ) | ||||
|         assert os.path.exists(schema_path) | ||||
|  | ||||
|         os.remove(schema_path) | ||||
							
								
								
									
										542
									
								
								api/tacticalrmm/ee/reporting/tests/test_report_asset_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										542
									
								
								api/tacticalrmm/ee/reporting/tests/test_report_asset_views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,542 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import os | ||||
| import uuid | ||||
| from unittest.mock import mock_open, patch | ||||
|  | ||||
| import pytest | ||||
| from django.core.exceptions import SuspiciousFileOperation | ||||
| from django.core.files.uploadedfile import SimpleUploadedFile | ||||
| from model_bakery import baker | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APIClient | ||||
|  | ||||
| from ..models import ReportAsset | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def authenticated_client(): | ||||
|     client = APIClient() | ||||
|     user = baker.make("accounts.User", is_superuser=True) | ||||
|     client.force_authenticate(user=user) | ||||
|     return client | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def unauthenticated_client(): | ||||
|     return APIClient() | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetReportAssets: | ||||
|     @patch("ee.reporting.views.report_assets_fs") | ||||
|     def test_valid_path_with_dir_and_files( | ||||
|         self, mock_report_assets_fs, authenticated_client | ||||
|     ): | ||||
|         # Set up the mock behavior for report_assets_fs | ||||
|         mock_report_assets_fs.listdir.return_value = (["folder1"], ["file1.txt"]) | ||||
|         mock_report_assets_fs.size.return_value = 100 | ||||
|         mock_report_assets_fs.url.return_value = "/mocked/url/to/resource" | ||||
|  | ||||
|         path = "some/valid/path" | ||||
|         url = f"/reporting/assets/?path={path}" | ||||
|         expected_response_data = [ | ||||
|             { | ||||
|                 "name": "folder1", | ||||
|                 "path": os.path.join(path, "folder1"), | ||||
|                 "type": "folder", | ||||
|                 "size": None, | ||||
|                 "url": "/mocked/url/to/resource", | ||||
|             }, | ||||
|             { | ||||
|                 "name": "file1.txt", | ||||
|                 "path": os.path.join(path, "file1.txt"), | ||||
|                 "type": "file", | ||||
|                 "size": "100", | ||||
|                 "url": "/mocked/url/to/resource", | ||||
|             }, | ||||
|         ] | ||||
|  | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         assert response.data == expected_response_data | ||||
|  | ||||
|     @patch("ee.reporting.views.report_assets_fs") | ||||
|     def test_no_path(self, mock_report_assets_fs, authenticated_client): | ||||
|         # Set up the mock behavior for report_assets_fs | ||||
|         mock_report_assets_fs.listdir.return_value = (["folder1"], ["file1.txt"]) | ||||
|         mock_report_assets_fs.size.return_value = 100 | ||||
|         mock_report_assets_fs.url.return_value = "/mocked/url/to/resource" | ||||
|  | ||||
|         url = "/reporting/assets/" | ||||
|         expected_response_data = [ | ||||
|             { | ||||
|                 "name": "folder1", | ||||
|                 "path": "folder1", | ||||
|                 "type": "folder", | ||||
|                 "size": None, | ||||
|                 "url": "/mocked/url/to/resource", | ||||
|             }, | ||||
|             { | ||||
|                 "name": "file1.txt", | ||||
|                 "path": "file1.txt", | ||||
|                 "type": "file", | ||||
|                 "size": "100", | ||||
|                 "url": "/mocked/url/to/resource", | ||||
|             }, | ||||
|         ] | ||||
|  | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         assert response.data == expected_response_data | ||||
|  | ||||
|     def test_invalid_path(self, authenticated_client): | ||||
|         url = "/reporting/assets/?path=some/invalid/path" | ||||
|  | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_get_report_assets_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetAllAssets: | ||||
|     @patch("ee.reporting.views.os.walk") | ||||
|     def test_general_functionality(self, mock_os_walk, authenticated_client): | ||||
|         mock_os_walk.return_value = iter( | ||||
|             [(".", ["subdir"], ["file1.txt"]), ("./subdir", [], ["subdirfile.txt"])] | ||||
|         ) | ||||
|  | ||||
|         asset1 = baker.make("reporting.ReportAsset", file="file1.txt") | ||||
|         asset2 = baker.make("reporting.ReportAsset", file="subdir/subdirfile.txt") | ||||
|  | ||||
|         expected_data = [ | ||||
|             { | ||||
|                 "type": "folder", | ||||
|                 "name": "Report Assets", | ||||
|                 "path": ".", | ||||
|                 "children": [ | ||||
|                     { | ||||
|                         "type": "folder", | ||||
|                         "name": "subdir", | ||||
|                         "path": "subdir", | ||||
|                         "children": [ | ||||
|                             { | ||||
|                                 "id": asset2.id, | ||||
|                                 "type": "file", | ||||
|                                 "name": "subdirfile.txt", | ||||
|                                 "path": "subdir/subdirfile.txt", | ||||
|                                 "icon": "description", | ||||
|                             } | ||||
|                         ], | ||||
|                         "selectable": False, | ||||
|                         "icon": "folder", | ||||
|                         "iconColor": "yellow-9", | ||||
|                     }, | ||||
|                     { | ||||
|                         "id": asset1.id, | ||||
|                         "type": "file", | ||||
|                         "name": "file1.txt", | ||||
|                         "path": "file1.txt", | ||||
|                         "icon": "description", | ||||
|                     }, | ||||
|                 ], | ||||
|                 "selectable": False, | ||||
|                 "icon": "folder", | ||||
|                 "iconColor": "yellow-9", | ||||
|             } | ||||
|         ] | ||||
|  | ||||
|         response = authenticated_client.get("/reporting/assets/all/") | ||||
|         assert response.status_code == 200 | ||||
|         assert expected_data == response.data | ||||
|  | ||||
|     @patch("ee.reporting.views.os.chdir", side_effect=FileNotFoundError) | ||||
|     def test_invalid_report_assets_dir(self, mock_os_walk, authenticated_client): | ||||
|         response = authenticated_client.get("/reporting/assets/all/") | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     @patch("ee.reporting.views.os.walk") | ||||
|     def test_only_folders(self, mock_os_walk, authenticated_client): | ||||
|         mock_os_walk.return_value = iter( | ||||
|             [(".", ["subdir"], ["file1.txt"]), ("./subdir", [], ["subdirfile.txt"])] | ||||
|         ) | ||||
|  | ||||
|         baker.make("reporting.ReportAsset", file="file1.txt") | ||||
|         baker.make("reporting.ReportAsset", file="subdir/subdirfile.txt") | ||||
|  | ||||
|         response = authenticated_client.get("/reporting/assets/all/?onlyFolders=true") | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         for node in response.data: | ||||
|             assert node["type"] != "file" | ||||
|  | ||||
|     def test_unauthenticated_get_report_assets_all_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/all/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestRenameAsset: | ||||
|     def test_rename_file(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.rename", | ||||
|             return_value="path/to/newname.txt", | ||||
|         ) as mock_rename, patch( | ||||
|             "ee.reporting.views.report_assets_fs.isfile", return_value=True | ||||
|         ), patch( | ||||
|             "ee.reporting.views.report_assets_fs.exists", return_value=True | ||||
|         ): | ||||
|             asset = baker.make("reporting.ReportAsset", file="path/to/file.txt") | ||||
|  | ||||
|             response = authenticated_client.put( | ||||
|                 "/reporting/assets/rename/", | ||||
|                 data={"path": "path/to/file.txt", "newName": "newname.txt"}, | ||||
|             ) | ||||
|  | ||||
|             mock_rename.assert_called_with( | ||||
|                 path="path/to/file.txt", new_name="newname.txt" | ||||
|             ) | ||||
|             assert response.status_code == 200 | ||||
|             assert response.data == "path/to/newname.txt" | ||||
|  | ||||
|             asset.refresh_from_db() | ||||
|             assert asset.file.name == "path/to/newname.txt" | ||||
|  | ||||
|     def test_rename_folder(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.rename", | ||||
|             return_value="path/to/newfolder", | ||||
|         ) as mock_rename, patch( | ||||
|             "ee.reporting.views.report_assets_fs.isfile", return_value=False | ||||
|         ), patch( | ||||
|             "ee.reporting.views.report_assets_fs.exists", return_value=True | ||||
|         ): | ||||
|             response = authenticated_client.put( | ||||
|                 "/reporting/assets/rename/", | ||||
|                 data={"path": "path/to/folder", "newName": "newfolder"}, | ||||
|             ) | ||||
|  | ||||
|             mock_rename.assert_called_with(path="path/to/folder", new_name="newfolder") | ||||
|             assert response.status_code == 200 | ||||
|             assert response.data == "path/to/newfolder" | ||||
|  | ||||
|     def test_rename_non_existent_file(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.rename", | ||||
|             side_effect=OSError("File not found"), | ||||
|         ) as mock_rename, patch( | ||||
|             "ee.reporting.views.report_assets_fs.exists", return_value=True | ||||
|         ): | ||||
|             response = authenticated_client.put( | ||||
|                 "/reporting/assets/rename/", | ||||
|                 data={ | ||||
|                     "path": "non_existent_path/to/file.txt", | ||||
|                     "newName": "newname.txt", | ||||
|                 }, | ||||
|             ) | ||||
|  | ||||
|             mock_rename.assert_called_with( | ||||
|                 path="non_existent_path/to/file.txt", new_name="newname.txt" | ||||
|             ) | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_suspicious_operation(self, authenticated_client): | ||||
|         response = authenticated_client.put( | ||||
|             "/reporting/assets/rename/", | ||||
|             data={"path": "../outside/path", "newName": "newname.txt"}, | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_rename_report_asset_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/rename/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestNewFolder: | ||||
|     def test_create_folder_success(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.createfolder", | ||||
|             return_value="new/path/to/folder", | ||||
|         ) as mock_createfolder: | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/newfolder/", data={"path": "new/path/to/folder"} | ||||
|             ) | ||||
|  | ||||
|             mock_createfolder.assert_called_with(path="new/path/to/folder") | ||||
|             assert response.status_code == 200 | ||||
|             assert response.data == "new/path/to/folder" | ||||
|  | ||||
|     def test_create_folder_missing_path(self, authenticated_client): | ||||
|         response = authenticated_client.post("/reporting/assets/newfolder/") | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     def test_create_folder_os_error(self, authenticated_client): | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/assets/newfolder/", data={"path": "invalid/path"} | ||||
|         ) | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     def test_create_folder_suspicious_operation(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.createfolder", | ||||
|             side_effect=SuspiciousFileOperation("Invalid path"), | ||||
|         ) as mock_createfolder: | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/newfolder/", data={"path": "../outside/path"} | ||||
|             ) | ||||
|  | ||||
|             mock_createfolder.assert_called_with(path="../outside/path") | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_new_folder_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/newfolder/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestDeleteAssets: | ||||
|     def test_delete_directory_success(self, authenticated_client): | ||||
|         from ..settings import settings | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=True | ||||
|         ), patch("ee.reporting.views.shutil.rmtree") as mock_rmtree: | ||||
|             asset1 = baker.make("reporting.ReportAsset", file="path/to/dir/file1.txt") | ||||
|             asset2 = baker.make("reporting.ReportAsset", file="path/to/dir/file2.txt") | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/delete/", data={"paths": ["path/to/dir"]} | ||||
|             ) | ||||
|  | ||||
|             mock_rmtree.assert_called_with( | ||||
|                 f"{settings.REPORTING_ASSETS_BASE_PATH}/path/to/dir" | ||||
|             ) | ||||
|             assert response.status_code == 200 | ||||
|  | ||||
|             # make sure the assets within the deleted folder also got deleted | ||||
|             assert not ReportAsset.objects.filter(id=asset1.id).exists() | ||||
|             assert not ReportAsset.objects.filter(id=asset2.id).exists() | ||||
|  | ||||
|     def test_delete_file_success(self, authenticated_client): | ||||
|         with patch("ee.reporting.views.report_assets_fs.isdir", return_value=False): | ||||
|             asset = baker.make("reporting.ReportAsset", file="path/to/file.txt") | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/delete/", data={"paths": ["path/to/file.txt"]} | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 200 | ||||
|             assert not ReportAsset.objects.filter(id=asset.id).exists() | ||||
|  | ||||
|     def test_delete_os_error(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=True | ||||
|         ), patch( | ||||
|             "ee.reporting.views.shutil.rmtree", side_effect=OSError("Unable to delete") | ||||
|         ): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/delete/", data={"paths": ["invalid/path"]} | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_delete_suspicious_operation(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=True | ||||
|         ), patch( | ||||
|             "ee.reporting.views.shutil.rmtree", | ||||
|             side_effect=SuspiciousFileOperation("Invalid path"), | ||||
|         ): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/delete/", data={"paths": ["../outside/path"]} | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_delete_asset_not_in_db_but_on_fs(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=False | ||||
|         ), patch("ee.reporting.views.report_assets_fs.delete") as mock_fs_delete: | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/delete/", data={"paths": ["path/to/file.txt"]} | ||||
|             ) | ||||
|  | ||||
|             mock_fs_delete.assert_called_with("path/to/file.txt") | ||||
|             assert response.status_code == 200 | ||||
|  | ||||
|     def test_unauthenticated_delete_assets_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/delete/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestUploadAssets: | ||||
|     def test_upload_success(self, authenticated_client): | ||||
|         mock_file = SimpleUploadedFile("test123.txt", b"file_content") | ||||
|  | ||||
|         with patch("ee.reporting.views.report_assets_fs.isdir", return_value=True): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/upload/", | ||||
|                 data={"parentPath": "path", "test123.txt": mock_file}, | ||||
|             ) | ||||
|  | ||||
|             assert response.data["test123.txt"]["filename"] == "path/test123.txt" | ||||
|             assert response.status_code == 200 | ||||
|  | ||||
|             assert ReportAsset.objects.filter(file="path/test123.txt").exists() | ||||
|  | ||||
|             # cleanup file so future tests don't break | ||||
|             asset = ReportAsset.objects.get(file="path/test123.txt") | ||||
|             asset.file.delete() | ||||
|             asset.delete() | ||||
|  | ||||
|     def test_upload_invalid_directory(self, authenticated_client): | ||||
|         with patch("ee.reporting.views.report_assets_fs.isdir", return_value=False): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/upload/", | ||||
|                 data={"parentPath": "invalid_path"}, | ||||
|             ) | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_upload_suspicious_operation(self, authenticated_client): | ||||
|         mock_file = SimpleUploadedFile("test2.txt", b"file_content") | ||||
|  | ||||
|         with patch("ee.reporting.views.report_assets_fs.isdir", return_value=True): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/assets/upload/", | ||||
|                 data={"parentPath": "../path", "test2.txt": mock_file}, | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_uploads_assets_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/upload/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestAssetDownload: | ||||
|     def test_download_file_success(self, authenticated_client): | ||||
|         m = mock_open() | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=False | ||||
|         ), patch("builtins.open", m), patch( | ||||
|             "ee.reporting.views.report_assets_fs.path", return_value="path/test.txt" | ||||
|         ): | ||||
|             response = authenticated_client.get( | ||||
|                 "/reporting/assets/download/", {"path": "test.txt"} | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 200 | ||||
|  | ||||
|     def test_download_directory_success(self, authenticated_client): | ||||
|         m = mock_open() | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", return_value=True | ||||
|         ), patch( | ||||
|             "ee.reporting.views.shutil.make_archive", return_value="path/test.zip" | ||||
|         ), patch( | ||||
|             "builtins.open", m | ||||
|         ), patch( | ||||
|             "ee.reporting.views.report_assets_fs.path", return_value="path/test" | ||||
|         ), patch( | ||||
|             "ee.reporting.views.os.remove", return_value=None | ||||
|         ): | ||||
|             response = authenticated_client.get( | ||||
|                 "/reporting/assets/download/", {"path": "test"} | ||||
|             ) | ||||
|  | ||||
|             assert response.status_code == 200 | ||||
|             m.assert_called_once_with("path/test.zip", "rb") | ||||
|  | ||||
|     def test_download_invalid_path(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", | ||||
|             side_effect=OSError("Path does not exist"), | ||||
|         ): | ||||
|             response = authenticated_client.get( | ||||
|                 "/reporting/assets/download/", {"path": "invalid_path"} | ||||
|             ) | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_download_os_error(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", | ||||
|             side_effect=OSError("Download failed"), | ||||
|         ): | ||||
|             response = authenticated_client.get( | ||||
|                 "/reporting/assets/download/", {"path": "test.txt"} | ||||
|             ) | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_download_suspicious_operation(self, authenticated_client): | ||||
|         with patch( | ||||
|             "ee.reporting.views.report_assets_fs.isdir", | ||||
|             side_effect=SuspiciousFileOperation("Suspicious path"), | ||||
|         ): | ||||
|             response = authenticated_client.get( | ||||
|                 "/reporting/assets/download/", {"path": "test.txt"} | ||||
|             ) | ||||
|             assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_uploads_assets_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/assets/download/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestAssetNginxRedirect: | ||||
|     @pytest.fixture | ||||
|     def asset(self): | ||||
|         return baker.make("reporting.ReportAsset", file="test_asset.txt") | ||||
|  | ||||
|     def test_valid_uuid_and_path(self, unauthenticated_client, asset): | ||||
|         response = unauthenticated_client.get( | ||||
|             f"/reporting/assets/test_asset.txt?id={asset.id}" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         assert response["X-Accel-Redirect"] == "/assets/test_asset.txt" | ||||
|  | ||||
|     def test_valid_uuid_wrong_path(self, unauthenticated_client, asset): | ||||
|         response = unauthenticated_client.get( | ||||
|             f"/reporting/assets/wrong_path.txt?id={asset.id}" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == 403 | ||||
|  | ||||
|     def test_valid_uuid_no_asset(self, unauthenticated_client): | ||||
|         non_existent_uuid = uuid.uuid4() | ||||
|         url = f"/reporting/assets/test_asset.txt?id={non_existent_uuid}" | ||||
|         response = unauthenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == 404 | ||||
|  | ||||
|     def test_invalid_uuid(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.get( | ||||
|             "/reporting/assets/test_asset.txt?id=invalid_uuid" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|         assert "There was a error processing the request" in response.content.decode( | ||||
|             "utf-8" | ||||
|         ) | ||||
|  | ||||
|     def test_no_id(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.get("/reporting/assets/test_asset.txt") | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|         assert "There was a error processing the request" in response.content.decode( | ||||
|             "utf-8" | ||||
|         ) | ||||
							
								
								
									
										374
									
								
								api/tacticalrmm/ee/reporting/tests/test_report_template_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										374
									
								
								api/tacticalrmm/ee/reporting/tests/test_report_template_views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,374 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from unittest.mock import patch | ||||
|  | ||||
| import pytest | ||||
| from jinja2.exceptions import TemplateError | ||||
| from model_bakery import baker | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APIClient | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def authenticated_client(): | ||||
|     client = APIClient() | ||||
|     user = baker.make("accounts.User", is_superuser=True) | ||||
|     client.force_authenticate(user=user) | ||||
|     return client | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def unauthenticated_client(): | ||||
|     return APIClient() | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def report_template(): | ||||
|     return baker.make("reporting.ReportTemplate") | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestReportTemplateViews: | ||||
|     def test_get_all_report_templates_empty_db(self, authenticated_client): | ||||
|         response = authenticated_client.get("/reporting/templates/") | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert len(response.data) == 0 | ||||
|  | ||||
|     def test_get_all_report_templates(self, authenticated_client): | ||||
|         # Create some sample ReportTemplates using model_bakery | ||||
|         baker.make("reporting.ReportTemplate", _quantity=5) | ||||
|         response = authenticated_client.get("/reporting/templates/") | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert len(response.data) == 5 | ||||
|  | ||||
|     def test_get_report_templates_with_filter(self, authenticated_client): | ||||
|         # Create templates with specific dependencies | ||||
|         baker.make("reporting.ReportTemplate", depends_on=["agent"], _quantity=3) | ||||
|         baker.make("reporting.ReportTemplate", depends_on=["client"], _quantity=2) | ||||
|  | ||||
|         response = authenticated_client.get( | ||||
|             "/reporting/templates/", {"dependsOn[]": ["agent"]} | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert len(response.data) == 3 | ||||
|  | ||||
|     def test_post_report_template_valid_data(self, authenticated_client): | ||||
|         valid_data = {"name": "Test Template", "template_md": "Template Text"} | ||||
|         response = authenticated_client.post("/reporting/templates/", valid_data) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data["name"] == "Test Template" | ||||
|  | ||||
|     def test_post_report_template_invalid_data(self, authenticated_client): | ||||
|         invalid_data = {"name": "Test Template"} | ||||
|         response = authenticated_client.post("/reporting/templates/", invalid_data) | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_get_report_template(self, authenticated_client, report_template): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         response = authenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response.data["id"] == report_template.id | ||||
|  | ||||
|     def test_edit_report_template(self, authenticated_client, report_template): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         updated_name = "Updated name" | ||||
|  | ||||
|         response = authenticated_client.put(url, {"name": updated_name}, format="json") | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         report_template.refresh_from_db() | ||||
|         assert report_template.name == updated_name | ||||
|  | ||||
|     def test_delete_report_template(self, authenticated_client, report_template): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         response = authenticated_client.delete(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         with pytest.raises(report_template.DoesNotExist): | ||||
|             report_template.refresh_from_db() | ||||
|  | ||||
|     # test unauthorized access | ||||
|     def test_unauthorized_get_report_templates_view(self, unauthenticated_client): | ||||
|         url = "/reporting/templates/" | ||||
|         response = unauthenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthorized_add_report_template_view(self, unauthenticated_client): | ||||
|         url = "/reporting/templates/" | ||||
|         response = unauthenticated_client.post(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthorized_get_report_template_view( | ||||
|         self, unauthenticated_client, report_template | ||||
|     ): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         response = unauthenticated_client.get(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthorized_edit_report_template_view( | ||||
|         self, unauthenticated_client, report_template | ||||
|     ): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         updated_name = "Updated name" | ||||
|  | ||||
|         response = unauthenticated_client.put( | ||||
|             url, {"name": updated_name}, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|     def test_unauthorized_delete_report_template_view( | ||||
|         self, unauthenticated_client, report_template | ||||
|     ): | ||||
|         url = f"/reporting/templates/{report_template.pk}/" | ||||
|         response = unauthenticated_client.delete(url) | ||||
|  | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestReportTemplateGenerateView: | ||||
|     def test_generate_html_report(self, authenticated_client, report_template): | ||||
|         data = {"format": "html", "dependencies": {}} | ||||
|         response = authenticated_client.post( | ||||
|             f"/reporting/templates/{report_template.id}/run/", data=data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert report_template.template_md in response.data | ||||
|  | ||||
|     def test_generate_pdf_report(self, authenticated_client, report_template): | ||||
|         data = {"format": "pdf", "dependencies": {}} | ||||
|         response = authenticated_client.post( | ||||
|             f"/reporting/templates/{report_template.id}/run/", data=data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response["content-type"] == "application/pdf" | ||||
|  | ||||
|     def test_generate_invalid_format_report( | ||||
|         self, authenticated_client, report_template | ||||
|     ): | ||||
|         data = {"format": "invalid_format", "dependencies": {}} | ||||
|         response = authenticated_client.post( | ||||
|             f"/reporting/templates/{report_template.id}/run/", data=data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_generate_report_template_error(self, authenticated_client): | ||||
|         template = baker.make("reporting.ReportTemplate", template_md="{{invalid}") | ||||
|         data = {"format": "html", "dependencies": {}} | ||||
|         response = authenticated_client.post( | ||||
|             f"/reporting/templates/{template.id}/run/", data=data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_generate_report_with_dependencies( | ||||
|         self, authenticated_client, report_template | ||||
|     ): | ||||
|         sample_html = "<html><body>Sample Report</body></html>" | ||||
|         data = {"format": "html", "dependencies": {"client": 1}} | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.generate_html", return_value=(sample_html, None) | ||||
|         ) as mock_generate_html: | ||||
|             url = f"/reporting/templates/{report_template.id}/run/" | ||||
|             response = authenticated_client.post(url, data, format="json") | ||||
|  | ||||
|             assert response.status_code == status.HTTP_200_OK | ||||
|             assert response.data == sample_html | ||||
|  | ||||
|         mock_generate_html.assert_called_with( | ||||
|             template=report_template.template_md, | ||||
|             template_type=report_template.type, | ||||
|             css=report_template.template_css if report_template.template_css else "", | ||||
|             html_template=report_template.template_html.id | ||||
|             if report_template.template_html | ||||
|             else None, | ||||
|             variables=report_template.template_variables, | ||||
|             dependencies={"client": 1}, | ||||
|         ) | ||||
|  | ||||
|     def test_unauthenticated_generate_report_view( | ||||
|         self, unauthenticated_client, report_template | ||||
|     ): | ||||
|         response = unauthenticated_client.post( | ||||
|             f"/reporting/templates/{report_template.id}/run/" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGenerateReportPreview: | ||||
|     def test_generate_report_preview_html_format(self, authenticated_client): | ||||
|         data = { | ||||
|             "template_md": "some template md", | ||||
|             "type": "some type", | ||||
|             "template_css": "some css", | ||||
|             "template_variables": {}, | ||||
|             "dependencies": {}, | ||||
|             "format": "html", | ||||
|             "debug": False, | ||||
|         } | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.generate_html", return_value=("<html></html>", {}) | ||||
|         ) as mock_generate_html: | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/templates/preview/", data, format="json" | ||||
|             ) | ||||
|             assert response.status_code == status.HTTP_200_OK | ||||
|             assert response.data == "<html></html>" | ||||
|         mock_generate_html.assert_called() | ||||
|  | ||||
|     @patch("ee.reporting.views.generate_html", return_value=("<html></html>", {})) | ||||
|     @patch("ee.reporting.views.generate_pdf", return_value=b"some_pdf_bytes") | ||||
|     def test_generate_report_preview_pdf_format( | ||||
|         self, mock_generate_html, mock_generate_pdf, authenticated_client | ||||
|     ): | ||||
|         data = { | ||||
|             "template_md": "some template md", | ||||
|             "type": "some type", | ||||
|             "template_css": "some css", | ||||
|             "template_variables": {}, | ||||
|             "dependencies": {}, | ||||
|             "format": "pdf", | ||||
|             "debug": False, | ||||
|         } | ||||
|  | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/templates/preview/", data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_200_OK | ||||
|         assert response["Content-Type"] == "application/pdf" | ||||
|         mock_generate_html.assert_called() | ||||
|         mock_generate_pdf.assert_called() | ||||
|  | ||||
|     def test_generate_report_preview_debug(self, authenticated_client): | ||||
|         data = { | ||||
|             "template_md": "some template md", | ||||
|             "type": "markdown", | ||||
|             "template_css": "some css", | ||||
|             "template_variables": {}, | ||||
|             "dependencies": {}, | ||||
|             "format": "html", | ||||
|             "debug": True, | ||||
|         } | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.generate_html", | ||||
|             return_value=("<html></html>", {"agent": baker.prepare("agents.Agent")}), | ||||
|         ) as mock_generate_html: | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/templates/preview/", data, format="json" | ||||
|             ) | ||||
|             assert response.status_code == status.HTTP_200_OK | ||||
|             assert "template" in response.data | ||||
|             assert "variables" in response.data | ||||
|         mock_generate_html.assert_called() | ||||
|  | ||||
|     def test_generate_report_preview_invalid_data(self, authenticated_client): | ||||
|         data = { | ||||
|             "template_md": "some template md", | ||||
|             # Missing 'type' | ||||
|             "template_css": "some css", | ||||
|             "template_variables": {}, | ||||
|             "dependencies": {}, | ||||
|             "format": "invalid_format", | ||||
|             "debug": True, | ||||
|         } | ||||
|  | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/templates/preview/", data, format="json" | ||||
|         ) | ||||
|         assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|  | ||||
|     def test_generate_report_preview_template_error(self, authenticated_client): | ||||
|         data = { | ||||
|             "template_md": "some template md", | ||||
|             "type": "some type", | ||||
|             "template_css": "some css", | ||||
|             "template_variables": {}, | ||||
|             "dependencies": {}, | ||||
|             "format": "html", | ||||
|             "debug": True, | ||||
|         } | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.generate_html", | ||||
|             side_effect=TemplateError("Some template error"), | ||||
|         ): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/templates/preview/", data, format="json" | ||||
|             ) | ||||
|             assert response.status_code == status.HTTP_400_BAD_REQUEST | ||||
|             assert "Some template error" in response.data | ||||
|  | ||||
|     def test_unauthenticated_generate_report_preview_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/templates/preview/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGetAllowedValues: | ||||
|     def test_valid_input(self, authenticated_client): | ||||
|         data = { | ||||
|             "variables": { | ||||
|                 "user": { | ||||
|                     "name": "Alice", | ||||
|                     "roles": ["admin", "user"], | ||||
|                 }, | ||||
|                 "count": 1, | ||||
|             }, | ||||
|             "dependencies": {}, | ||||
|         } | ||||
|  | ||||
|         expected_response_data = { | ||||
|             "user": "Object", | ||||
|             "user.name": "str", | ||||
|             "user.roles": "Array (2 Results)", | ||||
|             "user.roles[0]": "str", | ||||
|             "count": "int", | ||||
|         } | ||||
|  | ||||
|         with patch( | ||||
|             "ee.reporting.views.prep_variables_for_template", | ||||
|             return_value=data["variables"], | ||||
|         ): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/templates/preview/analysis/", data, format="json" | ||||
|             ) | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         assert response.data == expected_response_data | ||||
|  | ||||
|     def test_empty_variables(self, authenticated_client): | ||||
|         data = {"variables": {}, "dependencies": {}} | ||||
|         with patch( | ||||
|             "ee.reporting.views.prep_variables_for_template", | ||||
|             return_value=data["variables"], | ||||
|         ): | ||||
|             response = authenticated_client.post( | ||||
|                 "/reporting/templates/preview/analysis/", data, format="json" | ||||
|             ) | ||||
|  | ||||
|         assert response.status_code == 200 | ||||
|         assert response.data is None | ||||
|  | ||||
|     def test_invalid_input(self, authenticated_client): | ||||
|         data = {"invalidKey": {}} | ||||
|  | ||||
|         response = authenticated_client.post( | ||||
|             "/reporting/templates/preview/analysis/", data, format="json" | ||||
|         ) | ||||
|  | ||||
|         assert response.status_code == 400 | ||||
|  | ||||
|     def test_unauthenticated_variable_analysis_view(self, unauthenticated_client): | ||||
|         response = unauthenticated_client.post("/reporting/templates/preview/analysis/") | ||||
|         assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||||
							
								
								
									
										268
									
								
								api/tacticalrmm/ee/reporting/tests/test_storage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								api/tacticalrmm/ee/reporting/tests/test_storage.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from pathlib import Path | ||||
|  | ||||
| import pytest | ||||
| from django.core.exceptions import SuspiciousFileOperation | ||||
|  | ||||
| from ..storage import ReportAssetStorage | ||||
|  | ||||
|  | ||||
| def test_is_file(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp" | ||||
|     file = tmp_path / "file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|     assert not storage.isfile(path="temp") | ||||
|     assert storage.isfile(path="file") | ||||
|  | ||||
|  | ||||
| def test_is_file_wrong_path(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     assert not storage.isfile(path="doesntexist") | ||||
|  | ||||
|  | ||||
| def test_is_dir(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp" | ||||
|     file = tmp_path / "file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|     assert storage.isdir(path="temp") | ||||
|     assert not storage.isdir(path="file") | ||||
|  | ||||
|  | ||||
| def test_is_dir_wrong_path(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     assert not storage.isdir(path="doesntexist") | ||||
|  | ||||
|  | ||||
| def test_get_full_dir(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp" | ||||
|     file = tmp_path / "temp/file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|  | ||||
|     assert storage.getfulldir(path="temp") == str(tmp_path) | ||||
|     assert storage.getfulldir(path="temp/file") == str(tmp_path / "temp") | ||||
|  | ||||
|  | ||||
| def test_full_dir_directory_traversal(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # test with relative path | ||||
|         storage.getfulldir(path="../..") | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # test with absolute path | ||||
|         storage.getfulldir(path="/etc") | ||||
|  | ||||
|  | ||||
| def test_get_rel_dir(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp"  # {tmp_path}/temp -> '' | ||||
|     file = tmp_path / "temp/file"  # {tmp_path}/temp/file -> 'temp' | ||||
|     nested_dir = dir / "nested"  # {tmp_path}/temp/nested -> 'temp' | ||||
|     nested_dir2 = ( | ||||
|         nested_dir / "nested" | ||||
|     )  # {tmp_path}/temp/nested/nested -> 'temp/nested' | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|     nested_dir.mkdir() | ||||
|     nested_dir2.mkdir() | ||||
|  | ||||
|     assert storage.getreldir(path="temp") == "" | ||||
|     assert storage.getreldir(path="temp/file") == "temp" | ||||
|     assert storage.getreldir(path="temp/nested") == "temp" | ||||
|     assert storage.getreldir(path="temp/nested/nested") == "temp/nested" | ||||
|  | ||||
|  | ||||
| def test_rename_file(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     file = tmp_path / "file" | ||||
|  | ||||
|     file.touch() | ||||
|  | ||||
|     new_path = storage.rename(path="file", new_name="newfilename") | ||||
|  | ||||
|     assert new_path == "newfilename" | ||||
|  | ||||
|  | ||||
| def test_rename_directory(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp" | ||||
|     dir2 = tmp_path / "temp2" | ||||
|     dir3 = dir2 / "nested" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     dir2.mkdir() | ||||
|     dir3.mkdir() | ||||
|  | ||||
|     new_path = storage.rename(path="temp", new_name="newfoldername") | ||||
|     new_path_nested = storage.rename(path="temp2/nested", new_name="newfoldername") | ||||
|  | ||||
|     assert new_path == "newfoldername" | ||||
|     assert new_path_nested == "temp2/newfoldername" | ||||
|  | ||||
|  | ||||
| def test_rename_with_conflicting_path(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|     dir2 = tmp_path / "temp2" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     dir2.mkdir() | ||||
|  | ||||
|     new_path = storage.rename(path="temp2", new_name="temp") | ||||
|  | ||||
|     assert new_path != "temp" | ||||
|     assert new_path.startswith("temp_") | ||||
|  | ||||
|  | ||||
| def test_rename_with_invalid_path(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     with pytest.raises(FileNotFoundError): | ||||
|         storage.rename(path="path", new_name="doesntexist") | ||||
|  | ||||
|  | ||||
| def test_rename_denies_directory_traversal(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # relative | ||||
|         storage.rename(path="../../etc", new_name="doesntexist") | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # absolute | ||||
|         storage.rename(path="/etc", new_name="doesntexist") | ||||
|  | ||||
|  | ||||
| def test_create_folder(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|     dir = tmp_path / "temp" | ||||
|     dir_nested = dir / "nested" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     dir_nested.mkdir() | ||||
|  | ||||
|     new_path = storage.createfolder(path="temp/newfoldername") | ||||
|     new_path_nested = storage.createfolder(path="temp/nested/newfoldername") | ||||
|  | ||||
|     assert new_path == "temp/newfoldername" | ||||
|     assert new_path_nested == "temp/nested/newfoldername" | ||||
|  | ||||
|  | ||||
| def test_create_folder_with_conflicting_name(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|  | ||||
|     dir.mkdir() | ||||
|  | ||||
|     new_path = storage.createfolder(path="temp") | ||||
|  | ||||
|     assert new_path != "temp" | ||||
|     assert new_path.startswith("temp_") | ||||
|  | ||||
|  | ||||
| def test_create_folder_directory_traversal(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # relative | ||||
|         storage.createfolder(path="../../etc") | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # absolute | ||||
|         storage.createfolder(path="/etc") | ||||
|  | ||||
|  | ||||
| def test_move_folder(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|     file = dir / "file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|  | ||||
|     new_path = storage.move(source="temp", destination="dest") | ||||
|  | ||||
|     assert new_path == "dest/temp" | ||||
|     assert storage.exists("dest/temp/file") | ||||
|  | ||||
|  | ||||
| def test_move_file(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|     file = dir / "file" | ||||
|     dest = tmp_path / "dest" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|     dest.mkdir() | ||||
|  | ||||
|     new_path = storage.move(source="temp/file", destination="dest") | ||||
|  | ||||
|     assert new_path == "dest/file" | ||||
|  | ||||
|  | ||||
| def test_move_file_with_file_conflict(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|     file = dir / "file" | ||||
|  | ||||
|     dest_dir = tmp_path / "dest" | ||||
|     dest_file = dest_dir / "file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|     dest_dir.mkdir() | ||||
|     dest_file.mkdir() | ||||
|  | ||||
|     new_path = storage.move(source="temp/file", destination="dest") | ||||
|  | ||||
|     assert new_path != "dest/file" | ||||
|     assert new_path.startswith("dest/file_") | ||||
|  | ||||
|  | ||||
| def test_move_folder_with_name_conflict(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     dir = tmp_path / "temp" | ||||
|     file = dir / "file" | ||||
|  | ||||
|     dir.mkdir() | ||||
|     file.touch() | ||||
|  | ||||
|     new_path = storage.move(source="temp", destination="") | ||||
|  | ||||
|     assert new_path != "temp" | ||||
|     assert new_path.startswith("temp_") | ||||
|  | ||||
|  | ||||
| def test_move_directory_traversal(tmp_path: Path) -> None: | ||||
|     storage = ReportAssetStorage(location=tmp_path) | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # relative | ||||
|         storage.move(source="../../file", destination="../..") | ||||
|  | ||||
|     with pytest.raises(SuspiciousFileOperation): | ||||
|         # absolute | ||||
|         storage.move(source="/etc", destination="/newpath") | ||||
							
								
								
									
										113
									
								
								api/tacticalrmm/ee/reporting/tests/test_template_generation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								api/tacticalrmm/ee/reporting/tests/test_template_generation.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
|  | ||||
| from ..utils import db_template_loader, generate_html | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestGenerateHTML: | ||||
|     @pytest.fixture | ||||
|     def base_template(self): | ||||
|         html = "<html>{% block content %}{% endblock %}</html>" | ||||
|         return baker.make( | ||||
|             "reporting.ReportHTMLTemplate", name="Base Template", html=html | ||||
|         ) | ||||
|  | ||||
|     def test_markdown_conversion(self): | ||||
|         template = "# This is a header" | ||||
|         result, _ = generate_html( | ||||
|             template=template, template_type="markdown", css="", variables="" | ||||
|         ) | ||||
|         assert "<h1>This is a header</h1>" in result | ||||
|  | ||||
|     def test_html_unchanged(self): | ||||
|         template = "<h1>This is a header</h1>" | ||||
|         result, _ = generate_html( | ||||
|             template=template, template_type="html", css="", variables="" | ||||
|         ) | ||||
|         assert "<h1>This is a header</h1>" in result | ||||
|  | ||||
|     def test_html_template_exists(self, base_template): | ||||
|         template = "{% block content %}<h1>This is a header</h1>{% endblock %}" | ||||
|         result, _ = generate_html( | ||||
|             template=template, | ||||
|             template_type="html", | ||||
|             html_template=base_template.id, | ||||
|             css="", | ||||
|             variables="", | ||||
|         ) | ||||
|  | ||||
|         assert "<html><h1>This is a header</h1></html>" == result | ||||
|  | ||||
|     def test_html_template_does_not_exist(self): | ||||
|         template = "<h1>This is a header</h1>" | ||||
|         # check it doesn't raise an error. | ||||
|         generate_html( | ||||
|             template=template, | ||||
|             template_type="html", | ||||
|             html_template=999, | ||||
|             css="", | ||||
|             variables="", | ||||
|         ) | ||||
|  | ||||
|     def test_variables_processing(self): | ||||
|         template = "Hello {{ name }}" | ||||
|         variables = "name: John" | ||||
|         result, _ = generate_html( | ||||
|             template=template, template_type="html", css="", variables=variables | ||||
|         ) | ||||
|  | ||||
|         assert "Hello John" in result | ||||
|  | ||||
|     def test_css_incorporation(self): | ||||
|         template = "<html><head><style>{{css}}</style></head></html>" | ||||
|         css = ".my-class { color: red; }" | ||||
|         result, _ = generate_html( | ||||
|             template=template, template_type="html", css=css, variables="" | ||||
|         ) | ||||
|         assert css in result | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestJinjaDBLoader: | ||||
|     @pytest.fixture | ||||
|     def report_base_template(self): | ||||
|         return baker.make( | ||||
|             "reporting.ReportHTMLTemplate", name="test_html_template", html="Test HTML" | ||||
|         ) | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def report_template(self): | ||||
|         return baker.make( | ||||
|             "reporting.ReportTemplate", name="test_md_template", template_md="Test MD" | ||||
|         ) | ||||
|  | ||||
|     def test_load_base_template(self, report_base_template): | ||||
|         result = db_template_loader(report_base_template.name) | ||||
|         assert result == "Test HTML" | ||||
|  | ||||
|     def test_fallback_to_md_template(self, report_template): | ||||
|         result = db_template_loader(report_template.name) | ||||
|         assert result == "Test MD" | ||||
|  | ||||
|     def test_no_template_found(self): | ||||
|         # Will return None | ||||
|         result = db_template_loader("nonexistent_template") | ||||
|         assert result is None | ||||
|  | ||||
|     def test_html_template_priority(self): | ||||
|         # Create both a ReportHTMLTemplate and a ReportTemplate with the same name | ||||
|         template_name = "common_template" | ||||
|         baker.make("reporting.ReportHTMLTemplate", name=template_name, html="Test HTML") | ||||
|         baker.make( | ||||
|             "reporting.ReportTemplate", name=template_name, template_md="Test MD" | ||||
|         ) | ||||
|  | ||||
|         result = db_template_loader(template_name) | ||||
|         assert result == "Test HTML"  # HTML has priority | ||||
							
								
								
									
										299
									
								
								api/tacticalrmm/ee/reporting/tests/test_template_variables.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								api/tacticalrmm/ee/reporting/tests/test_template_variables.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,299 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from unittest.mock import patch | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
|  | ||||
| from ..utils import ( | ||||
|     prep_variables_for_template, | ||||
|     process_chart_variables, | ||||
|     process_data_sources, | ||||
|     process_dependencies, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestProcessingDependencies: | ||||
|     # Fixtures for creating model instances | ||||
|     @pytest.fixture | ||||
|     def test_client(db): | ||||
|         return baker.make("clients.Client", name="Test Client Name") | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def test_site(db): | ||||
|         return baker.make("clients.Site", name="Test Site Name") | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def test_agent(db): | ||||
|         return baker.make( | ||||
|             "agents.Agent", agent_id="Test Agent ID", hostname="Test Agent Name" | ||||
|         ) | ||||
|  | ||||
|     @pytest.fixture | ||||
|     def test_global(db): | ||||
|         return baker.make( | ||||
|             "core.GlobalKVStore", name="some_global_value", value="Some Global Value" | ||||
|         ) | ||||
|  | ||||
|     def test_replace_with_client_db_value(self, test_client): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             client_field: {{client.name}} | ||||
|         """ | ||||
|         dependencies = {"client": test_client.id} | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["client_field"] == test_client.name | ||||
|  | ||||
|     def test_replace_with_site_db_value(self, test_site): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             site_field: {{site.name}} | ||||
|         """ | ||||
|         dependencies = {"site": test_site.id} | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["site_field"] == test_site.name | ||||
|  | ||||
|     def test_replace_with_agent_db_value(self, test_agent): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             agent_field: {{agent.hostname}} | ||||
|         """ | ||||
|         dependencies = {"agent": test_agent.agent_id} | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["agent_field"] == test_agent.hostname | ||||
|  | ||||
|     def test_replace_with_global_value(self, test_global): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             global_field: {{global.some_global_value}} | ||||
|         """ | ||||
|         result = process_dependencies(variables=variables, dependencies={}) | ||||
|         # Assuming you have a global value with key 'some_global_value' set to 'Some Global Value' | ||||
|         assert result["some_field"]["global_field"] == test_global.value | ||||
|  | ||||
|     def test_replace_with_non_db_dependencies(self): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             dependency_field: {{some_dependency}} | ||||
|         """ | ||||
|         dependencies = {"some_dependency": "Some Value"} | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["dependency_field"] == "Some Value" | ||||
|  | ||||
|     def test_missing_non_db_dependencies(self): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             missing_dependency: "{{missing_dependency}}" | ||||
|         """ | ||||
|         dependencies = {}  # Empty dependencies, simulating a missing dependency | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["missing_dependency"] == "{{missing_dependency}}" | ||||
|  | ||||
|     def test_variables_dependencies_merge(self, test_agent): | ||||
|         variables = """ | ||||
|         some_field: | ||||
|             agent_field: {{agent.agent_id}} | ||||
|             dependency_field: {{some_dependency}} | ||||
|         """ | ||||
|         dependencies = {"agent": test_agent.agent_id, "some_dependency": "Some Value"} | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|         assert result["some_field"]["agent_field"] == "Test Agent ID" | ||||
|         assert result["some_field"]["dependency_field"] == "Some Value" | ||||
|         # Additionally, assert the merged structure has both processed variables and dependencies | ||||
|         assert "agent" in result | ||||
|         assert isinstance(result["agent"], type(test_agent)) | ||||
|         assert "some_dependency" in result | ||||
|         assert result["some_dependency"] == "Some Value" | ||||
|  | ||||
|     def test_multiple_replacements(self, test_agent, test_client, test_site): | ||||
|         variables = """ | ||||
|         fields: | ||||
|             agent_name: {{agent.hostname}} | ||||
|             client_name: {{client.name}} | ||||
|             site_name: {{site.name}} | ||||
|             dependency_1: {{dep_1}} | ||||
|             dependency_2: {{dep_2}} | ||||
|         """ | ||||
|  | ||||
|         dependencies = { | ||||
|             "agent": test_agent.agent_id, | ||||
|             "client": test_client.id, | ||||
|             "site": test_site.id, | ||||
|             "dep_1": "Dependency Value 1", | ||||
|             "dep_2": "Dependency Value 2", | ||||
|         } | ||||
|  | ||||
|         result = process_dependencies(variables=variables, dependencies=dependencies) | ||||
|  | ||||
|         assert result["fields"]["agent_name"] == test_agent.hostname | ||||
|         assert result["fields"]["client_name"] == test_client.name | ||||
|         assert result["fields"]["site_name"] == test_site.name | ||||
|         assert result["fields"]["dependency_1"] == "Dependency Value 1" | ||||
|         assert result["fields"]["dependency_2"] == "Dependency Value 2" | ||||
|  | ||||
|         # Also verify that the non-replaced fields from dependencies are present in the result. | ||||
|         assert "agent" in result | ||||
|         assert "client" in result | ||||
|         assert "site" in result | ||||
|         assert result["dep_1"] == "Dependency Value 1" | ||||
|         assert result["dep_2"] == "Dependency Value 2" | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestProcessDataSourceVariables: | ||||
|     def test_process_data_sources_without_data_sources(self): | ||||
|         variables = {"other_key": "some_value"} | ||||
|         result = process_data_sources(variables=variables) | ||||
|         assert result == variables | ||||
|  | ||||
|     def test_process_data_sources_with_non_dict_data_sources(self): | ||||
|         variables = {"data_sources": "some_string_value"} | ||||
|         result = process_data_sources(variables=variables) | ||||
|         assert result == variables | ||||
|  | ||||
|     def test_process_data_sources_with_dict_data_sources(self): | ||||
|         variables = { | ||||
|             "data_sources": { | ||||
|                 "source1": {"model": "agent", "other_field": "value"}, | ||||
|                 "source2": "some_string_value", | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         mock_queryset = {"data": "sample_data"} | ||||
|  | ||||
|         # Mock build_queryset to return the mock_queryset | ||||
|         with patch("ee.reporting.utils.build_queryset", return_value=mock_queryset): | ||||
|             result = process_data_sources(variables=variables) | ||||
|  | ||||
|             # Assert that the data_sources for "source1" is replaced with mock_queryset | ||||
|             assert result["data_sources"]["source1"] == mock_queryset | ||||
|  | ||||
|             # Assert that the "source2" data remains unchanged | ||||
|             assert result["data_sources"]["source2"] == "some_string_value" | ||||
|  | ||||
|  | ||||
| class TestProcessChartVariables: | ||||
|     def test_process_chart_no_replace_data_frame(self): | ||||
|         # Scenario where path doesn't exist in variables | ||||
|         variables = { | ||||
|             "charts": { | ||||
|                 "chart1": { | ||||
|                     "chartType": "type1", | ||||
|                     "outputType": "html", | ||||
|                     "options": {"data_frame": "path.to.nonexistent"}, | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         assert process_chart_variables(variables=variables) == variables | ||||
|  | ||||
|     def test_process_chart_generate_chart_invocation(self): | ||||
|         # Ensure generate_chart is invoked with expected parameters | ||||
|         variables = { | ||||
|             "charts": { | ||||
|                 "chart1": { | ||||
|                     "chartType": "type1", | ||||
|                     "outputType": "html", | ||||
|                     "options": {}, | ||||
|                     "traces": "Some Traces", | ||||
|                     "layout": "Some Layout", | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         with patch("ee.reporting.utils.generate_chart") as mock_generate_chart: | ||||
|             mock_generate_chart.return_value = "<html>Chart Here</html>" | ||||
|             _ = process_chart_variables(variables=variables) | ||||
|  | ||||
|         mock_generate_chart.assert_called_once_with( | ||||
|             type="type1", | ||||
|             format="html", | ||||
|             options={}, | ||||
|             traces="Some Traces", | ||||
|             layout="Some Layout", | ||||
|         ) | ||||
|  | ||||
|     def test_process_chart_missing_keys(self): | ||||
|         # Scenario where necessary keys are missing | ||||
|         variables = {"charts": {"chart1": {}}} | ||||
|  | ||||
|         result = process_chart_variables(variables=variables) | ||||
|         assert result == variables  # Expecting unchanged variables | ||||
|  | ||||
|     def test_process_chart_no_charts(self): | ||||
|         # Scenario with no charts key or charts not a dict | ||||
|         variables1 = {} | ||||
|         variables2 = {"charts": "Not a dict"} | ||||
|  | ||||
|         assert process_chart_variables(variables=variables1) == variables1 | ||||
|         assert process_chart_variables(variables=variables2) == variables2 | ||||
|  | ||||
|     def test_process_chart_replaces_data_frame(self): | ||||
|         # Sample input | ||||
|         variables = { | ||||
|             "charts": { | ||||
|                 "myChart": { | ||||
|                     "chartType": "bar", | ||||
|                     "outputType": "html", | ||||
|                     "options": {"data_frame": "data_sources.sample_data"}, | ||||
|                 } | ||||
|             }, | ||||
|             "data_sources": {"sample_data": [{"x": 1, "y": 2}, {"x": 2, "y": 3}]}, | ||||
|         } | ||||
|  | ||||
|         with patch("ee.reporting.utils.generate_chart") as mock_generate_chart: | ||||
|             mock_generate_chart.return_value = "<html>Chart Here</html>" | ||||
|  | ||||
|             result = process_chart_variables(variables=variables) | ||||
|  | ||||
|         # Check if the generate_chart function was called correctly | ||||
|         mock_generate_chart.assert_called_once_with( | ||||
|             type="bar", | ||||
|             format="html", | ||||
|             options={"data_frame": [{"x": 1, "y": 2}, {"x": 2, "y": 3}]}, | ||||
|             traces=None, | ||||
|             layout=None, | ||||
|         ) | ||||
|  | ||||
|         # Check if the returned data has the chart in place | ||||
|         assert "<html>Chart Here</html>" in result["charts"]["myChart"] | ||||
|  | ||||
|  | ||||
| class TestPrepVariablesFunction: | ||||
|     def test_prep_variables_base(self): | ||||
|         result = prep_variables_for_template(variables="") | ||||
|         assert isinstance(result, dict) | ||||
|         assert not result | ||||
|  | ||||
|     def test_prep_variables_with_dependencies(self): | ||||
|         with patch( | ||||
|             "ee.reporting.utils.process_dependencies", | ||||
|             return_value={"dependency_key": "dependency_value"}, | ||||
|         ): | ||||
|             result = prep_variables_for_template( | ||||
|                 variables="", dependencies={"some_dependency": "value"} | ||||
|             ) | ||||
|             assert "dependency_key" in result | ||||
|             assert result["dependency_key"] == "dependency_value" | ||||
|  | ||||
|     def test_prep_variables_with_data_sources(self): | ||||
|         with patch( | ||||
|             "ee.reporting.utils.process_data_sources", | ||||
|             return_value={"data_source_key": "data_value"}, | ||||
|         ): | ||||
|             result = prep_variables_for_template(variables="data_sources: some_data") | ||||
|             assert "data_source_key" in result | ||||
|             assert result["data_source_key"] == "data_value" | ||||
|  | ||||
|     def test_prep_variables_with_charts(self): | ||||
|         with patch( | ||||
|             "ee.reporting.utils.process_chart_variables", | ||||
|             return_value={"chart_key": "chart_value"}, | ||||
|         ): | ||||
|             result = prep_variables_for_template(variables="charts: some_chart") | ||||
|             assert "chart_key" in result | ||||
|             assert result["chart_key"] == "chart_value" | ||||
							
								
								
									
										112
									
								
								api/tacticalrmm/ee/reporting/tests/test_util_functions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								api/tacticalrmm/ee/reporting/tests/test_util_functions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import base64 | ||||
|  | ||||
| import pytest | ||||
| from model_bakery import baker | ||||
|  | ||||
| from ..utils import base64_encode_assets, decode_base64_asset, normalize_asset_url | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestBase64EncodeDecodeAssets: | ||||
|     def test_base64_encode_assets_multiple_valid_urls(self): | ||||
|         asset1 = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         asset2 = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         template = f"Some content with link asset://{asset1.id} and another link asset://{asset2.id}" | ||||
|  | ||||
|         result = base64_encode_assets(template) | ||||
|  | ||||
|         assert len(result) == 2 | ||||
|  | ||||
|         # check asset 1 | ||||
|         assert result[0]["id"] == asset1.id | ||||
|         # Checking if the file content is correctly encoded to base64 | ||||
|         encoded_content = base64.b64encode(asset1.file.file.read()).decode("utf-8") | ||||
|         assert result[0]["file"] == encoded_content | ||||
|  | ||||
|         # check asset 2 | ||||
|         assert result[1]["id"] == asset2.id | ||||
|         # Checking if the file content is correctly encoded to base64 | ||||
|         encoded_content = base64.b64encode(asset2.file.file.read()).decode("utf-8") | ||||
|         assert result[1]["file"] == encoded_content | ||||
|  | ||||
|     def test_base64_encode_assets_some_invalid_urls(self): | ||||
|         asset = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         invalid_id = "11111111-1111-1111-1111-111111111111" | ||||
|         template = f"Some content with link asset://{asset.id} and invalid link asset://{invalid_id}" | ||||
|  | ||||
|         result = base64_encode_assets(template) | ||||
|  | ||||
|         assert len(result) == 1 | ||||
|         assert result[0]["id"] == asset.id | ||||
|  | ||||
|     def test_base64_encode_assets_no_urls(self): | ||||
|         template = "Some content with no assets" | ||||
|         result = base64_encode_assets(template) | ||||
|         assert result == [] | ||||
|  | ||||
|     def test_base64_encode_assets_duplicate_urls(self): | ||||
|         asset = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         template = f"Some content with link asset://{asset.id} and another link asset://{asset.id}" | ||||
|  | ||||
|         result = base64_encode_assets(template) | ||||
|  | ||||
|         assert len(result) == 1 | ||||
|         assert result[0]["id"] == asset.id | ||||
|  | ||||
|     def test_decode_base64_asset_valid_input(self): | ||||
|         original_data = b"Hello, world!" | ||||
|         encoded_data = base64.b64encode(original_data).decode("utf-8") | ||||
|  | ||||
|         result = decode_base64_asset(encoded_data) | ||||
|  | ||||
|         assert result == original_data | ||||
|  | ||||
|     def test_decode_base64_asset_invalid_input(self): | ||||
|         invalid_data = "Not a base64 encoded string." | ||||
|  | ||||
|         with pytest.raises(base64.binascii.Error): | ||||
|             decode_base64_asset(invalid_data) | ||||
|  | ||||
|  | ||||
| @pytest.mark.django_db | ||||
| class TestNormalizeAssets: | ||||
|     def test_normalize_asset_url_valid_html_type(self): | ||||
|         asset = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         text = f"Some content with link asset://{asset.id} and more content" | ||||
|  | ||||
|         result = normalize_asset_url(text, "html") | ||||
|  | ||||
|         assert f"{asset.file.url}?id={asset.id}" in result | ||||
|         assert f"asset://{asset.id}" not in result | ||||
|  | ||||
|     def test_normalize_asset_url_valid_pdf_type(self): | ||||
|         asset = baker.make("reporting.ReportAsset", _create_files=True) | ||||
|         text = f"Some content with link asset://{asset.id} and more content" | ||||
|  | ||||
|         result = normalize_asset_url(text, "pdf") | ||||
|  | ||||
|         assert f"file://{asset.file.path}" in result | ||||
|         assert f"asset://{asset.id}" not in result | ||||
|  | ||||
|     def test_normalize_asset_url_invalid_asset(self): | ||||
|         invalid_id = "11111111-1111-1111-1111-111111111111"  # UUID that's not in the DB | ||||
|         text = f"Some content with link asset://{invalid_id} and more content" | ||||
|  | ||||
|         result = normalize_asset_url(text, "html") | ||||
|  | ||||
|         # Since the asset doesn't exist, the URL should remain unchanged | ||||
|         assert f"asset://{invalid_id}" in result | ||||
|  | ||||
|     def test_normalize_asset_url_no_asset(self): | ||||
|         text = "Some content with no assets" | ||||
|  | ||||
|         result = normalize_asset_url(text, "html") | ||||
|  | ||||
|         # The text remains unchanged | ||||
|         assert text == result | ||||
							
								
								
									
										39
									
								
								api/tacticalrmm/ee/reporting/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								api/tacticalrmm/ee/reporting/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| from django.urls import path | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     # report templates | ||||
|     path("templates/", views.GetAddReportTemplate.as_view()), | ||||
|     path("templates/<int:pk>/", views.GetEditDeleteReportTemplate.as_view()), | ||||
|     path("templates/<int:pk>/run/", views.GenerateReport.as_view()), | ||||
|     path("templates/<int:pk>/export/", views.ExportReportTemplate.as_view()), | ||||
|     path("templates/preview/", views.GenerateReportPreview.as_view()), | ||||
|     path("templates/preview/analysis/", views.GetAllowedValues.as_view()), | ||||
|     path("templates/import/", views.ImportReportTemplate.as_view()), | ||||
|     # shared templates | ||||
|     path("templates/shared/", views.SharedTemplatesRepo.as_view()), | ||||
|     # report assets | ||||
|     path("assets/", views.GetReportAssets.as_view()), | ||||
|     path("assets/all/", views.GetAllAssets.as_view()), | ||||
|     path("assets/rename/", views.RenameReportAsset.as_view()), | ||||
|     path("assets/newfolder/", views.CreateAssetFolder.as_view()), | ||||
|     path("assets/delete/", views.DeleteAssets.as_view()), | ||||
|     path("assets/upload/", views.UploadAssets.as_view()), | ||||
|     path("assets/download/", views.DownloadAssets.as_view()), | ||||
|     # report html templates | ||||
|     path("htmltemplates/", views.GetAddReportHTMLTemplate.as_view()), | ||||
|     path("htmltemplates/<int:pk>/", views.GetEditDeleteReportHTMLTemplate.as_view()), | ||||
|     # report data queries | ||||
|     path("dataqueries/", views.GetAddReportDataQuery.as_view()), | ||||
|     path("dataqueries/<int:pk>/", views.GetEditDeleteReportDataQuery.as_view()), | ||||
|     # serving assets | ||||
|     path("assets/<path:path>", views.NginxRedirect.as_view()), | ||||
|     path("queryschema/", views.QuerySchema.as_view()), | ||||
| ] | ||||
							
								
								
									
										712
									
								
								api/tacticalrmm/ee/reporting/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										712
									
								
								api/tacticalrmm/ee/reporting/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,712 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import json | ||||
| import re | ||||
| from enum import Enum | ||||
| from typing import Any, Dict, List, Literal, Optional, Tuple, Type, Union, cast | ||||
|  | ||||
| import yaml | ||||
| from django.apps import apps | ||||
| from jinja2 import Environment, FunctionLoader | ||||
| from rest_framework.serializers import ValidationError | ||||
| from tacticalrmm.utils import get_db_value | ||||
| from weasyprint import CSS, HTML | ||||
| from weasyprint.text.fonts import FontConfiguration | ||||
|  | ||||
| from .constants import REPORTING_MODELS | ||||
| from .markdown.config import Markdown | ||||
| from .models import ReportAsset, ReportDataQuery, ReportHTMLTemplate, ReportTemplate | ||||
|  | ||||
| # regex for db data replacement | ||||
| # will return 3 groups of matches in a tuple when uses with re.findall | ||||
| # i.e. - {{client.name}}, client.name, client | ||||
| RE_DB_VALUE = re.compile(r"(\{\{\s*(client|site|agent|global)\.(.*)\s*\}\})") | ||||
|  | ||||
| RE_ASSET_URL = re.compile( | ||||
|     r"(asset://([0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}))" | ||||
| ) | ||||
|  | ||||
| RE_DEPENDENCY_VALUE = re.compile(r"(\{\{\s*(.*)\s*\}\})") | ||||
|  | ||||
|  | ||||
| # this will lookup the Jinja parent template in the DB | ||||
| # Example: {% extends "MASTER_TEMPLATE_NAME or REPORT_TEMPLATE_NAME" %} | ||||
| def db_template_loader(template_name: str) -> Optional[str]: | ||||
|     # trys the ReportHTMLTemplate table and ReportTemplate table | ||||
|     try: | ||||
|         return ReportHTMLTemplate.objects.get(name=template_name).html | ||||
|     except ReportHTMLTemplate.DoesNotExist: | ||||
|         pass | ||||
|  | ||||
|     try: | ||||
|         template = ReportTemplate.objects.get(name=template_name) | ||||
|         return template.template_md | ||||
|     except ReportTemplate.DoesNotExist: | ||||
|         pass | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| # sets up Jinja environment wiht the db loader template | ||||
| # comment tags needed to be editted because they conflicted with css properties | ||||
| env = Environment( | ||||
|     loader=FunctionLoader(db_template_loader), | ||||
|     comment_start_string="{=", | ||||
|     comment_end_string="=}", | ||||
| ) | ||||
|  | ||||
|  | ||||
| def generate_pdf(*, html: str, css: str = "") -> bytes: | ||||
|     font_config = FontConfiguration() | ||||
|  | ||||
|     pdf_bytes: bytes = HTML(string=html).write_pdf( | ||||
|         stylesheets=[CSS(string=css, font_config=font_config)], font_config=font_config | ||||
|     ) | ||||
|  | ||||
|     return pdf_bytes | ||||
|  | ||||
|  | ||||
| def generate_html( | ||||
|     *, | ||||
|     template: str, | ||||
|     template_type: str, | ||||
|     css: str = "", | ||||
|     html_template: Optional[int] = None, | ||||
|     variables: str = "", | ||||
|     dependencies: Optional[Dict[str, int]] = None, | ||||
| ) -> Tuple[str, Dict[str, Any]]: | ||||
|     if dependencies is None: | ||||
|         dependencies = {} | ||||
|  | ||||
|     # validate the template | ||||
|     env.parse(template) | ||||
|  | ||||
|     # convert template | ||||
|     template_string = ( | ||||
|         Markdown.convert(template) if template_type == "markdown" else template | ||||
|     ) | ||||
|  | ||||
|     # append extends if base template is configured | ||||
|     if html_template: | ||||
|         try: | ||||
|             html_template_name = ReportHTMLTemplate.objects.get(pk=html_template).name | ||||
|             template_string = ( | ||||
|                 f"""{{% extends "{html_template_name}" %}}\n{template_string}""" | ||||
|             ) | ||||
|         except ReportHTMLTemplate.DoesNotExist: | ||||
|             pass | ||||
|  | ||||
|     tm = env.from_string(template_string) | ||||
|  | ||||
|     variables_dict = prep_variables_for_template( | ||||
|         variables=variables, dependencies=dependencies | ||||
|     ) | ||||
|  | ||||
|     return (tm.render(css=css, **variables_dict), variables_dict) | ||||
|  | ||||
|  | ||||
| def make_dataqueries_inline(*, variables: str) -> str: | ||||
|     try: | ||||
|         variables_obj = yaml.safe_load(variables) or {} | ||||
|     except (yaml.parser.ParserError, yaml.YAMLError): | ||||
|         variables_obj = {} | ||||
|  | ||||
|     data_sources = variables_obj.get("data_sources", {}) | ||||
|     if isinstance(data_sources, dict): | ||||
|         for key, value in data_sources.items(): | ||||
|             if isinstance(value, str): | ||||
|                 try: | ||||
|                     query = ReportDataQuery.objects.get(name=value).json_query | ||||
|                     variables_obj["data_sources"][key] = query | ||||
|                 except ReportDataQuery.DoesNotExist: | ||||
|                     continue | ||||
|  | ||||
|     return yaml.dump(variables_obj) | ||||
|  | ||||
|  | ||||
| def prep_variables_for_template( | ||||
|     *, | ||||
|     variables: str, | ||||
|     dependencies: Optional[Dict[str, Any]] = None, | ||||
|     limit_query_results: Optional[int] = None, | ||||
| ) -> Dict[str, Any]: | ||||
|     if not dependencies: | ||||
|         dependencies = {} | ||||
|  | ||||
|     if not variables: | ||||
|         variables = "" | ||||
|  | ||||
|     # process report dependencies | ||||
|     variables_dict = process_dependencies( | ||||
|         variables=variables, dependencies=dependencies | ||||
|     ) | ||||
|  | ||||
|     # replace the data_sources with the actual data from DB. This will be passed to the template | ||||
|     # in the form of {{data_sources.data_source_name}} | ||||
|     variables_dict = process_data_sources( | ||||
|         variables=variables_dict, limit_query_results=limit_query_results | ||||
|     ) | ||||
|  | ||||
|     # generate and replace charts in the variables | ||||
|     variables_dict = process_chart_variables(variables=variables_dict) | ||||
|  | ||||
|     return variables_dict | ||||
|  | ||||
|  | ||||
| class ResolveModelException(Exception): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| def resolve_model(*, data_source: Dict[str, Any]) -> Dict[str, Any]: | ||||
|     tmp_data_source = data_source | ||||
|  | ||||
|     # check that model property is present and correct | ||||
|     if "model" in data_source: | ||||
|         for model, app in REPORTING_MODELS: | ||||
|             if data_source["model"].lower() == model.lower(): | ||||
|                 try: | ||||
|                     # overwrite model with the model type | ||||
|                     tmp_data_source["model"] = apps.get_model(app, model) | ||||
|                     return tmp_data_source | ||||
|                 except LookupError: | ||||
|                     raise ResolveModelException( | ||||
|                         f"Model: {model} does not exist in app: {app}" | ||||
|                     ) | ||||
|         raise ResolveModelException(f"Model lookup failed for {data_source['model']}") | ||||
|     else: | ||||
|         raise ResolveModelException("Model key must be present on data_source") | ||||
|  | ||||
|  | ||||
| class AllowedOperations(Enum): | ||||
|     # filtering | ||||
|     ONLY = "only" | ||||
|     DEFER = "defer" | ||||
|     FILTER = "filter" | ||||
|     EXCLUDE = "exclude" | ||||
|     LIMIT = "limit" | ||||
|     GET = "get" | ||||
|     FIRST = "first" | ||||
|     ALL = "all" | ||||
|     # custom fields | ||||
|     CUSTOM_FIELDS = "custom_fields" | ||||
|     # presentation | ||||
|     JSON = "json" | ||||
|     CSV = "csv" | ||||
|     # relations | ||||
|     SELECT_RELATED = "select_related" | ||||
|     PREFETCH_RELATED = "prefetch_related" | ||||
|     # operations | ||||
|     AGGREGATE = "aggregate" | ||||
|     ANNOTATE = "annotate" | ||||
|     COUNT = "count" | ||||
|     VALUES = "values" | ||||
|     # ordering | ||||
|     ORDER_BY = "order_by" | ||||
|  | ||||
|  | ||||
| class InvalidDBOperationException(Exception): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| def build_queryset(*, data_source: Dict[str, Any], limit: Optional[int] = None) -> Any: | ||||
|     local_data_source = data_source | ||||
|     Model = local_data_source.pop("model") | ||||
|     count = False | ||||
|     get = False | ||||
|     first = False | ||||
|     all = False | ||||
|     isJson = False | ||||
|     isCsv = False | ||||
|     csv_columns = {} | ||||
|     defer = local_data_source.get("defer", None) | ||||
|     columns = local_data_source.get("only", None) | ||||
|     fields_to_add = [] | ||||
|  | ||||
|     # create a base reporting queryset | ||||
|     queryset = Model.objects.using("default") | ||||
|     model_name = Model.__name__.lower() | ||||
|     for operation, values in local_data_source.items(): | ||||
|         # Usage in the build_queryset function: | ||||
|         if operation not in [op.value for op in AllowedOperations]: | ||||
|             raise InvalidDBOperationException( | ||||
|                 f"DB operation: {operation} not allowed. Supported operations: {', '.join(op.value for op in AllowedOperations)}" | ||||
|             ) | ||||
|  | ||||
|         if operation == "meta": | ||||
|             continue | ||||
|         elif operation == "custom_fields" and isinstance(values, list): | ||||
|             from core.models import CustomField | ||||
|  | ||||
|             if model_name in ("client", "site", "agent"): | ||||
|                 fields = CustomField.objects.filter(model=model_name) | ||||
|                 fields_to_add = [ | ||||
|                     field for field in values if fields.filter(name=field).exists() | ||||
|                 ] | ||||
|  | ||||
|         elif operation == "limit": | ||||
|             limit = values | ||||
|         elif operation == "count": | ||||
|             count = True | ||||
|         elif operation == "get": | ||||
|             # need to add a filter for the get if present | ||||
|             if isinstance(values, dict): | ||||
|                 queryset = queryset.filter(**values) | ||||
|             get = True | ||||
|         elif operation == "first": | ||||
|             first = True | ||||
|         elif operation == "all": | ||||
|             all = True | ||||
|         elif operation == "json": | ||||
|             isJson = True | ||||
|         elif operation == "csv": | ||||
|             if isinstance(values, dict): | ||||
|                 csv_columns = values | ||||
|             isCsv = True | ||||
|         elif isinstance(values, list): | ||||
|             queryset = getattr(queryset, operation)(*values) | ||||
|         elif isinstance(values, dict): | ||||
|             queryset = getattr(queryset, operation)(**values) | ||||
|         else: | ||||
|             queryset = getattr(queryset, operation)(values) | ||||
|  | ||||
|     if all: | ||||
|         queryset = queryset.all() | ||||
|  | ||||
|     if count: | ||||
|         return queryset.count() | ||||
|  | ||||
|     if limit and not first and not get: | ||||
|         queryset = queryset[:limit] | ||||
|  | ||||
|     if columns: | ||||
|         # remove columns from only if defer is also present | ||||
|         if defer: | ||||
|             columns = [column for column in columns if column not in defer] | ||||
|         if "id" not in columns: | ||||
|             columns.append("id") | ||||
|  | ||||
|         queryset = queryset.values(*columns) | ||||
|     elif defer: | ||||
|         # Since values seems to ignore only and defer, we need to get all columns and remove the defered ones. | ||||
|         # Then we can pass the rest of the columns in | ||||
|         included_fields = [ | ||||
|             field.name for field in Model._meta.local_fields if field.name not in defer | ||||
|         ] | ||||
|         queryset = queryset.values(*included_fields) | ||||
|     else: | ||||
|         queryset = queryset.values() | ||||
|  | ||||
|     if get or first: | ||||
|         if get: | ||||
|             queryset = queryset.get() | ||||
|         elif first: | ||||
|             queryset = queryset.first() | ||||
|  | ||||
|         if fields_to_add: | ||||
|             return add_custom_fields( | ||||
|                 data=queryset, | ||||
|                 fields_to_add=fields_to_add, | ||||
|                 model_name=model_name, | ||||
|                 dict_value=True, | ||||
|             ) | ||||
|         else: | ||||
|             if isJson: | ||||
|                 return json.dumps(queryset, default=str) | ||||
|             elif isCsv: | ||||
|                 import pandas as pd | ||||
|  | ||||
|                 df = pd.DataFrame.from_dict([queryset]) | ||||
|                 df.drop("id", axis=1, inplace=True) | ||||
|                 if csv_columns: | ||||
|                     df = df.rename(columns=csv_columns) | ||||
|                 return df.to_csv(index=False) | ||||
|             else: | ||||
|                 return queryset | ||||
|     else: | ||||
|         # add custom fields for list results | ||||
|         if fields_to_add: | ||||
|             return add_custom_fields( | ||||
|                 data=list(queryset), fields_to_add=fields_to_add, model_name=model_name | ||||
|             ) | ||||
|         else: | ||||
|             if isJson: | ||||
|                 return json.dumps(list(queryset), default=str) | ||||
|             elif isCsv: | ||||
|                 import pandas as pd | ||||
|  | ||||
|                 df = pd.DataFrame.from_dict(list(queryset)) | ||||
|                 df.drop("id", axis=1, inplace=True) | ||||
|                 print(csv_columns) | ||||
|                 if csv_columns: | ||||
|                     df = df.rename(columns=csv_columns) | ||||
|                 return df.to_csv(index=False) | ||||
|             else: | ||||
|                 return list(queryset) | ||||
|  | ||||
|  | ||||
| def add_custom_fields( | ||||
|     *, | ||||
|     data: Union[Dict[str, Any], List[Dict[str, Any]]], | ||||
|     fields_to_add: List[str], | ||||
|     model_name: Literal["client", "site", "agent"], | ||||
|     dict_value: bool = False, | ||||
| ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: | ||||
|     from agents.models import AgentCustomField | ||||
|     from clients.models import ClientCustomField, SiteCustomField | ||||
|     from core.models import CustomField | ||||
|  | ||||
|     model_name = model_name.lower() | ||||
|     CustomFieldModel: Union[ | ||||
|         Type[AgentCustomField], Type[ClientCustomField], Type[SiteCustomField] | ||||
|     ] | ||||
|     if model_name == "agent": | ||||
|         CustomFieldModel = AgentCustomField | ||||
|     elif model_name == "client": | ||||
|         CustomFieldModel = ClientCustomField | ||||
|     else: | ||||
|         CustomFieldModel = SiteCustomField | ||||
|  | ||||
|     custom_fields = CustomField.objects.filter(name__in=fields_to_add, model=model_name) | ||||
|  | ||||
|     if dict_value: | ||||
|         custom_field_data = list( | ||||
|             CustomFieldModel.objects.select_related("field").filter( | ||||
|                 field__name__in=fields_to_add, **{f"{model_name}_id": data["id"]} | ||||
|             ) | ||||
|         ) | ||||
|         # hold custom field data on the returned object | ||||
|         data["custom_fields"]: Dict[str, Any] = {} | ||||
|  | ||||
|         for custom_field in custom_fields: | ||||
|             find_custom_field_data = next( | ||||
|                 (cf for cf in custom_field_data if cf.field_id == custom_field.id), | ||||
|                 None, | ||||
|             ) | ||||
|  | ||||
|             if find_custom_field_data is not None: | ||||
|                 data["custom_fields"][custom_field.name] = find_custom_field_data.value | ||||
|             else: | ||||
|                 data["custom_fields"][custom_field.name] = custom_field.default_value | ||||
|  | ||||
|         return data | ||||
|     else: | ||||
|         ids = [row["id"] for row in data] | ||||
|         custom_field_data = list( | ||||
|             CustomFieldModel.objects.select_related("field").filter( | ||||
|                 field__name__in=fields_to_add, **{f"{model_name}_id__in": ids} | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         for row in data: | ||||
|             row["custom_fields"]: Dict[str, Any] = {} | ||||
|  | ||||
|             for custom_field in custom_fields: | ||||
|                 find_custom_field_data = next( | ||||
|                     ( | ||||
|                         cf | ||||
|                         for cf in custom_field_data | ||||
|                         if cf.field_id == custom_field.id | ||||
|                         and getattr(cf, f"{model_name}_id") == row["id"] | ||||
|                     ), | ||||
|                     None, | ||||
|                 ) | ||||
|  | ||||
|                 if find_custom_field_data is not None: | ||||
|                     row["custom_fields"][ | ||||
|                         custom_field.name | ||||
|                     ] = find_custom_field_data.value | ||||
|                 else: | ||||
|                     row["custom_fields"][custom_field.name] = custom_field.default_value | ||||
|  | ||||
|         return data | ||||
|  | ||||
|  | ||||
| def normalize_asset_url(text: str, type: Literal["pdf", "html", "plaintext"]) -> str: | ||||
|     new_text = text | ||||
|     for url, id in RE_ASSET_URL.findall(text): | ||||
|         try: | ||||
|             asset = ReportAsset.objects.get(id=id) | ||||
|             if type == "html": | ||||
|                 new_text = new_text.replace( | ||||
|                     f"asset://{id}", f"{asset.file.url}?id={id}" | ||||
|                 ) | ||||
|             else: | ||||
|                 new_text = new_text.replace(f"{url}", f"file://{asset.file.path}") | ||||
|         except ReportAsset.DoesNotExist: | ||||
|             pass | ||||
|  | ||||
|     return new_text | ||||
|  | ||||
|  | ||||
| def base64_encode_assets(template: str) -> List[Dict[str, Any]]: | ||||
|     import base64 | ||||
|  | ||||
|     assets = [] | ||||
|     added_ids = [] | ||||
|     for _, id in RE_ASSET_URL.findall(template): | ||||
|         if id not in added_ids: | ||||
|             try: | ||||
|                 asset = ReportAsset.objects.get(pk=id) | ||||
|  | ||||
|                 encoded_base64_str = base64.b64encode(asset.file.file.read()).decode( | ||||
|                     "utf-8" | ||||
|                 ) | ||||
|                 assets.append( | ||||
|                     { | ||||
|                         "id": asset.id, | ||||
|                         "name": asset.file.name, | ||||
|                         "file": encoded_base64_str, | ||||
|                     } | ||||
|                 ) | ||||
|                 added_ids.append( | ||||
|                     str(asset.id) | ||||
|                 )  # need to convert uuid to str for easy comparison | ||||
|             except ReportAsset.DoesNotExist: | ||||
|                 continue | ||||
|  | ||||
|     return assets | ||||
|  | ||||
|  | ||||
| def decode_base64_asset(asset: str) -> bytes: | ||||
|     import base64 | ||||
|  | ||||
|     return base64.b64decode(asset.encode("utf-8")) | ||||
|  | ||||
|  | ||||
| def process_data_sources( | ||||
|     *, variables: Dict[str, Any], limit_query_results: Optional[int] = None | ||||
| ) -> Dict[str, Any]: | ||||
|     data_sources = variables.get("data_sources") | ||||
|  | ||||
|     if isinstance(data_sources, dict): | ||||
|         for key, value in data_sources.items(): | ||||
|             if isinstance(value, dict): | ||||
|                 modified_datasource = resolve_model(data_source=value) | ||||
|                 queryset = build_queryset( | ||||
|                     data_source=modified_datasource, limit=limit_query_results | ||||
|                 ) | ||||
|                 data_sources[key] = queryset | ||||
|  | ||||
|     return variables | ||||
|  | ||||
|  | ||||
| def process_dependencies( | ||||
|     *, variables: str, dependencies: Dict[str, Any] | ||||
| ) -> Dict[str, Any]: | ||||
|     DEPENDENCY_MODELS = { | ||||
|         "client": ("clients", "Client"), | ||||
|         "site": ("clients", "Site"), | ||||
|         "agent": ("agents", "Agent"), | ||||
|     } | ||||
|  | ||||
|     # Resolve dependencies that are agent, site, or client | ||||
|     for dep, (app_label, model_name) in DEPENDENCY_MODELS.items(): | ||||
|         if dep in dependencies: | ||||
|             Model = apps.get_model(app_label, model_name) | ||||
|             # Assumes each model has a unique lookup mechanism | ||||
|             lookup_param = "agent_id" if dep == "agent" else "id" | ||||
|             dependencies[dep] = Model.objects.get(**{lookup_param: dependencies[dep]}) | ||||
|  | ||||
|     # Handle database value placeholders | ||||
|     for string, model, prop in RE_DB_VALUE.findall(variables): | ||||
|         value = get_value_for_model(model, prop, dependencies) | ||||
|         if value: | ||||
|             variables = variables.replace(string, str(value)) | ||||
|  | ||||
|     # Handle non-database dependencies | ||||
|     for string, dep in RE_DEPENDENCY_VALUE.findall(variables): | ||||
|         value = dependencies.get(dep) | ||||
|         if value: | ||||
|             variables = variables.replace(string, str(value)) | ||||
|  | ||||
|     # Load yaml variables if they exist | ||||
|     variables = yaml.safe_load(variables) or {} | ||||
|  | ||||
|     return {**variables, **dependencies} | ||||
|  | ||||
|  | ||||
| def get_value_for_model(model: str, prop: str, dependencies: Dict[str, Any]) -> Any: | ||||
|     if model == "global": | ||||
|         return get_db_value(string=f"{model}.{prop}") | ||||
|     instance = dependencies.get(model) | ||||
|     return get_db_value(string=prop, instance=instance) if instance else None | ||||
|  | ||||
|  | ||||
| def process_chart_variables(*, variables: Dict[str, Any]) -> Dict[str, Any]: | ||||
|     charts = variables.get("charts") | ||||
|  | ||||
|     if not isinstance(charts, dict): | ||||
|         return variables | ||||
|  | ||||
|     # these will be remove so they don't render in the template | ||||
|     invalid_chart_keys = [] | ||||
|  | ||||
|     for key, chart in charts.items(): | ||||
|         options = chart.get("options") | ||||
|         if not isinstance(options, dict): | ||||
|             continue | ||||
|  | ||||
|         data_frame = options.get("data_frame") | ||||
|         if isinstance(data_frame, str): | ||||
|             data_source = data_frame.split(".") | ||||
|             data = variables | ||||
|  | ||||
|             path_exists = True | ||||
|             for path in data_source: | ||||
|                 data = data.get(path) | ||||
|                 if data is None: | ||||
|                     path_exists = False | ||||
|                     break | ||||
|  | ||||
|             if not path_exists: | ||||
|                 continue | ||||
|  | ||||
|             if not data: | ||||
|                 invalid_chart_keys.append(key) | ||||
|                 continue | ||||
|  | ||||
|             options["data_frame"] = data | ||||
|  | ||||
|         traces = chart.get("traces") | ||||
|         layout = chart.get("layout") | ||||
|         charts[key] = generate_chart( | ||||
|             type=chart["chartType"], | ||||
|             format=chart["outputType"], | ||||
|             options=options, | ||||
|             traces=traces, | ||||
|             layout=layout, | ||||
|         ) | ||||
|  | ||||
|     for key in invalid_chart_keys: | ||||
|         del charts[key] | ||||
|  | ||||
|     return variables | ||||
|  | ||||
|  | ||||
| def generate_chart( | ||||
|     *, | ||||
|     type: Literal["pie", "bar", "line"], | ||||
|     format: Literal["html", "image"], | ||||
|     options: Dict[str, Any], | ||||
|     traces: Optional[Dict[str, Any]] = None, | ||||
|     layout: Optional[Dict[str, Any]] = None, | ||||
| ) -> str: | ||||
|     # TODO figure out why plotly affects perf | ||||
|     import plotly.express as px | ||||
|  | ||||
|     fig = getattr(px, type)(**options) | ||||
|  | ||||
|     if traces: | ||||
|         fig.update_traces(**traces) | ||||
|  | ||||
|     if layout: | ||||
|         fig.update_layout(**layout) | ||||
|  | ||||
|     if format == "html": | ||||
|         return cast(str, fig.to_html(full_html=False, include_plotlyjs="cdn")) | ||||
|     elif format == "image": | ||||
|         return cast(str, fig.to_image(format="svg").decode("utf-8")) | ||||
|  | ||||
|  | ||||
| # import report functions | ||||
| def _import_base_template( | ||||
|     base_template_data: Optional[Dict[str, Any]] = None, | ||||
|     overwrite: bool = False, | ||||
| ) -> Optional[int]: | ||||
|     if base_template_data: | ||||
|         # Check name conflict and modify name if necessary | ||||
|         name = base_template_data.get("name") | ||||
|         html = base_template_data.get("html") | ||||
|  | ||||
|         if not name: | ||||
|             raise ValidationError("base_template is missing 'name' key") | ||||
|         if not html: | ||||
|             raise ValidationError("base_template is missing 'html' field") | ||||
|  | ||||
|         if ReportHTMLTemplate.objects.filter(name=name).exists(): | ||||
|             base_template = ReportHTMLTemplate.objects.filter(name=name).get() | ||||
|             if overwrite: | ||||
|                 base_template.html = html | ||||
|                 base_template.save() | ||||
|             else: | ||||
|                 name += f"_{_generate_random_string()}" | ||||
|                 base_template = ReportHTMLTemplate.objects.create(name=name, html=html) | ||||
|         else: | ||||
|             base_template = ReportHTMLTemplate.objects.create(name=name, html=html) | ||||
|  | ||||
|         base_template.refresh_from_db() | ||||
|         return base_template.id | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def _import_report_template( | ||||
|     report_template_data: Dict[str, Any], | ||||
|     base_template_id: Optional[int] = None, | ||||
|     overwrite: bool = False, | ||||
| ) -> "ReportTemplate": | ||||
|     if report_template_data: | ||||
|         name = report_template_data.pop("name", None) | ||||
|         template_md = report_template_data.get("template_md") | ||||
|  | ||||
|         if not name: | ||||
|             raise ValidationError("template requires a 'name' key") | ||||
|         if not template_md: | ||||
|             raise ValidationError("template requires a 'template_md' field") | ||||
|  | ||||
|         if ReportTemplate.objects.filter(name=name).exists(): | ||||
|             report_template = ReportTemplate.objects.filter(name=name).get() | ||||
|             if overwrite: | ||||
|                 for key, value in report_template_data.items(): | ||||
|                     setattr(report_template, key, value) | ||||
|  | ||||
|                 report_template.save() | ||||
|             else: | ||||
|                 name += f"_{_generate_random_string()}" | ||||
|                 report_template = ReportTemplate.objects.create( | ||||
|                     name=name, | ||||
|                     template_html_id=base_template_id, | ||||
|                     **report_template_data, | ||||
|                 ) | ||||
|         else: | ||||
|             report_template = ReportTemplate.objects.create( | ||||
|                 name=name, template_html_id=base_template_id, **report_template_data | ||||
|             ) | ||||
|         report_template.refresh_from_db() | ||||
|         return report_template | ||||
|     else: | ||||
|         raise ValidationError("'template' key is required in input") | ||||
|  | ||||
|  | ||||
| def _import_assets(assets: List[Dict[str, Any]]) -> None: | ||||
|     import io | ||||
|     import os | ||||
|  | ||||
|     from django.core.files import File | ||||
|  | ||||
|     from .storage import report_assets_fs | ||||
|  | ||||
|     if isinstance(assets, list): | ||||
|         for asset in assets: | ||||
|             parent_folder = report_assets_fs.getreldir(path=asset["name"]) | ||||
|             path = report_assets_fs.get_available_name( | ||||
|                 os.path.join(parent_folder, asset["name"]) | ||||
|             ) | ||||
|             asset_obj = ReportAsset( | ||||
|                 id=asset["id"], | ||||
|                 file=File( | ||||
|                     io.BytesIO(decode_base64_asset(asset["file"])), | ||||
|                     name=path, | ||||
|                 ), | ||||
|             ) | ||||
|             asset_obj.save() | ||||
|  | ||||
|  | ||||
| def _generate_random_string(length: int = 6) -> str: | ||||
|     import random | ||||
|     import string | ||||
|  | ||||
|     return "".join(random.choice(string.ascii_lowercase) for i in range(length)) | ||||
							
								
								
									
										850
									
								
								api/tacticalrmm/ee/reporting/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										850
									
								
								api/tacticalrmm/ee/reporting/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,850 @@ | ||||
| """ | ||||
| Copyright (c) 2023-present Amidaware Inc. | ||||
| This file is subject to the EE License Agreement. | ||||
| For details, see: https://license.tacticalrmm.com/ee | ||||
| """ | ||||
|  | ||||
| import json | ||||
| import os | ||||
| import shutil | ||||
| import uuid | ||||
| from typing import Any, Dict, List, Literal, Optional, Union | ||||
|  | ||||
| import requests | ||||
| from django.conf import settings as djangosettings | ||||
| from django.core.exceptions import ( | ||||
|     ObjectDoesNotExist, | ||||
|     PermissionDenied, | ||||
|     SuspiciousFileOperation, | ||||
| ) | ||||
| from django.core.files.base import ContentFile | ||||
| from django.db import transaction | ||||
| from django.http import FileResponse, HttpResponse, JsonResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from jinja2.exceptions import TemplateError | ||||
| from rest_framework.permissions import AllowAny, IsAuthenticated | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     BooleanField, | ||||
|     CharField, | ||||
|     ChoiceField, | ||||
|     IntegerField, | ||||
|     JSONField, | ||||
|     ListField, | ||||
|     ModelSerializer, | ||||
|     Serializer, | ||||
|     ValidationError, | ||||
| ) | ||||
| from rest_framework.views import APIView | ||||
| from tacticalrmm.utils import notify_error | ||||
|  | ||||
| from .models import ReportAsset, ReportDataQuery, ReportHTMLTemplate, ReportTemplate | ||||
| from .permissions import GenerateReportPerms, ReportingPerms | ||||
| from .storage import report_assets_fs | ||||
| from .utils import ( | ||||
|     _import_assets, | ||||
|     _import_base_template, | ||||
|     _import_report_template, | ||||
|     base64_encode_assets, | ||||
|     generate_html, | ||||
|     generate_pdf, | ||||
|     normalize_asset_url, | ||||
|     prep_variables_for_template, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def path_exists(value: str) -> None: | ||||
|     if not report_assets_fs.exists(value): | ||||
|         raise ValidationError("Path does not exist on the file system") | ||||
|  | ||||
|  | ||||
| class ReportTemplateSerializer(ModelSerializer[ReportTemplate]): | ||||
|     class Meta: | ||||
|         model = ReportTemplate | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class GetAddReportTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|     queryset = ReportTemplate.objects.all() | ||||
|     serializer_class = ReportTemplateSerializer | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         depends_on: List[str] = request.query_params.getlist("dependsOn[]", []) | ||||
|  | ||||
|         if depends_on: | ||||
|             templates = ReportTemplate.objects.filter(depends_on__overlap=depends_on) | ||||
|         else: | ||||
|             templates = ReportTemplate.objects.all() | ||||
|         return Response(ReportTemplateSerializer(templates, many=True).data) | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         serializer = ReportTemplateSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportTemplateSerializer(response).data) | ||||
|  | ||||
|  | ||||
| class GetEditDeleteReportTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|     queryset = ReportTemplate.objects.all() | ||||
|     serializer_class = ReportTemplateSerializer | ||||
|  | ||||
|     def get(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportTemplate, pk=pk) | ||||
|  | ||||
|         return Response(ReportTemplateSerializer(template).data) | ||||
|  | ||||
|     def put(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportTemplate, pk=pk) | ||||
|  | ||||
|         serializer = ReportTemplateSerializer( | ||||
|             instance=template, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportTemplateSerializer(response).data) | ||||
|  | ||||
|     def delete(self, request: Request, pk: int) -> Response: | ||||
|         get_object_or_404(ReportTemplate, pk=pk).delete() | ||||
|  | ||||
|         return Response() | ||||
|  | ||||
|  | ||||
| class GenerateReport(APIView): | ||||
|     permission_classes = [IsAuthenticated, GenerateReportPerms] | ||||
|  | ||||
|     def post(self, request: Request, pk: int) -> Union[FileResponse, Response]: | ||||
|         template = get_object_or_404(ReportTemplate, pk=pk) | ||||
|  | ||||
|         format = request.data["format"] | ||||
|  | ||||
|         if format not in ("pdf", "html", "plaintext"): | ||||
|             return notify_error("Report format is incorrect.") | ||||
|  | ||||
|         try: | ||||
|             html_report, _ = generate_html( | ||||
|                 template=template.template_md, | ||||
|                 template_type=template.type, | ||||
|                 css=template.template_css or "", | ||||
|                 html_template=template.template_html.id | ||||
|                 if template.template_html | ||||
|                 else None, | ||||
|                 variables=template.template_variables, | ||||
|                 dependencies=request.data["dependencies"], | ||||
|             ) | ||||
|  | ||||
|             html_report = normalize_asset_url(html_report, format) | ||||
|  | ||||
|             if format != "pdf": | ||||
|                 return Response(html_report) | ||||
|             else: | ||||
|                 pdf_bytes = generate_pdf(html=html_report) | ||||
|  | ||||
|                 return FileResponse( | ||||
|                     ContentFile(pdf_bytes), | ||||
|                     content_type="application/pdf", | ||||
|                     filename=f"{template.name}.pdf", | ||||
|                 ) | ||||
|  | ||||
|         except TemplateError as error: | ||||
|             if hasattr(error, "lineno"): | ||||
|                 return notify_error(f"Line {error.lineno}: {error.message}") | ||||
|             else: | ||||
|                 return notify_error(str(error)) | ||||
|         except Exception as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class GenerateReportPreview(APIView): | ||||
|     permission_classes = [IsAuthenticated, GenerateReportPerms] | ||||
|  | ||||
|     class InputRequest: | ||||
|         template_md: str | ||||
|         type: Literal["markdown", "html"] | ||||
|         template_css: str | ||||
|         template_html: int | ||||
|         template_variables: Dict[str, Any] | ||||
|         dependencies: Dict[str, Any] | ||||
|         format: Literal["html", "pdf", "plaintext"] | ||||
|         debug: bool | ||||
|  | ||||
|     class InputSerializer(Serializer[InputRequest]): | ||||
|         template_md = CharField() | ||||
|         type = CharField() | ||||
|         template_css = CharField(allow_blank=True, required=False) | ||||
|         template_html = IntegerField(allow_null=True, required=False) | ||||
|         template_variables = JSONField() | ||||
|         dependencies = JSONField() | ||||
|         format = ChoiceField(choices=["html", "pdf", "plaintext"]) | ||||
|         debug = BooleanField(default=False) | ||||
|  | ||||
|     def post(self, request: Request) -> Union[FileResponse, Response]: | ||||
|         try: | ||||
|             report_data = self._parse_and_validate_request_data(request.data) | ||||
|             html_report, variables = generate_html( | ||||
|                 template=report_data["template_md"], | ||||
|                 template_type=report_data["type"], | ||||
|                 css=report_data.get("template_css", ""), | ||||
|                 html_template=report_data.get("template_html"), | ||||
|                 variables=report_data["template_variables"], | ||||
|                 dependencies=report_data["dependencies"], | ||||
|             ) | ||||
|  | ||||
|             if report_data["debug"]: | ||||
|                 return self._process_debug_response(html_report, variables) | ||||
|             return self._generate_response_based_on_format( | ||||
|                 html_report, report_data["format"] | ||||
|             ) | ||||
|         except TemplateError as error: | ||||
|             return self._handle_template_error(error) | ||||
|         except Exception as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|     def _parse_and_validate_request_data(self, data: Dict[str, Any]) -> Any: | ||||
|         serializer = self.InputSerializer(data=data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         return serializer.validated_data | ||||
|  | ||||
|     def _process_debug_response( | ||||
|         self, html_report: str, variables: Dict[str, Any] | ||||
|     ) -> Response: | ||||
|         if variables: | ||||
|             from django.forms.models import model_to_dict | ||||
|  | ||||
|             # serialize any model instances provided | ||||
|             for model_name in ("agent", "site", "client"): | ||||
|                 if model_name in variables: | ||||
|                     model_instance = variables[model_name] | ||||
|                     serialized_model = model_to_dict( | ||||
|                         model_instance, | ||||
|                         fields=[field.name for field in model_instance._meta.fields], | ||||
|                     ) | ||||
|                     variables[model_name] = serialized_model | ||||
|  | ||||
|         return Response({"template": html_report, "variables": variables}) | ||||
|  | ||||
|     def _generate_response_based_on_format( | ||||
|         self, html_report: str, format: Literal["html", "pdf", "plaintext"] | ||||
|     ) -> Union[Response, FileResponse]: | ||||
|         html_report = normalize_asset_url(html_report, format) | ||||
|  | ||||
|         if format != "pdf": | ||||
|             return Response(html_report) | ||||
|         else: | ||||
|             pdf_bytes = generate_pdf(html=html_report) | ||||
|             return FileResponse( | ||||
|                 ContentFile(pdf_bytes), | ||||
|                 content_type="application/pdf", | ||||
|                 filename="preview.pdf", | ||||
|             ) | ||||
|  | ||||
|     def _handle_template_error(self, error: TemplateError) -> Response: | ||||
|         if hasattr(error, "lineno"): | ||||
|             error_message = f"Line {error.lineno}: {error.message}" | ||||
|         else: | ||||
|             error_message = str(error) | ||||
|  | ||||
|         return notify_error(error_message) | ||||
|  | ||||
|  | ||||
| class ExportReportTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, GenerateReportPerms] | ||||
|  | ||||
|     def post(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportTemplate, pk=pk) | ||||
|  | ||||
|         template_html = template.template_html or None | ||||
|  | ||||
|         base_template = None | ||||
|         if template_html: | ||||
|             base_template = { | ||||
|                 "name": template_html.name, | ||||
|                 "html": template_html.html, | ||||
|             } | ||||
|  | ||||
|         assets = base64_encode_assets( | ||||
|             template.template_md + base_template["html"] | ||||
|             if base_template | ||||
|             else template.template_md | ||||
|         ) | ||||
|         return Response( | ||||
|             { | ||||
|                 "base_template": base_template, | ||||
|                 "template": { | ||||
|                     "name": template.name, | ||||
|                     "template_css": template.template_css, | ||||
|                     "template_md": template.template_md, | ||||
|                     "type": template.type, | ||||
|                     "depends_on": template.depends_on, | ||||
|                     "template_variables": template.template_variables, | ||||
|                 }, | ||||
|                 "assets": assets, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ImportReportTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def post(self, request: Request) -> Response: | ||||
|         try: | ||||
|             template_obj = json.loads(request.data["template"]) | ||||
|             overwrite = request.data.get("overwrite", False) | ||||
|  | ||||
|             # import base template if exists | ||||
|             base_template_id = _import_base_template( | ||||
|                 template_obj.get("base_template"), overwrite | ||||
|             ) | ||||
|  | ||||
|             # import template if exists | ||||
|             report_template = _import_report_template( | ||||
|                 template_obj.get("template"), base_template_id, overwrite | ||||
|             ) | ||||
|  | ||||
|             # import assets if exists | ||||
|             _import_assets(template_obj.get("assets")) | ||||
|  | ||||
|             return Response(ReportTemplateSerializer(report_template).data) | ||||
|  | ||||
|         except Exception as e: | ||||
|             # rollback db transaction if any exception occurs | ||||
|             transaction.set_rollback(True) | ||||
|             return notify_error(str(e)) | ||||
|  | ||||
|  | ||||
| class GetAllowedValues(APIView): | ||||
|     permission_classes = [IsAuthenticated, GenerateReportPerms] | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         variables = request.data.get("variables", None) | ||||
|         if variables is None: | ||||
|             return notify_error("'variables' is required") | ||||
|  | ||||
|         dependencies = request.data.get("dependencies", None) | ||||
|  | ||||
|         # process variables and dependencies | ||||
|         variables = prep_variables_for_template( | ||||
|             variables=variables, | ||||
|             dependencies=dependencies, | ||||
|             limit_query_results=1,  # only get first item for querysets | ||||
|         ) | ||||
|  | ||||
|         if variables: | ||||
|             return Response(self._get_dot_notation(variables)) | ||||
|  | ||||
|         return Response() | ||||
|  | ||||
|     # recursive function to get properties on any embedded objects | ||||
|     def _get_dot_notation( | ||||
|         self, d: Dict[str, Any], parent_key: str = "", path: Optional[str] = None | ||||
|     ) -> Dict[str, Any]: | ||||
|         items = {} | ||||
|         for k, v in d.items(): | ||||
|             new_key = f"{parent_key}.{k}" if parent_key else k | ||||
|             if isinstance(v, dict): | ||||
|                 items[new_key] = "Object" | ||||
|                 items.update(self._get_dot_notation(v, new_key, path=path)) | ||||
|             elif isinstance(v, list) or type(v).__name__ == "PermissionQuerySet": | ||||
|                 items[new_key] = f"Array ({len(v)} Results)" | ||||
|                 if v:  # Ensure the list is not empty | ||||
|                     item = v[0] | ||||
|                     if isinstance(item, dict): | ||||
|                         items.update( | ||||
|                             self._get_dot_notation(item, f"{new_key}[0]", path=path) | ||||
|                         ) | ||||
|                     else: | ||||
|                         items[f"{new_key}[0]"] = type(item).__name__ | ||||
|  | ||||
|             else: | ||||
|                 items[new_key] = type(v).__name__ | ||||
|         return items | ||||
|  | ||||
|  | ||||
| class SharedTemplatesRepo(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         try: | ||||
|             url = "https://raw.githubusercontent.com/amidaware/reporting-templates/master/index.json" | ||||
|             response = requests.get(url, timeout=15) | ||||
|             files = response.json() | ||||
|             return Response( | ||||
|                 [ | ||||
|                     {"name": file["name"], "url": file["download_url"]} | ||||
|                     for file in files | ||||
|                     if file["download_url"] | ||||
|                 ] | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             return notify_error(str(e)) | ||||
|  | ||||
|     @transaction.atomic | ||||
|     def post(self, request: Request) -> Response: | ||||
|         overwrite = request.data.get("overwrite", False) | ||||
|         templates = request.data.get("templates", None) | ||||
|  | ||||
|         if not templates: | ||||
|             return notify_error("No templates to import") | ||||
|  | ||||
|         try: | ||||
|             for template in templates: | ||||
|                 response = requests.get(template["url"], timeout=10) | ||||
|                 template_obj = response.json() | ||||
|  | ||||
|                 # import base template if exists | ||||
|                 base_template_id = _import_base_template( | ||||
|                     template_obj.get("base_template"), overwrite | ||||
|                 ) | ||||
|  | ||||
|                 # import template if exists | ||||
|                 _import_report_template( | ||||
|                     template_obj.get("template"), base_template_id, overwrite | ||||
|                 ) | ||||
|  | ||||
|                 # import assets if exists | ||||
|                 _import_assets(template_obj.get("assets")) | ||||
|  | ||||
|             return Response() | ||||
|  | ||||
|         except Exception as e: | ||||
|             # rollback db transaction if any exception occurs | ||||
|             transaction.set_rollback(True) | ||||
|             return notify_error(str(e)) | ||||
|  | ||||
|  | ||||
| class GetReportAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         path = request.query_params.get("path", "").lstrip("/") | ||||
|  | ||||
|         try: | ||||
|             directories, files = report_assets_fs.listdir(path) | ||||
|         except FileNotFoundError: | ||||
|             return notify_error("The path is invalid") | ||||
|  | ||||
|         response = [] | ||||
|  | ||||
|         # parse directories | ||||
|         for foldername in directories: | ||||
|             relpath = os.path.join(path, foldername) | ||||
|             response.append( | ||||
|                 { | ||||
|                     "name": foldername, | ||||
|                     "path": relpath, | ||||
|                     "type": "folder", | ||||
|                     "size": None, | ||||
|                     "url": report_assets_fs.url(relpath), | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
|         # parse files | ||||
|         for filename in files: | ||||
|             relpath = os.path.join(path, filename) | ||||
|             response.append( | ||||
|                 { | ||||
|                     "name": filename, | ||||
|                     "path": relpath, | ||||
|                     "type": "file", | ||||
|                     "size": str(report_assets_fs.size(relpath)), | ||||
|                     "url": report_assets_fs.url(relpath), | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
|         return Response(response) | ||||
|  | ||||
|  | ||||
| class GetAllAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         only_folders = request.query_params.get("onlyFolders", None) | ||||
|         only_folders = True if only_folders and only_folders == "true" else False | ||||
|  | ||||
|         try: | ||||
|             os.chdir(report_assets_fs.base_location) | ||||
|             response = [self._walk_folder_and_return_node(".", only_folders)] | ||||
|             return Response(response) | ||||
|         except FileNotFoundError: | ||||
|             return notify_error("Unable to process request") | ||||
|  | ||||
|     # TODO: define a Type for file node | ||||
|     def _walk_folder_and_return_node(self, path: str, only_folders: bool = False): | ||||
|         # pull report assets from the database so we can pair with the file system assets | ||||
|         assets = ReportAsset.objects.all() | ||||
|  | ||||
|         for current_dir, subdirs, files in os.walk(path): | ||||
|             current_dir = "Report Assets" if current_dir == "." else current_dir | ||||
|             node = { | ||||
|                 "type": "folder", | ||||
|                 "name": current_dir.replace("./", ""), | ||||
|                 "path": path.replace("./", ""), | ||||
|                 "children": [], | ||||
|                 "selectable": False, | ||||
|                 "icon": "folder", | ||||
|                 "iconColor": "yellow-9", | ||||
|             } | ||||
|             for dirname in subdirs: | ||||
|                 dirpath = f"{path}/{dirname}" | ||||
|                 node["children"].append( | ||||
|                     # recursively call | ||||
|                     self._walk_folder_and_return_node(dirpath, only_folders) | ||||
|                 ) | ||||
|  | ||||
|             if not only_folders: | ||||
|                 for filename in files: | ||||
|                     path = f"{current_dir}/{filename}".replace("./", "").replace( | ||||
|                         "Report Assets/", "" | ||||
|                     ) | ||||
|                     try: | ||||
|                         # need to remove the relative path | ||||
|                         id = assets.get(file=path).id | ||||
|                         node["children"].append( | ||||
|                             { | ||||
|                                 "id": id, | ||||
|                                 "type": "file", | ||||
|                                 "name": filename, | ||||
|                                 "path": path, | ||||
|                                 "icon": "description", | ||||
|                             } | ||||
|                         ) | ||||
|                     except ReportAsset.DoesNotExist: | ||||
|                         pass | ||||
|  | ||||
|             return node | ||||
|  | ||||
|  | ||||
| class RenameReportAsset(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     class InputRequest: | ||||
|         path: str | ||||
|         newName: str | ||||
|  | ||||
|     class InputSerializer(Serializer[InputRequest]): | ||||
|         path = CharField(required=True, validators=[path_exists]) | ||||
|         newName = CharField(required=True) | ||||
|  | ||||
|     def put(self, request: Request) -> Response: | ||||
|         serializer = self.InputSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         old_path = serializer.data["path"] | ||||
|         new_name = serializer.data["newName"] | ||||
|  | ||||
|         # make sure absolute path isn't processed | ||||
|         old_path = old_path.lstrip("/") if old_path else "" | ||||
|  | ||||
|         try: | ||||
|             name = report_assets_fs.rename(path=old_path, new_name=new_name) | ||||
|  | ||||
|             if report_assets_fs.isfile(path=name): | ||||
|                 asset = ReportAsset.objects.get(file=old_path) | ||||
|                 asset.file.name = name | ||||
|                 asset.save() | ||||
|  | ||||
|             return Response(name) | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class CreateAssetFolder(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         path = request.data["path"].lstrip("/") if "path" in request.data else "" | ||||
|  | ||||
|         if not path: | ||||
|             return notify_error("'path' in required.") | ||||
|         try: | ||||
|             new_path = report_assets_fs.createfolder(path=path) | ||||
|             return Response(new_path) | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class DeleteAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     class InputRequest: | ||||
|         paths: List[str] | ||||
|  | ||||
|     class InputSerializer(Serializer[InputRequest]): | ||||
|         paths = ListField(required=True) | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         serializer = self.InputSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         paths = serializer.data["paths"] | ||||
|  | ||||
|         try: | ||||
|             for path in paths: | ||||
|                 path = path.lstrip("/") if path else "" | ||||
|                 if report_assets_fs.isdir(path=path): | ||||
|                     shutil.rmtree(report_assets_fs.path(path)) | ||||
|                     ReportAsset.objects.filter(file__startswith=f"{path}/").delete() | ||||
|                 else: | ||||
|                     try: | ||||
|                         asset = ReportAsset.objects.get(file=path) | ||||
|                         asset.file.delete() | ||||
|                         asset.delete() | ||||
|                     except ObjectDoesNotExist: | ||||
|                         report_assets_fs.delete(path) | ||||
|  | ||||
|             return Response() | ||||
|  | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class UploadAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         path = ( | ||||
|             request.data["parentPath"].lstrip("/") | ||||
|             if "parentPath" in request.data | ||||
|             else "" | ||||
|         ) | ||||
|  | ||||
|         try: | ||||
|             response = {} | ||||
|  | ||||
|             # make sure this is actually a directory | ||||
|             if report_assets_fs.isdir(path=path): | ||||
|                 for filename in request.FILES: | ||||
|                     asset = ReportAsset(file=request.FILES[filename]) | ||||
|                     asset.file.name = os.path.join(path, filename) | ||||
|                     asset.save() | ||||
|  | ||||
|                     asset.refresh_from_db() | ||||
|  | ||||
|                     response[filename] = { | ||||
|                         "id": asset.id, | ||||
|                         "filename": asset.file.name, | ||||
|                     } | ||||
|  | ||||
|                 return Response(response) | ||||
|             else: | ||||
|                 return notify_error("parentPath doesn't point to a directory") | ||||
|  | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class DownloadAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Union[Response, FileResponse]: | ||||
|         path = request.query_params.get("path", "") | ||||
|  | ||||
|         # make sure absolute path isn't processed | ||||
|         path = path.lstrip("/") if path else "" | ||||
|  | ||||
|         try: | ||||
|             full_path = report_assets_fs.path(name=path) | ||||
|             if report_assets_fs.isdir(path=path): | ||||
|                 zip_path = shutil.make_archive( | ||||
|                     base_name=f"{report_assets_fs.path(name=path)}.zip", | ||||
|                     format="zip", | ||||
|                     root_dir=full_path, | ||||
|                 ) | ||||
|  | ||||
|                 response = FileResponse( | ||||
|                     open(zip_path, "rb"), | ||||
|                     as_attachment=True, | ||||
|                     filename=zip_path.split("/")[-1], | ||||
|                 ) | ||||
|  | ||||
|                 os.remove(zip_path) | ||||
|  | ||||
|                 return response | ||||
|             else: | ||||
|                 return FileResponse( | ||||
|                     open(full_path, "rb"), | ||||
|                     as_attachment=True, | ||||
|                     filename=full_path.split("/")[-1], | ||||
|                 ) | ||||
|  | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class MoveAssets(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     class InputRequest: | ||||
|         srcPaths: List[str] | ||||
|         destination: str | ||||
|  | ||||
|     class InputSerializer(Serializer[InputRequest]): | ||||
|         srcPaths = ListField(required=True) | ||||
|         destination = CharField(required=True, validators=[path_exists]) | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         serializer = self.InputSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         paths = serializer.data["srcPaths"] | ||||
|         destination = serializer.data["destination"] | ||||
|  | ||||
|         try: | ||||
|             response = {} | ||||
|             for path in paths: | ||||
|                 new_path = report_assets_fs.move(source=path, destination=destination) | ||||
|  | ||||
|                 response["path"] = new_path | ||||
|  | ||||
|             return Response(response) | ||||
|  | ||||
|         except OSError as error: | ||||
|             return notify_error(str(error)) | ||||
|         except SuspiciousFileOperation as error: | ||||
|             return notify_error(str(error)) | ||||
|  | ||||
|  | ||||
| class ReportHTMLTemplateSerializer(ModelSerializer[ReportHTMLTemplate]): | ||||
|     class Meta: | ||||
|         model = ReportHTMLTemplate | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class GetAddReportHTMLTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         reports = ReportHTMLTemplate.objects.all() | ||||
|         return Response(ReportHTMLTemplateSerializer(reports, many=True).data) | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         serializer = ReportHTMLTemplateSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportHTMLTemplateSerializer(response).data) | ||||
|  | ||||
|  | ||||
| class GetEditDeleteReportHTMLTemplate(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportHTMLTemplate, pk=pk) | ||||
|  | ||||
|         return Response(ReportHTMLTemplateSerializer(template).data) | ||||
|  | ||||
|     def put(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportHTMLTemplate, pk=pk) | ||||
|  | ||||
|         serializer = ReportHTMLTemplateSerializer( | ||||
|             instance=template, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportHTMLTemplateSerializer(response).data) | ||||
|  | ||||
|     def delete(self, request: Request, pk: int) -> Response: | ||||
|         get_object_or_404(ReportHTMLTemplate, pk=pk).delete() | ||||
|  | ||||
|         return Response() | ||||
|  | ||||
|  | ||||
| class ReportDataQuerySerializer(ModelSerializer[ReportDataQuery]): | ||||
|     class Meta: | ||||
|         model = ReportDataQuery | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class GetAddReportDataQuery(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request) -> Response: | ||||
|         reports = ReportDataQuery.objects.all() | ||||
|         return Response(ReportDataQuerySerializer(reports, many=True).data) | ||||
|  | ||||
|     def post(self, request: Request) -> Response: | ||||
|         serializer = ReportDataQuerySerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportDataQuerySerializer(response).data) | ||||
|  | ||||
|  | ||||
| class GetEditDeleteReportDataQuery(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportDataQuery, pk=pk) | ||||
|  | ||||
|         return Response(ReportDataQuerySerializer(template).data) | ||||
|  | ||||
|     def put(self, request: Request, pk: int) -> Response: | ||||
|         template = get_object_or_404(ReportDataQuery, pk=pk) | ||||
|  | ||||
|         serializer = ReportDataQuerySerializer( | ||||
|             instance=template, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         response = serializer.save() | ||||
|  | ||||
|         return Response(ReportDataQuerySerializer(response).data) | ||||
|  | ||||
|     def delete(self, request: Request, pk: int) -> Response: | ||||
|         get_object_or_404(ReportDataQuery, pk=pk).delete() | ||||
|  | ||||
|         return Response() | ||||
|  | ||||
|  | ||||
| class NginxRedirect(APIView): | ||||
|     permission_classes = (AllowAny,) | ||||
|  | ||||
|     def get(self, request: Request, path: str) -> HttpResponse: | ||||
|         id = request.query_params.get("id", "") | ||||
|         try: | ||||
|             asset_uuid = uuid.UUID(id, version=4) | ||||
|             asset = get_object_or_404(ReportAsset, id=asset_uuid) | ||||
|             new_path = path.split("?")[0] | ||||
|             if asset.file.name == new_path: | ||||
|                 response = HttpResponse(status=200) | ||||
|                 response["X-Accel-Redirect"] = "/assets/" + new_path | ||||
|                 return response | ||||
|             else: | ||||
|                 raise PermissionDenied() | ||||
|         except ValueError: | ||||
|             return notify_error("There was a error processing the request") | ||||
|  | ||||
|  | ||||
| class QuerySchema(APIView): | ||||
|     permission_classes = [IsAuthenticated, ReportingPerms] | ||||
|  | ||||
|     def get(self, request): | ||||
|         schema_path = "static/reporting/schemas/query_schema.json" | ||||
|  | ||||
|         if djangosettings.DEBUG: | ||||
|             try: | ||||
|                 with open(djangosettings.BASE_DIR / schema_path, "r") as f: | ||||
|                     data = json.load(f) | ||||
|  | ||||
|                 return JsonResponse(data) | ||||
|             except FileNotFoundError: | ||||
|                 return notify_error("There was an error getting the file") | ||||
|         else: | ||||
|             response = HttpResponse() | ||||
|             response["X-Accel-Redirect"] = f"/{schema_path}" | ||||
|             return response | ||||
| @@ -1,4 +1,5 @@ | ||||
| black | ||||
| daphne==4.0.0 | ||||
| Werkzeug | ||||
| django-extensions | ||||
| isort | ||||
| @@ -6,6 +7,7 @@ types-pytz | ||||
| django-silk | ||||
| mypy | ||||
| django-stubs | ||||
| pandas-stubs | ||||
| djangorestframework-stubs | ||||
| django-types | ||||
| djangorestframework-types | ||||
| @@ -15,3 +17,4 @@ types-Markdown | ||||
| types-requests | ||||
| types-PyYAML | ||||
| types-urllib3 | ||||
| types-Jinja2 | ||||
| @@ -6,4 +6,5 @@ pytest-django | ||||
| pytest-xdist | ||||
| pytest-cov | ||||
| refurb | ||||
| flake8 | ||||
| flake8 | ||||
| daphne==4.0.0 | ||||
| @@ -1,27 +1,27 @@ | ||||
| adrf==0.1.1 | ||||
| adrf==0.1.2 | ||||
| asgiref==3.7.2 | ||||
| celery==5.3.1 | ||||
| certifi==2023.7.22 | ||||
| cffi==1.15.1 | ||||
| channels==4.0.0 | ||||
| channels_redis==4.1.0 | ||||
| cryptography==41.0.3 | ||||
| daphne==4.0.0 | ||||
| Django==4.2.4 | ||||
| django-cors-headers==4.2.0 | ||||
| cryptography==41.0.5 | ||||
| Django==4.2.6 | ||||
| django-cors-headers==4.3.0 | ||||
| django-filter==23.3 | ||||
| django-ipware==5.0.0 | ||||
| django-rest-knox==4.2.0 | ||||
| djangorestframework==3.14.0 | ||||
| drf-spectacular==0.26.4 | ||||
| drf-spectacular==0.26.5 | ||||
| hiredis==2.2.3 | ||||
| meshctrl==0.1.15 | ||||
| msgpack==1.0.5 | ||||
| nats-py==2.3.1 | ||||
| packaging==23.1 | ||||
| psutil==5.9.5 | ||||
| psycopg[binary]==3.1.10 | ||||
| msgpack==1.0.7 | ||||
| nats-py==2.6.0 | ||||
| packaging==23.2 | ||||
| psutil==5.9.6 | ||||
| psycopg[binary]==3.1.12 | ||||
| pycparser==2.21 | ||||
| pycryptodome==3.18.0 | ||||
| pycryptodome==3.19.0 | ||||
| pyotp==2.9.0 | ||||
| pyparsing==3.1.1 | ||||
| pytz==2023.3 | ||||
| @@ -30,10 +30,18 @@ redis==4.5.5 | ||||
| requests==2.31.0 | ||||
| six==1.16.0 | ||||
| sqlparse==0.4.4 | ||||
| twilio==8.5.0 | ||||
| urllib3==2.0.4 | ||||
| twilio==8.10.0 | ||||
| urllib3==2.0.7 | ||||
| uvicorn[standard]==0.23.2 | ||||
| uWSGI==2.0.22 | ||||
| validators==0.20.0 | ||||
| vine==5.0.0 | ||||
| websockets==11.0.3 | ||||
| zipp==3.16.2 | ||||
| zipp==3.17.0 | ||||
| pandas==2.1.2 | ||||
| kaleido==0.2.1 | ||||
| jinja2==3.1.2 | ||||
| markdown==3.5 | ||||
| plotly==5.18.0 | ||||
| weasyprint==60.1 | ||||
| ocxsect==0.1.5 | ||||
| @@ -9,7 +9,7 @@ from django.db.models.fields import CharField, TextField | ||||
|  | ||||
| from logs.models import BaseAuditModel | ||||
| from tacticalrmm.constants import ScriptShell, ScriptType | ||||
| from tacticalrmm.utils import replace_db_values | ||||
| from tacticalrmm.utils import replace_arg_db_values | ||||
|  | ||||
|  | ||||
| class Script(BaseAuditModel): | ||||
| @@ -194,6 +194,7 @@ class Script(BaseAuditModel): | ||||
|         return ScriptSerializer(script).data | ||||
|  | ||||
|     @classmethod | ||||
|     # TODO refactor common functionality of parse functions | ||||
|     def parse_script_args(cls, agent, shell: str, args: List[str] = []) -> list: | ||||
|         if not args: | ||||
|             return [] | ||||
| @@ -204,11 +205,10 @@ class Script(BaseAuditModel): | ||||
|         pattern = re.compile(".*\\{\\{(.*)\\}\\}.*") | ||||
|  | ||||
|         for arg in args: | ||||
|             match = pattern.match(arg) | ||||
|             if match: | ||||
|             if match := pattern.match(arg): | ||||
|                 # only get the match between the () in regex | ||||
|                 string = match.group(1) | ||||
|                 value = replace_db_values( | ||||
|                 value = replace_arg_db_values( | ||||
|                     string=string, | ||||
|                     instance=agent, | ||||
|                     shell=shell, | ||||
| @@ -231,6 +231,42 @@ class Script(BaseAuditModel): | ||||
|  | ||||
|         return temp_args | ||||
|  | ||||
|     @classmethod | ||||
|     # TODO refactor common functionality of parse functions | ||||
|     def parse_script_env_vars(cls, agent, shell: str, env_vars: list[str] = []) -> list: | ||||
|         if not env_vars: | ||||
|             return [] | ||||
|  | ||||
|         temp_env_vars = [] | ||||
|         pattern = re.compile(".*\\{\\{(.*)\\}\\}.*") | ||||
|         for env_var in env_vars: | ||||
|             # must be in format KEY=VALUE | ||||
|             try: | ||||
|                 env_key = env_var.split("=")[0] | ||||
|                 env_val = env_var.split("=")[1] | ||||
|             except: | ||||
|                 continue | ||||
|             if match := pattern.match(env_val): | ||||
|                 string = match.group(1) | ||||
|                 value = replace_arg_db_values( | ||||
|                     string=string, | ||||
|                     instance=agent, | ||||
|                     shell=shell, | ||||
|                     quotes=False, | ||||
|                 ) | ||||
|  | ||||
|                 if value: | ||||
|                     try: | ||||
|                         new_val = re.sub("\\{\\{.*\\}\\}", value, env_val) | ||||
|                     except re.error: | ||||
|                         new_val = re.sub("\\{\\{.*\\}\\}", re.escape(value), env_val) | ||||
|                     temp_env_vars.append(f"{env_key}={new_val}") | ||||
|             else: | ||||
|                 # pass parameter unaltered | ||||
|                 temp_env_vars.append(env_var) | ||||
|  | ||||
|         return temp_env_vars | ||||
|  | ||||
|  | ||||
| class ScriptSnippet(models.Model): | ||||
|     name = CharField(max_length=40, unique=True) | ||||
|   | ||||
| @@ -77,7 +77,7 @@ def bulk_script_task( | ||||
|                 "shell": script.shell, | ||||
|             }, | ||||
|             "run_as_user": run_as_user, | ||||
|             "env_vars": env_vars, | ||||
|             "env_vars": script.parse_script_env_vars(agent, script.shell, env_vars), | ||||
|         } | ||||
|         tup = (agent.agent_id, data) | ||||
|         items.append(tup) | ||||
|   | ||||
| @@ -242,6 +242,25 @@ class TestScriptViews(TacticalTestCase): | ||||
|             Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args), | ||||
|         ) | ||||
|  | ||||
|     def test_script_env_vars_variable_replacement(self): | ||||
|         agent = baker.make_recipe("agents.agent", public_ip="12.12.12.12") | ||||
|         env_vars = [ | ||||
|             "PUBIP={{agent.public_ip}}", | ||||
|             "123CLIENT={{client.name}}", | ||||
|             "FOOBARSITE={{site.name}}", | ||||
|         ] | ||||
|  | ||||
|         self.assertEqual( | ||||
|             [ | ||||
|                 "PUBIP=12.12.12.12", | ||||
|                 f"123CLIENT={agent.client.name}", | ||||
|                 f"FOOBARSITE={agent.site.name}", | ||||
|             ], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def test_script_arg_replacement_custom_field(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
| @@ -272,6 +291,40 @@ class TestScriptViews(TacticalTestCase): | ||||
|             Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args), | ||||
|         ) | ||||
|  | ||||
|     def test_script_env_vars_replacement_custom_field(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
|             "core.CustomField", | ||||
|             name="Test Field", | ||||
|             model=CustomFieldModel.AGENT, | ||||
|             type=CustomFieldType.TEXT, | ||||
|             default_value_string="DEFAULT", | ||||
|         ) | ||||
|  | ||||
|         env_vars = ["FOOBAR={{agent.Test Field}}"] | ||||
|  | ||||
|         # test default value | ||||
|         self.assertEqual( | ||||
|             ["FOOBAR=DEFAULT"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         # test with set value | ||||
|         baker.make( | ||||
|             "agents.AgentCustomField", | ||||
|             field=field, | ||||
|             agent=agent, | ||||
|             string_value="CUSTOM VALUE", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             ["FOOBAR=CUSTOM VALUE"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def test_script_arg_replacement_client_custom_fields(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
| @@ -302,6 +355,42 @@ class TestScriptViews(TacticalTestCase): | ||||
|             Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args), | ||||
|         ) | ||||
|  | ||||
|     def test_script_env_vars_replacement_client_custom_fields(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
|             "core.CustomField", | ||||
|             name="test123", | ||||
|             model=CustomFieldModel.CLIENT, | ||||
|             type=CustomFieldType.TEXT, | ||||
|             default_value_string="https://a1234lkasd.asdinasd234.com/ask2348uASDlk234@!#$@#asd1dsf", | ||||
|         ) | ||||
|  | ||||
|         env_vars = ["FOOBAR={{client.test123}}"] | ||||
|  | ||||
|         # test default value | ||||
|         self.assertEqual( | ||||
|             ["FOOBAR=https://a1234lkasd.asdinasd234.com/ask2348uASDlk234@!#$@#asd1dsf"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         # test with set value | ||||
|         baker.make( | ||||
|             "clients.ClientCustomField", | ||||
|             field=field, | ||||
|             client=agent.client, | ||||
|             string_value="uASdklj23487ASDkjhr345il987UASXK<DFOIul32oi454329837492384512342134!@#!@#ADSFW45X", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             [ | ||||
|                 "FOOBAR=uASdklj23487ASDkjhr345il987UASXK<DFOIul32oi454329837492384512342134!@#!@#ADSFW45X" | ||||
|             ], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def test_script_arg_replacement_site_custom_fields(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
| @@ -350,6 +439,51 @@ class TestScriptViews(TacticalTestCase): | ||||
|             Script.parse_script_args(agent=agent, shell=ScriptShell.PYTHON, args=args), | ||||
|         ) | ||||
|  | ||||
|     def test_script_env_vars_replacement_site_custom_fields(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
|             "core.CustomField", | ||||
|             name="ffas2345asdasasdWEdd", | ||||
|             model=CustomFieldModel.SITE, | ||||
|             type=CustomFieldType.TEXT, | ||||
|             default_value_string="https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD", | ||||
|         ) | ||||
|  | ||||
|         env_vars = ["ASD45ASDKJASHD={{site.ffas2345asdasasdWEdd}}"] | ||||
|  | ||||
|         # test default value | ||||
|         self.assertEqual( | ||||
|             ["ASD45ASDKJASHD=https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         # test with set value | ||||
|         value = baker.make( | ||||
|             "clients.SiteCustomField", | ||||
|             field=field, | ||||
|             site=agent.site, | ||||
|             string_value="g435asdASD2354SDFasdfsdf", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             ["ASD45ASDKJASHD=g435asdASD2354SDFasdfsdf"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         # test with set but empty field value | ||||
|         value.string_value = "" | ||||
|         value.save() | ||||
|  | ||||
|         self.assertEqual( | ||||
|             ["ASD45ASDKJASHD=https://site.easkdjas.com/asik2348aSDH234RJKADBCA%123SAD"], | ||||
|             Script.parse_script_env_vars( | ||||
|                 agent=agent, shell=ScriptShell.POWERSHELL, env_vars=env_vars | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def test_script_arg_replacement_array_fields(self): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         field = baker.make( | ||||
|   | ||||
| @@ -148,6 +148,9 @@ class TestScript(APIView): | ||||
|         parsed_args = Script.parse_script_args( | ||||
|             agent, request.data["shell"], request.data["args"] | ||||
|         ) | ||||
|         parsed_env_vars = Script.parse_script_env_vars( | ||||
|             agent, request.data["shell"], request.data["env_vars"] | ||||
|         ) | ||||
|  | ||||
|         data = { | ||||
|             "func": "runscript", | ||||
| @@ -158,7 +161,7 @@ class TestScript(APIView): | ||||
|                 "shell": request.data["shell"], | ||||
|             }, | ||||
|             "run_as_user": request.data["run_as_user"], | ||||
|             "env_vars": request.data["env_vars"], | ||||
|             "env_vars": parsed_env_vars, | ||||
|         } | ||||
|  | ||||
|         r = asyncio.run( | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -450,4 +450,6 @@ CONFIG_MGMT_CMDS = ( | ||||
|     "meshuser", | ||||
|     "meshtoken", | ||||
|     "meshdomain", | ||||
|     "certfile", | ||||
|     "keyfile", | ||||
| ) | ||||
|   | ||||
| @@ -42,6 +42,13 @@ def get_nats_ports() -> tuple[int, int]: | ||||
|     return nats_standard_port, nats_websocket_port | ||||
|  | ||||
|  | ||||
| def get_nats_internal_protocol() -> str: | ||||
|     if getattr(settings, "TRMM_INSECURE", False): | ||||
|         return "nats" | ||||
|  | ||||
|     return "tls" | ||||
|  | ||||
|  | ||||
| def date_is_in_past(*, datetime_obj: "datetime", agent_tz: str) -> bool: | ||||
|     """ | ||||
|     datetime_obj must be a naive datetime | ||||
| @@ -66,8 +73,9 @@ def rand_range(min: int, max: int) -> float: | ||||
|  | ||||
| def setup_nats_options() -> dict[str, Any]: | ||||
|     nats_std_port, _ = get_nats_ports() | ||||
|     proto = get_nats_internal_protocol() | ||||
|     opts = { | ||||
|         "servers": f"tls://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}", | ||||
|         "servers": f"{proto}://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}", | ||||
|         "user": "tacticalrmm", | ||||
|         "name": "trmm-django", | ||||
|         "password": settings.SECRET_KEY, | ||||
|   | ||||
| @@ -20,27 +20,27 @@ MAC_UNINSTALL = BASE_DIR / "core" / "mac_uninstall.sh" | ||||
| AUTH_USER_MODEL = "accounts.User" | ||||
|  | ||||
| # latest release | ||||
| TRMM_VERSION = "0.16.1" | ||||
| TRMM_VERSION = "0.17.0" | ||||
|  | ||||
| # https://github.com/amidaware/tacticalrmm-web | ||||
| WEB_VERSION = "0.101.28" | ||||
| WEB_VERSION = "0.101.34" | ||||
|  | ||||
| # bump this version everytime vue code is changed | ||||
| # to alert user they need to manually refresh their browser | ||||
| APP_VER = "0.0.183" | ||||
| APP_VER = "0.0.186" | ||||
|  | ||||
| # https://github.com/amidaware/rmmagent | ||||
| LATEST_AGENT_VER = "2.4.10" | ||||
| LATEST_AGENT_VER = "2.5.0" | ||||
|  | ||||
| MESH_VER = "1.1.9" | ||||
|  | ||||
| NATS_SERVER_VER = "2.9.21" | ||||
| NATS_SERVER_VER = "2.10.4" | ||||
|  | ||||
| # for the update script, bump when need to recreate venv | ||||
| PIP_VER = "38" | ||||
| PIP_VER = "39" | ||||
|  | ||||
| SETUPTOOLS_VER = "68.0.0" | ||||
| WHEEL_VER = "0.41.1" | ||||
| SETUPTOOLS_VER = "68.2.2" | ||||
| WHEEL_VER = "0.41.3" | ||||
|  | ||||
| AGENT_BASE_URL = "https://agents.tacticalrmm.com" | ||||
|  | ||||
| @@ -77,6 +77,8 @@ with suppress(ImportError): | ||||
| CHECK_TOKEN_URL = f"{AGENT_BASE_URL}/api/v2/checktoken" | ||||
| AGENTS_URL = f"{AGENT_BASE_URL}/api/v2/agents/?" | ||||
| EXE_GEN_URL = f"{AGENT_BASE_URL}/api/v2/exe" | ||||
| REPORTING_CHECK_URL = f"{AGENT_BASE_URL}/api/v2/reporting/check" | ||||
| REPORTING_DL_URL = f"{AGENT_BASE_URL}/api/v2/reporting/download/?" | ||||
|  | ||||
| if "GHACTIONS" in os.environ: | ||||
|     DEBUG = False | ||||
| @@ -106,7 +108,6 @@ if not DEBUG: | ||||
|     ) | ||||
|  | ||||
| INSTALLED_APPS = [ | ||||
|     "daphne", | ||||
|     "django.contrib.auth", | ||||
|     "django.contrib.contenttypes", | ||||
|     "django.contrib.sessions", | ||||
| @@ -130,6 +131,7 @@ INSTALLED_APPS = [ | ||||
|     "logs", | ||||
|     "scripts", | ||||
|     "alerts", | ||||
|     "ee.reporting", | ||||
| ] | ||||
|  | ||||
| CHANNEL_LAYERS = { | ||||
| @@ -174,6 +176,7 @@ if SWAGGER_ENABLED: | ||||
|     INSTALLED_APPS += ("drf_spectacular",) | ||||
|  | ||||
| if DEBUG and not DEMO: | ||||
|     INSTALLED_APPS.insert(0, "daphne") | ||||
|     INSTALLED_APPS += ( | ||||
|         "django_extensions", | ||||
|         "silk", | ||||
|   | ||||
| @@ -38,8 +38,12 @@ urlpatterns = [ | ||||
|     path("scripts/", include("scripts.urls")), | ||||
|     path("alerts/", include("alerts.urls")), | ||||
|     path("accounts/", include("accounts.urls")), | ||||
|     path("reporting/", include("ee.reporting.urls")), | ||||
| ] | ||||
|  | ||||
| if getattr(settings, "BETA_API_ENABLED", False): | ||||
|     urlpatterns += (path("beta/v1/", include("beta.v1.urls")),) | ||||
|  | ||||
| if getattr(settings, "ADMIN_ENABLED", False): | ||||
|     from django.contrib import admin | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import time | ||||
| from concurrent.futures import ThreadPoolExecutor | ||||
| from contextlib import contextmanager | ||||
| from functools import wraps | ||||
| from typing import List, Optional, Union | ||||
| from typing import List, Optional, Union, Literal, TYPE_CHECKING | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| import requests | ||||
| @@ -34,7 +34,15 @@ from tacticalrmm.constants import ( | ||||
|     DebugLogType, | ||||
|     ScriptShell, | ||||
| ) | ||||
| from tacticalrmm.helpers import get_certs, get_nats_ports, notify_error | ||||
| from tacticalrmm.helpers import ( | ||||
|     get_certs, | ||||
|     get_nats_internal_protocol, | ||||
|     get_nats_ports, | ||||
|     notify_error, | ||||
| ) | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from clients.models import Site, Client | ||||
|  | ||||
|  | ||||
| def generate_winagent_exe( | ||||
| @@ -204,10 +212,6 @@ def reload_nats() -> None: | ||||
|     nats_std_port, nats_ws_port = get_nats_ports() | ||||
|  | ||||
|     config = { | ||||
|         "tls": { | ||||
|             "cert_file": cert_file, | ||||
|             "key_file": key_file, | ||||
|         }, | ||||
|         "authorization": {"users": users}, | ||||
|         "max_payload": 67108864, | ||||
|         "port": nats_std_port,  # internal only | ||||
| @@ -217,6 +221,12 @@ def reload_nats() -> None: | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     if get_nats_internal_protocol() == "tls": | ||||
|         config["tls"] = { | ||||
|             "cert_file": cert_file, | ||||
|             "key_file": key_file, | ||||
|         } | ||||
|  | ||||
|     if "NATS_HTTP_PORT" in os.environ: | ||||
|         config["http_port"] = int(os.getenv("NATS_HTTP_PORT"))  # type: ignore | ||||
|     elif hasattr(settings, "NATS_HTTP_PORT"): | ||||
| @@ -280,131 +290,117 @@ def get_latest_trmm_ver() -> str: | ||||
|     return "error" | ||||
|  | ||||
|  | ||||
| def replace_db_values( | ||||
|     string: str, instance=None, shell: str = None, quotes=True  # type:ignore | ||||
| ) -> Union[str, None]: | ||||
|     from clients.models import Client, Site | ||||
| # Receives something like {{ client.name }} and a Model instance of Client, Site, or Agent. If an | ||||
| # agent instance is passed it will resolve the value of agent.client.name and return the agent's client name. | ||||
| # | ||||
| # You can query custom fields by using their name. {{ site.Custom Field Name }} | ||||
| # | ||||
| # This will recursively lookup values for relations. {{ client.site.id }} | ||||
| # | ||||
| # You can also use {{ global.value }} without an obj instance to use the global key store | ||||
| def get_db_value( | ||||
|     *, string: str, instance: Optional[Union["Agent", "Client", "Site"]] = None | ||||
| ) -> Union[str, List[str], Literal[True], Literal[False], None]: | ||||
|     from core.models import CustomField, GlobalKVStore | ||||
|  | ||||
|     # split by period if exists. First should be model and second should be property i.e {{client.name}} | ||||
|     temp = string.split(".") | ||||
|  | ||||
|     # check for model and property | ||||
|     if len(temp) < 2: | ||||
|         # ignore arg since it is invalid | ||||
|         return "" | ||||
|     # get properties into an array | ||||
|     props = string.strip().split(".") | ||||
|  | ||||
|     # value is in the global keystore and replace value | ||||
|     if temp[0] == "global": | ||||
|         if GlobalKVStore.objects.filter(name=temp[1]).exists(): | ||||
|             value = GlobalKVStore.objects.get(name=temp[1]).value | ||||
|  | ||||
|             return f"'{value}'" if quotes else value | ||||
|         else: | ||||
|     if props[0] == "global" and len(props) == 2: | ||||
|         try: | ||||
|             return GlobalKVStore.objects.get(name=props[1]).value | ||||
|         except GlobalKVStore.DoesNotExist: | ||||
|             DebugLog.error( | ||||
|                 log_type=DebugLogType.SCRIPTING, | ||||
|                 message=f"Couldn't lookup value for: {string}. Make sure it exists in CoreSettings > Key Store",  # type:ignore | ||||
|                 message=f"Couldn't lookup value for: {string}. Make sure it exists in CoreSettings > Key Store", | ||||
|             ) | ||||
|             return "" | ||||
|             return None | ||||
|  | ||||
|     if not instance: | ||||
|         # instance must be set if not global property | ||||
|         return "" | ||||
|         return None | ||||
|  | ||||
|     if temp[0] == "client": | ||||
|         model = "client" | ||||
|         if isinstance(instance, Client): | ||||
|             obj = instance | ||||
|         elif hasattr(instance, "client"): | ||||
|             obj = instance.client | ||||
|         else: | ||||
|             obj = None | ||||
|     elif temp[0] == "site": | ||||
|         model = "site" | ||||
|         if isinstance(instance, Site): | ||||
|             obj = instance | ||||
|         elif hasattr(instance, "site"): | ||||
|             obj = instance.site | ||||
|         else: | ||||
|             obj = None | ||||
|     elif temp[0] == "agent": | ||||
|         model = "agent" | ||||
|         if isinstance(instance, Agent): | ||||
|             obj = instance | ||||
|         else: | ||||
|             obj = None | ||||
|     else: | ||||
|         # ignore arg since it is invalid | ||||
|     # custom field lookup | ||||
|     try: | ||||
|         # looking up custom field directly on this instance | ||||
|         if len(props) == 2: | ||||
|             field = CustomField.objects.get(model=props[0], name=props[1]) | ||||
|             model_fields = getattr(field, f"{props[0]}_fields") | ||||
|  | ||||
|             try: | ||||
|                 # resolve the correct model id | ||||
|                 if props[0] != instance.__class__.__name__.lower(): | ||||
|                     value = model_fields.get( | ||||
|                         **{props[0]: getattr(instance, props[0])} | ||||
|                     ).value | ||||
|                 else: | ||||
|                     value = model_fields.get(**{f"{props[0]}_id": instance.id}).value | ||||
|  | ||||
|                 if field.type != CustomFieldType.CHECKBOX: | ||||
|                     if value: | ||||
|                         return value | ||||
|                     else: | ||||
|                         return field.default_value | ||||
|                 else: | ||||
|                     return bool(value) | ||||
|             except: | ||||
|                 return ( | ||||
|                     field.default_value | ||||
|                     if field.type != CustomFieldType.CHECKBOX | ||||
|                     else bool(field.default_value) | ||||
|                 ) | ||||
|     except CustomField.DoesNotExist: | ||||
|         pass | ||||
|  | ||||
|     # if the instance is the same as the first prop. We remove it. | ||||
|     if props[0] == instance.__class__.__name__.lower(): | ||||
|         del props[0] | ||||
|  | ||||
|     instance_value = instance | ||||
|  | ||||
|     # look through all properties and return the value | ||||
|     for prop in props: | ||||
|         if hasattr(instance_value, prop): | ||||
|             value = getattr(instance_value, prop) | ||||
|             if callable(value): | ||||
|                 return None | ||||
|             instance_value = value | ||||
|  | ||||
|         if not instance_value: | ||||
|             return None | ||||
|  | ||||
|     return instance_value | ||||
|  | ||||
|  | ||||
| def replace_arg_db_values( | ||||
|     string: str, instance=None, shell: str = None, quotes=True  # type:ignore | ||||
| ) -> Union[str, None]: | ||||
|     # resolve the value | ||||
|     value = get_db_value(string=string, instance=instance) | ||||
|  | ||||
|     # check for model and property | ||||
|     if value is None: | ||||
|         DebugLog.error( | ||||
|             log_type=DebugLogType.SCRIPTING, | ||||
|             message=f"{instance} Not enough information to find value for: {string}. Only agent, site, client, and global are supported.", | ||||
|             message=f"Couldn't lookup value for: {string}. Make sure it exists", | ||||
|         ) | ||||
|         return "" | ||||
|  | ||||
|     if not obj: | ||||
|         return "" | ||||
|     # format args for str | ||||
|     if isinstance(value, str): | ||||
|         if shell == ScriptShell.POWERSHELL and "'" in value: | ||||
|             value = value.replace("'", "''") | ||||
|  | ||||
|     # check if attr exists and isn't a function | ||||
|     if hasattr(obj, temp[1]) and not callable(getattr(obj, temp[1])): | ||||
|         temp1 = getattr(obj, temp[1]) | ||||
|         if shell == ScriptShell.POWERSHELL and isinstance(temp1, str) and "'" in temp1: | ||||
|             temp1 = temp1.replace("'", "''") | ||||
|         return f"'{value}'" if quotes else value | ||||
|  | ||||
|         value = f"'{temp1}'" if quotes else temp1 | ||||
|     # format args for list | ||||
|     elif isinstance(value, list): | ||||
|         return f"'{format_shell_array(value)}'" if quotes else format_shell_array(value) | ||||
|  | ||||
|     elif CustomField.objects.filter(model=model, name=temp[1]).exists(): | ||||
|         field = CustomField.objects.get(model=model, name=temp[1]) | ||||
|         model_fields = getattr(field, f"{model}_fields") | ||||
|         value = None | ||||
|         if model_fields.filter(**{model: obj}).exists(): | ||||
|             if ( | ||||
|                 field.type != CustomFieldType.CHECKBOX | ||||
|                 and model_fields.get(**{model: obj}).value | ||||
|             ): | ||||
|                 value = model_fields.get(**{model: obj}).value | ||||
|             elif field.type == CustomFieldType.CHECKBOX: | ||||
|                 value = model_fields.get(**{model: obj}).value | ||||
|  | ||||
|         # need explicit None check since a false boolean value will pass default value | ||||
|         if value is None and field.default_value is not None: | ||||
|             value = field.default_value | ||||
|  | ||||
|         # check if value exists and if not use default | ||||
|         if value and field.type == CustomFieldType.MULTIPLE: | ||||
|             value = ( | ||||
|                 f"'{format_shell_array(value)}'" | ||||
|                 if quotes | ||||
|                 else format_shell_array(value) | ||||
|             ) | ||||
|         elif value is not None and field.type == CustomFieldType.CHECKBOX: | ||||
|             value = format_shell_bool(value, shell) | ||||
|         else: | ||||
|             if ( | ||||
|                 shell == ScriptShell.POWERSHELL | ||||
|                 and isinstance(value, str) | ||||
|                 and "'" in value | ||||
|             ): | ||||
|                 value = value.replace("'", "''") | ||||
|  | ||||
|             value = f"'{value}'" if quotes else value | ||||
|  | ||||
|     else: | ||||
|         # ignore arg since property is invalid | ||||
|         DebugLog.error( | ||||
|             log_type=DebugLogType.SCRIPTING, | ||||
|             message=f"{instance} Couldn't find property on supplied variable: {string}. Make sure it exists as a custom field or a valid agent property", | ||||
|         ) | ||||
|         return "" | ||||
|  | ||||
|     # log any unhashable type errors | ||||
|     if value is not None: | ||||
|         return value | ||||
|     else: | ||||
|         DebugLog.error( | ||||
|             log_type=DebugLogType.SCRIPTING, | ||||
|             message=f" {instance}({instance.pk}) Couldn't lookup value for: {string}. Make sure it exists as a custom field or a valid agent property", | ||||
|         ) | ||||
|         return "" | ||||
|     # format args for bool | ||||
|     elif value is True or value is False: | ||||
|         return format_shell_bool(value, shell) | ||||
|  | ||||
|  | ||||
| def format_shell_array(value: list[str]) -> str: | ||||
|   | ||||
							
								
								
									
										17
									
								
								backup.sh
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								backup.sh
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| SCRIPT_VERSION="28" | ||||
| SCRIPT_VERSION="30" | ||||
|  | ||||
| GREEN='\033[0;32m' | ||||
| YELLOW='\033[1;33m' | ||||
| @@ -68,11 +68,12 @@ mkdir ${tmp_dir}/nginx | ||||
| mkdir ${tmp_dir}/systemd | ||||
| mkdir ${tmp_dir}/rmm | ||||
| mkdir ${tmp_dir}/confd | ||||
| mkdir ${tmp_dir}/opt | ||||
|  | ||||
| POSTGRES_USER=$(/rmm/api/env/bin/python /rmm/api/tacticalrmm/manage.py get_config dbuser) | ||||
| POSTGRES_PW=$(/rmm/api/env/bin/python /rmm/api/tacticalrmm/manage.py get_config dbpw) | ||||
|  | ||||
| pg_dump --dbname=postgresql://"${POSTGRES_USER}":"${POSTGRES_PW}"@127.0.0.1:5432/tacticalrmm | gzip -9 >${tmp_dir}/postgres/db-${dt_now}.psql.gz | ||||
| pg_dump --dbname=postgresql://"${POSTGRES_USER}":"${POSTGRES_PW}"@localhost:5432/tacticalrmm | gzip -9 >${tmp_dir}/postgres/db-${dt_now}.psql.gz | ||||
|  | ||||
| node /meshcentral/node_modules/meshcentral --dbexport # for import to postgres | ||||
|  | ||||
| @@ -82,7 +83,7 @@ if grep -q postgres "/meshcentral/meshcentral-data/config.json"; then | ||||
|     fi | ||||
|     MESH_POSTGRES_USER=$(jq '.settings.postgres.user' /meshcentral/meshcentral-data/config.json -r) | ||||
|     MESH_POSTGRES_PW=$(jq '.settings.postgres.password' /meshcentral/meshcentral-data/config.json -r) | ||||
|     pg_dump --dbname=postgresql://"${MESH_POSTGRES_USER}":"${MESH_POSTGRES_PW}"@127.0.0.1:5432/meshcentral | gzip -9 >${tmp_dir}/postgres/mesh-db-${dt_now}.psql.gz | ||||
|     pg_dump --dbname=postgresql://"${MESH_POSTGRES_USER}":"${MESH_POSTGRES_PW}"@localhost:5432/meshcentral | gzip -9 >${tmp_dir}/postgres/mesh-db-${dt_now}.psql.gz | ||||
| else | ||||
|     mongodump --gzip --out=${tmp_dir}/meshcentral/mongo | ||||
| fi | ||||
| @@ -93,6 +94,10 @@ if [ -d /etc/letsencrypt ]; then | ||||
|     sudo tar -czvf ${tmp_dir}/certs/etc-letsencrypt.tar.gz -C /etc/letsencrypt . | ||||
| fi | ||||
|  | ||||
| if [ -d /opt/tactical ]; then | ||||
|     sudo tar -czvf ${tmp_dir}/opt/opt-tactical.tar.gz -C /opt/tactical . | ||||
| fi | ||||
|  | ||||
| local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py' | ||||
|  | ||||
| if grep -q CERT_FILE "$local_settings"; then | ||||
| @@ -101,6 +106,11 @@ if grep -q CERT_FILE "$local_settings"; then | ||||
|     KEY_FILE=$(grep "^KEY_FILE" "$local_settings" | awk -F'[= "]' '{print $5}') | ||||
|     cp -p $CERT_FILE ${tmp_dir}/certs/custom/cert | ||||
|     cp -p $KEY_FILE ${tmp_dir}/certs/custom/key | ||||
| elif grep -q TRMM_INSECURE "$local_settings"; then | ||||
|     mkdir -p ${tmp_dir}/certs/selfsigned | ||||
|     certdir='/etc/ssl/tactical' | ||||
|     cp -p ${certdir}/key.pem ${tmp_dir}/certs/selfsigned/ | ||||
|     cp -p ${certdir}/cert.pem ${tmp_dir}/certs/selfsigned/ | ||||
| fi | ||||
|  | ||||
| for i in rmm frontend meshcentral; do | ||||
| @@ -138,6 +148,7 @@ if [[ $* == *--auto* ]]; then | ||||
|  | ||||
| else | ||||
|     tar -cf /rmmbackups/rmm-backup-${dt_now}.tar -C ${tmp_dir} . | ||||
|     rm -rf ${tmp_dir} | ||||
|  | ||||
|     echo -ne "${GREEN}Backup saved to /rmmbackups/rmm-backup-${dt_now}.tar${NC}\n" | ||||
| fi | ||||
|   | ||||
| @@ -5,6 +5,10 @@ VERSION=latest | ||||
| TRMM_USER=tactical | ||||
| TRMM_PASS=tactical | ||||
|  | ||||
| # optional web port override settings  | ||||
| TRMM_HTTP_PORT=80 | ||||
| TRMM_HTTPS_PORT=443 | ||||
|  | ||||
| # dns settings | ||||
| APP_HOST=rmm.example.com | ||||
| API_HOST=api.example.com | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| FROM nginxinc/nginx-unprivileged:stable-alpine | ||||
|  | ||||
| ENV PUBLIC_DIR /usr/share/nginx/html | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
| USER root | ||||
|  | ||||
| RUN deluser --remove-home nginx \ | ||||
|   | ||||
| @@ -2,14 +2,20 @@ | ||||
| # | ||||
| # https://www.freecodecamp.org/news/how-to-implement-runtime-environment-variables-with-create-react-app-docker-and-nginx-7f9d42a91d70/ | ||||
| # | ||||
| set -e | ||||
|  | ||||
| function check_tactical_ready { | ||||
|   sleep 15 | ||||
|   until [ -f "${TACTICAL_READY_FILE}" ]; do | ||||
|     echo "waiting for init container to finish install or update..." | ||||
|     sleep 10 | ||||
|   done | ||||
| } | ||||
|  | ||||
| # Recreate js config file on start | ||||
| rm -rf ${PUBLIC_DIR}/env-config.js | ||||
| touch ${PUBLIC_DIR}/env-config.js | ||||
|  | ||||
| # Add runtime base url assignment  | ||||
| echo "window._env_ = {PROD_URL: \"https://${API_HOST}\"}" >> ${PUBLIC_DIR}/env-config.js | ||||
|  | ||||
| nginx_config="$(cat << EOF | ||||
| server { | ||||
|   listen 8080; | ||||
| @@ -25,4 +31,31 @@ server { | ||||
| EOF | ||||
| )" | ||||
|  | ||||
| echo "${nginx_config}" > /etc/nginx/conf.d/default.conf | ||||
| echo "${nginx_config}" > /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| check_tactical_ready | ||||
|  | ||||
| URL_PATH="${TACTICAL_DIR}/tmp/web_tar_url" | ||||
| AGENT_BASE=$(grep -o 'AGENT_BASE_URL.*' /tmp/settings.py | cut -d'"' -f 2) | ||||
| WEB_VERSION=$(grep -o 'WEB_VERSION.*' /tmp/settings.py | cut -d'"' -f 2) | ||||
|  | ||||
| # add dynamic web tar if configured | ||||
| if [ -f "$URL_PATH" ]; then | ||||
|   START_STRING=$(head -c ${#AGENT_BASE} "$URL_PATH") | ||||
|   if [ "$START_STRING" == "${AGENT_BASE}" ]; then | ||||
|     echo "Attempting to pull dynamic web tar from ${AGENT_BASE}" | ||||
|     webtar="trmm-web-v${WEB_VERSION}.tar.gz" | ||||
|     wget -q $(cat "${URL_PATH}") -O /tmp/${webtar} | ||||
|     tar -xzf /tmp/${webtar} -C /tmp/ | ||||
|     rm -rf ${PUBLIC_DIR}/* | ||||
|     mv /tmp/dist/* ${PUBLIC_DIR} | ||||
|  | ||||
|     rm -f /tmp/${webtar} | ||||
|     rm -rf /tmp/dist | ||||
|     echo "Success!" | ||||
|   fi | ||||
| fi   | ||||
|  | ||||
| # Add runtime base url assignment  | ||||
| echo "window._env_ = {PROD_URL: \"https://${API_HOST}\"}" > ${PUBLIC_DIR}/env-config.js | ||||
| chown -R nginx:nginx /etc/nginx && chown -R nginx:nginx ${PUBLIC_DIR} | ||||
| @@ -10,7 +10,22 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] | ||||
|  | ||||
| COPY api/tacticalrmm/tacticalrmm/settings.py /tmp/settings.py | ||||
|  | ||||
| RUN npm install meshcentral@$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2) | ||||
| RUN MESH_VER=$(grep -o 'MESH_VER.*' /tmp/settings.py | cut -d'"' -f 2) && \ | ||||
|     cat > package.json <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "meshcentral": "$MESH_VER", | ||||
|     "mongodb": "4.13.0", | ||||
|     "otplib": "10.2.3", | ||||
|     "pg": "8.7.1", | ||||
|     "pgtools": "0.3.2", | ||||
|     "saslprep": "1.0.3" | ||||
|   } | ||||
| } | ||||
| EOF | ||||
|  | ||||
| RUN npm install | ||||
|  | ||||
| RUN chown -R node:node /home/node | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM nats:2.9.20-alpine | ||||
| FROM nats:2.10.3-alpine | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
|   | ||||
| @@ -21,14 +21,14 @@ rm -f /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| # check for certificates in env variable | ||||
| if [ ! -z "$CERT_PRIV_KEY" ] && [ ! -z "$CERT_PUB_KEY" ]; then | ||||
|   echo "${CERT_PRIV_KEY}" | base64 -d > ${CERT_PRIV_PATH} | ||||
|   echo "${CERT_PUB_KEY}" | base64 -d > ${CERT_PUB_PATH} | ||||
|     echo "${CERT_PRIV_KEY}" | base64 -d >${CERT_PRIV_PATH} | ||||
|     echo "${CERT_PUB_KEY}" | base64 -d >${CERT_PUB_PATH} | ||||
| else | ||||
|   # generate a self signed cert | ||||
|   if [ ! -f "${CERT_PRIV_PATH}" ] || [ ! -f "${CERT_PUB_PATH}" ]; then | ||||
|     rootdomain=$(echo ${API_HOST} | cut -d "." -f2- ) | ||||
|     openssl req -newkey rsa:4096 -x509 -sha256 -days 365 -nodes -out ${CERT_PUB_PATH} -keyout ${CERT_PRIV_PATH} -subj "/C=US/ST=Some-State/L=city/O=Internet Widgits Pty Ltd/CN=*.${rootdomain}" | ||||
|   fi | ||||
|     # generate a self signed cert | ||||
|     if [ ! -f "${CERT_PRIV_PATH}" ] || [ ! -f "${CERT_PUB_PATH}" ]; then | ||||
|         rootdomain=$(echo ${API_HOST} | cut -d "." -f2-) | ||||
|         openssl req -newkey rsa:4096 -x509 -sha256 -days 365 -nodes -out ${CERT_PUB_PATH} -keyout ${CERT_PRIV_PATH} -subj "/C=US/ST=Some-State/L=city/O=Internet Widgits Pty Ltd/CN=*.${rootdomain}" | ||||
|     fi | ||||
| fi | ||||
|  | ||||
| nginxdefaultconf='/etc/nginx/nginx.conf' | ||||
| @@ -54,6 +54,13 @@ if [[ $DEV -eq 1 ]]; then | ||||
|         proxy_set_header X-Forwarded-Host  \$host; | ||||
|         proxy_set_header X-Forwarded-Port  \$server_port; | ||||
| " | ||||
|  | ||||
|     STATIC_ASSETS=" | ||||
|     location /static/ { | ||||
|         root /workspace/api/tacticalrmm; | ||||
|         add_header "Access-Control-Allow-Origin" "https://${APP_HOST}"; | ||||
|     } | ||||
| " | ||||
| else | ||||
|     API_NGINX=" | ||||
|         #Using variable to disable start checks | ||||
| @@ -62,9 +69,17 @@ else | ||||
|         include         uwsgi_params; | ||||
|         uwsgi_pass      \$api; | ||||
| " | ||||
|  | ||||
|     STATIC_ASSETS=" | ||||
|     location /static/ { | ||||
|         root ${TACTICAL_DIR}/api/; | ||||
|         add_header "Access-Control-Allow-Origin" "https://${APP_HOST}"; | ||||
|     } | ||||
| " | ||||
| fi | ||||
|  | ||||
| nginx_config="$(cat << EOF | ||||
| nginx_config="$( | ||||
|     cat <<EOF | ||||
| # backend config | ||||
| server  { | ||||
|     resolver ${NGINX_RESOLVER} valid=30s; | ||||
| @@ -75,9 +90,7 @@ server  { | ||||
|         ${API_NGINX} | ||||
|     } | ||||
|  | ||||
|     location /static/ { | ||||
|         root ${TACTICAL_DIR}/api; | ||||
|     } | ||||
|     ${STATIC_ASSETS} | ||||
|  | ||||
|     location /private/ { | ||||
|         internal; | ||||
| @@ -100,6 +113,12 @@ server  { | ||||
|         proxy_set_header   X-Forwarded-Host \$server_name; | ||||
|     } | ||||
|  | ||||
|     location /assets/ { | ||||
|         internal; | ||||
|         add_header "Access-Control-Allow-Origin" "https://${APP_HOST}"; | ||||
|         alias /opt/tactical/reporting/assets/; | ||||
|     } | ||||
|  | ||||
|     location ~ ^/natsws { | ||||
|         set \$natswebsocket http://${NATS_SERVICE}:9235; | ||||
|         proxy_pass \$natswebsocket; | ||||
| @@ -229,4 +248,4 @@ server { | ||||
| EOF | ||||
| )" | ||||
|  | ||||
| echo "${nginx_config}" > /etc/nginx/conf.d/default.conf | ||||
| echo "${nginx_config}" >/etc/nginx/conf.d/default.conf | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # creates python virtual env | ||||
| FROM python:3.11.4-slim AS CREATE_VENV_STAGE | ||||
| FROM python:3.11.6-slim AS CREATE_VENV_STAGE | ||||
|  | ||||
| ARG DEBIAN_FRONTEND=noninteractive | ||||
|  | ||||
| @@ -21,14 +21,14 @@ RUN apt-get update && \ | ||||
|     pip install --no-cache-dir -r ${TACTICAL_TMP_DIR}/api/requirements.txt | ||||
|  | ||||
| # pulls community scripts from git repo | ||||
| FROM python:3.11.4-slim AS GET_SCRIPTS_STAGE | ||||
| FROM python:3.11.6-slim AS GET_SCRIPTS_STAGE | ||||
|  | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends git && \ | ||||
|     git clone https://github.com/amidaware/community-scripts.git /community-scripts | ||||
|  | ||||
| # runtime image | ||||
| FROM python:3.11.4-slim | ||||
| FROM python:3.11.6-slim | ||||
|  | ||||
| # set env variables | ||||
| ENV VIRTUAL_ENV /opt/venv | ||||
| @@ -48,7 +48,7 @@ COPY --from=CREATE_VENV_STAGE ${VIRTUAL_ENV} ${VIRTUAL_ENV} | ||||
| # install deps | ||||
| RUN apt-get update && \ | ||||
|     apt-get upgrade -y && \ | ||||
|     apt-get install -y --no-install-recommends rsync && \ | ||||
|     apt-get install -y --no-install-recommends rsync weasyprint && \ | ||||
|     rm -rf /var/lib/apt/lists/* && \ | ||||
|     groupadd -g 1000 "${TACTICAL_USER}" && \ | ||||
|     useradd -M -d "${TACTICAL_DIR}" -s /bin/bash -u 1000 -g 1000 "${TACTICAL_USER}" | ||||
|   | ||||
| @@ -17,6 +17,7 @@ set -e | ||||
| : "${API_HOST:=tactical-backend}" | ||||
| : "${APP_HOST:=tactical-frontend}" | ||||
| : "${REDIS_HOST:=tactical-redis}" | ||||
| : "${SKIP_UWSGI_CONFIG:=0}" | ||||
|  | ||||
| : "${CERT_PRIV_PATH:=${TACTICAL_DIR}/certs/privkey.pem}" | ||||
| : "${CERT_PUB_PATH:=${TACTICAL_DIR}/certs/fullchain.pem}" | ||||
| @@ -40,6 +41,8 @@ if [ "$1" = 'tactical-init' ]; then | ||||
|   mkdir -p /meshcentral-data | ||||
|   mkdir -p ${TACTICAL_DIR}/tmp | ||||
|   mkdir -p ${TACTICAL_DIR}/certs | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting/assets | ||||
|   mkdir -p /mongo/data/db | ||||
|   mkdir -p /redis/data | ||||
|   touch /meshcentral-data/.initialized && chown -R 1000:1000 /meshcentral-data | ||||
| @@ -47,16 +50,17 @@ if [ "$1" = 'tactical-init' ]; then | ||||
|   touch ${TACTICAL_DIR}/certs/.initialized && chown -R 1000:1000 ${TACTICAL_DIR}/certs | ||||
|   touch /mongo/data/db/.initialized && chown -R 1000:1000 /mongo/data/db | ||||
|   touch /redis/data/.initialized && chown -R 1000:1000 /redis/data | ||||
|   touch ${TACTICAL_DIR}/reporting && chown -R 1000:1000 ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/log | ||||
|   touch ${TACTICAL_DIR}/api/tacticalrmm/private/log/django_debug.log | ||||
|    | ||||
|   until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do | ||||
|  | ||||
|   until (echo >/dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &>/dev/null; do | ||||
|     echo "waiting for postgresql container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
|  | ||||
|   until (echo > /dev/tcp/"${MESH_SERVICE}"/4443) &> /dev/null; do | ||||
|   until (echo >/dev/tcp/"${MESH_SERVICE}"/4443) &>/dev/null; do | ||||
|     echo "waiting for meshcentral container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
| @@ -65,8 +69,9 @@ if [ "$1" = 'tactical-init' ]; then | ||||
|   MESH_TOKEN=$(cat ${TACTICAL_DIR}/tmp/mesh_token) | ||||
|   ADMINURL=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 70 | head -n 1) | ||||
|   DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) | ||||
|    | ||||
|   localvars="$(cat << EOF | ||||
|  | ||||
|   localvars="$( | ||||
|     cat <<EOF | ||||
| SECRET_KEY = '${DJANGO_SEKRET}' | ||||
|  | ||||
| DEBUG = False | ||||
| @@ -107,13 +112,15 @@ REDIS_HOST    = '${REDIS_HOST}' | ||||
| MESH_WS_URL = '${MESH_WS_URL}' | ||||
| ADMIN_ENABLED = False | ||||
| EOF | ||||
| )" | ||||
|   )" | ||||
|  | ||||
|   echo "${localvars}" > ${TACTICAL_DIR}/api/tacticalrmm/local_settings.py | ||||
|   echo "${localvars}" >${TACTICAL_DIR}/api/tacticalrmm/local_settings.py | ||||
|  | ||||
|   # run migrations and init scripts | ||||
|   python manage.py pre_update_tasks | ||||
|   python manage.py migrate --no-input | ||||
|   python manage.py generate_json_schemas | ||||
|   python manage.py get_webtar_url >${TACTICAL_DIR}/tmp/web_tar_url | ||||
|   python manage.py collectstatic --no-input | ||||
|   python manage.py initial_db_setup | ||||
|   python manage.py initial_mesh_setup | ||||
| @@ -121,12 +128,16 @@ EOF | ||||
|   python manage.py load_community_scripts | ||||
|   python manage.py reload_nats | ||||
|   python manage.py create_natsapi_conf | ||||
|   python manage.py create_uwsgi_conf | ||||
|  | ||||
|   if [ "$SKIP_UWSGI_CONFIG" = 0 ]; then | ||||
|     python manage.py create_uwsgi_conf | ||||
|   fi | ||||
|  | ||||
|   python manage.py create_installer_user | ||||
|   python manage.py clear_redis_celery_locks | ||||
|   python manage.py post_update_tasks | ||||
|  | ||||
|   # create super user  | ||||
|   # create super user | ||||
|   echo "Creating dashboard user if it doesn't exist" | ||||
|   echo "from accounts.models import User; User.objects.create_superuser('${TRMM_USER}', 'admin@example.com', '${TRMM_PASS}') if not User.objects.filter(username='${TRMM_USER}').exists() else 0;" | python manage.py shell | ||||
|  | ||||
| @@ -143,7 +154,6 @@ fi | ||||
| # backend container | ||||
| if [ "$1" = 'tactical-backend' ]; then | ||||
|   check_tactical_ready | ||||
|  | ||||
|   uwsgi ${TACTICAL_DIR}/api/app.ini | ||||
| fi | ||||
|  | ||||
| @@ -164,5 +174,5 @@ if [ "$1" = 'tactical-websockets' ]; then | ||||
|  | ||||
|   export DJANGO_SETTINGS_MODULE=tacticalrmm.settings | ||||
|  | ||||
|   daphne tacticalrmm.asgi:application --port 8383 -b 0.0.0.0 | ||||
|   uvicorn --host 0.0.0.0 --port 8383 --forwarded-allow-ips='*' tacticalrmm.asgi:application | ||||
| fi | ||||
|   | ||||
| @@ -195,8 +195,8 @@ services: | ||||
|       proxy: | ||||
|         ipv4_address: 172.20.0.20 | ||||
|     ports: | ||||
|       - "80:8080" | ||||
|       - "443:4443" | ||||
|       - "${TRMM_HTTP_PORT-80}:8080" | ||||
|       - "${TRMM_HTTPS_PORT-443}:4443" | ||||
|     volumes: | ||||
|       - tactical_data:/opt/tactical | ||||
|  | ||||
|   | ||||
							
								
								
									
										13
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								go.mod
									
									
									
									
									
								
							| @@ -3,24 +3,21 @@ module github.com/amidaware/tacticalrmm | ||||
| go 1.20 | ||||
|  | ||||
| require ( | ||||
| 	github.com/golang/protobuf v1.5.2 // indirect | ||||
| 	github.com/jmoiron/sqlx v1.3.5 | ||||
| 	github.com/lib/pq v1.10.9 | ||||
| 	github.com/nats-io/nats-server/v2 v2.9.21 // indirect | ||||
| 	github.com/nats-io/nats.go v1.28.0 | ||||
| 	github.com/nats-io/nats.go v1.31.0 | ||||
| 	github.com/ugorji/go/codec v1.2.11 | ||||
| 	github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139 | ||||
| 	google.golang.org/protobuf v1.28.0 // indirect | ||||
| ) | ||||
|  | ||||
| require github.com/sirupsen/logrus v1.9.3 | ||||
|  | ||||
| require ( | ||||
| 	github.com/klauspost/compress v1.16.7 // indirect | ||||
| 	github.com/nats-io/nkeys v0.4.4 // indirect | ||||
| 	github.com/klauspost/compress v1.17.2 // indirect | ||||
| 	github.com/nats-io/nkeys v0.4.6 // indirect | ||||
| 	github.com/nats-io/nuid v1.0.1 // indirect | ||||
| 	github.com/stretchr/testify v1.7.1 // indirect | ||||
| 	golang.org/x/crypto v0.11.0 // indirect | ||||
| 	golang.org/x/sys v0.10.0 // indirect | ||||
| 	golang.org/x/crypto v0.14.0 // indirect | ||||
| 	golang.org/x/sys v0.13.0 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|   | ||||
							
								
								
									
										36
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								go.sum
									
									
									
									
									
								
							| @@ -3,33 +3,23 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= | ||||
| github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= | ||||
| github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= | ||||
| github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= | ||||
| github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= | ||||
| github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= | ||||
| github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= | ||||
| github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= | ||||
| github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= | ||||
| github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= | ||||
| github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= | ||||
| github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= | ||||
| github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= | ||||
| github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= | ||||
| github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= | ||||
| github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= | ||||
| github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4= | ||||
| github.com/nats-io/nats-server/v2 v2.9.21 h1:2TBTh0UDE74eNXQmV4HofsmRSCiVN0TH2Wgrp6BD6fk= | ||||
| github.com/nats-io/nats-server/v2 v2.9.21/go.mod h1:ozqMZc2vTHcNcblOiXMWIXkf8+0lDGAi5wQcG+O1mHU= | ||||
| github.com/nats-io/nats.go v1.28.0 h1:Th4G6zdsz2d0OqXdfzKLClo6bOfoI/b1kInhRtFIy5c= | ||||
| github.com/nats-io/nats.go v1.28.0/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc= | ||||
| github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA= | ||||
| github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= | ||||
| github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= | ||||
| github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= | ||||
| github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY= | ||||
| github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts= | ||||
| github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= | ||||
| github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= | ||||
| github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||||
| github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||||
| github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| @@ -40,17 +30,11 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d | ||||
| github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139 h1:PfOl03o+Y+svWrfXAAu1QWUDePu1yqTq0pf4rpnN8eA= | ||||
| github.com/wh1te909/trmm-shared v0.0.0-20220227075846-f9f757361139/go.mod h1:ILUz1utl5KgwrxmNHv0RpgMtKeh8gPAABvK2MiXBqv8= | ||||
| golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= | ||||
| golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= | ||||
| golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= | ||||
| golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= | ||||
| golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= | ||||
| golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= | ||||
| google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= | ||||
| google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= | ||||
| google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= | ||||
| golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= | ||||
| golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
|   | ||||
							
								
								
									
										144
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										144
									
								
								install.sh
									
									
									
									
									
								
							| @@ -1,9 +1,26 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| SCRIPT_VERSION="75" | ||||
| SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh' | ||||
| REPO=amidaware | ||||
| BRANCH=master | ||||
| while [[ $# -gt 0 ]]; do | ||||
|   case $1 in | ||||
|   -r | --repo) | ||||
|     REPO="$2" | ||||
|     shift # past argument | ||||
|     shift # past value | ||||
|     ;; | ||||
|   -b | --branch) | ||||
|     BRANCH="$2" | ||||
|     shift # past argument | ||||
|     shift # past value | ||||
|     ;; | ||||
|   esac | ||||
| done | ||||
|  | ||||
| sudo apt install -y curl wget dirmngr gnupg lsb-release | ||||
| SCRIPT_VERSION="79" | ||||
| SCRIPT_URL="https://raw.githubusercontent.com/${REPO}/tacticalrmm/${BRANCH}/install.sh" | ||||
|  | ||||
| sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates | ||||
|  | ||||
| GREEN='\033[0;32m' | ||||
| YELLOW='\033[1;33m' | ||||
| @@ -12,8 +29,9 @@ RED='\033[0;31m' | ||||
| NC='\033[0m' | ||||
|  | ||||
| SCRIPTS_DIR='/opt/trmm-community-scripts' | ||||
| PYTHON_VER='3.11.4' | ||||
| PYTHON_VER='3.11.6' | ||||
| SETTINGS_FILE='/rmm/api/tacticalrmm/tacticalrmm/settings.py' | ||||
| local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py' | ||||
|  | ||||
| TMP_FILE=$(mktemp -p "" "rmminstall_XXXXXXXXXX") | ||||
| curl -s -L "${SCRIPT_URL}" >${TMP_FILE} | ||||
| @@ -86,7 +104,7 @@ if [ "$arch" = "x86_64" ]; then | ||||
| else | ||||
|   pgarch='arm64' | ||||
| fi | ||||
| postgresql_repo="deb [arch=${pgarch}] https://apt.postgresql.org/pub/repos/apt/ $codename-pgdg main" | ||||
| postgresql_repo="deb [arch=${pgarch} signed-by=/etc/apt/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt/ $codename-pgdg main" | ||||
|  | ||||
| # prevents logging issues with some VPS providers like Vultr if this is a freshly provisioned instance that hasn't been rebooted yet | ||||
| sudo systemctl restart systemd-journald.service | ||||
| @@ -161,30 +179,50 @@ if echo "$IPV4" | grep -qE '^(10\.|172\.1[6789]\.|172\.2[0-9]\.|172\.3[01]\.|192 | ||||
|   BEHIND_NAT=true | ||||
| fi | ||||
|  | ||||
| insecure=false | ||||
| if [[ $* == *--insecure* ]]; then | ||||
|   insecure=true | ||||
| fi | ||||
|  | ||||
| sudo apt install -y software-properties-common | ||||
| sudo apt update | ||||
| sudo apt install -y certbot openssl | ||||
| sudo apt install -y openssl | ||||
|  | ||||
| print_green 'Getting wildcard cert' | ||||
| if [[ "$insecure" = true ]]; then | ||||
|   print_green 'Generating self-signed cert' | ||||
|   certdir='/etc/ssl/tactical' | ||||
|   sudo mkdir -p $certdir | ||||
|   sudo chown ${USER}:${USER} $certdir | ||||
|   sudo chmod 770 $certdir | ||||
|   CERT_PRIV_KEY=${certdir}/key.pem | ||||
|   CERT_PUB_KEY=${certdir}/cert.pem | ||||
|   openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \ | ||||
|     -nodes -keyout ${CERT_PRIV_KEY} -out ${CERT_PUB_KEY} -subj "/CN=${rootdomain}" \ | ||||
|     -addext "subjectAltName=DNS:${rootdomain},DNS:*.${rootdomain}" | ||||
|  | ||||
| else | ||||
|   sudo apt install -y certbot | ||||
|   print_green 'Getting wildcard cert' | ||||
|  | ||||
| sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email | ||||
| while [[ $? -ne 0 ]]; do | ||||
|   sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email | ||||
| done | ||||
|  | ||||
| CERT_PRIV_KEY=/etc/letsencrypt/live/${rootdomain}/privkey.pem | ||||
| CERT_PUB_KEY=/etc/letsencrypt/live/${rootdomain}/fullchain.pem | ||||
|   while [[ $? -ne 0 ]]; do | ||||
|     sudo certbot certonly --manual -d *.${rootdomain} --agree-tos --no-bootstrap --preferred-challenges dns -m ${letsemail} --no-eff-email | ||||
|   done | ||||
|   CERT_PRIV_KEY=/etc/letsencrypt/live/${rootdomain}/privkey.pem | ||||
|   CERT_PUB_KEY=/etc/letsencrypt/live/${rootdomain}/fullchain.pem | ||||
| fi | ||||
|  | ||||
| sudo chown ${USER}:${USER} -R /etc/letsencrypt | ||||
|  | ||||
| print_green 'Installing Nginx' | ||||
|  | ||||
| wget -qO - https://nginx.org/packages/keys/nginx_signing.key | sudo apt-key add - | ||||
| sudo mkdir -p /etc/apt/keyrings | ||||
|  | ||||
| wget -qO - https://nginx.org/packages/keys/nginx_signing.key | sudo gpg --dearmor -o /etc/apt/keyrings/nginx-archive-keyring.gpg | ||||
|  | ||||
| nginxrepo="$( | ||||
|   cat <<EOF | ||||
| deb https://nginx.org/packages/$osname/ $codename nginx | ||||
| deb-src https://nginx.org/packages/$osname/ $codename nginx | ||||
| deb [signed-by=/etc/apt/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/$osname $codename nginx | ||||
| EOF | ||||
| )" | ||||
| echo "${nginxrepo}" | sudo tee /etc/apt/sources.list.d/nginx.list >/dev/null | ||||
| @@ -232,7 +270,9 @@ done | ||||
|  | ||||
| print_green 'Installing NodeJS' | ||||
|  | ||||
| curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash - | ||||
| curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg | ||||
| NODE_MAJOR=18 | ||||
| echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list | ||||
| sudo apt update | ||||
| sudo apt install -y gcc g++ make | ||||
| sudo apt install -y nodejs | ||||
| @@ -253,13 +293,13 @@ cd ~ | ||||
| sudo rm -rf Python-${PYTHON_VER} Python-${PYTHON_VER}.tgz | ||||
|  | ||||
| print_green 'Installing redis and git' | ||||
| sudo apt install -y ca-certificates redis git | ||||
| sudo apt install -y redis git | ||||
|  | ||||
| print_green 'Installing postgresql' | ||||
|  | ||||
| echo "$postgresql_repo" | sudo tee /etc/apt/sources.list.d/pgdg.list | ||||
|  | ||||
| wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - | ||||
| wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/keyrings/postgresql-archive-keyring.gpg | ||||
| sudo apt update | ||||
| sudo apt install -y postgresql-15 | ||||
| sleep 2 | ||||
| @@ -298,11 +338,11 @@ sudo mkdir /rmm | ||||
| sudo chown ${USER}:${USER} /rmm | ||||
| sudo mkdir -p /var/log/celery | ||||
| sudo chown ${USER}:${USER} /var/log/celery | ||||
| git clone https://github.com/amidaware/tacticalrmm.git /rmm/ | ||||
| git clone https://github.com/${REPO}/tacticalrmm.git /rmm/ | ||||
| cd /rmm | ||||
| git config user.email "admin@example.com" | ||||
| git config user.name "Bob" | ||||
| git checkout master | ||||
| git checkout ${BRANCH} | ||||
|  | ||||
| sudo mkdir -p ${SCRIPTS_DIR} | ||||
| sudo chown ${USER}:${USER} ${SCRIPTS_DIR} | ||||
| @@ -336,9 +376,23 @@ MESH_VER=$(grep "^MESH_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}') | ||||
| sudo mkdir -p /meshcentral/meshcentral-data | ||||
| sudo chown ${USER}:${USER} -R /meshcentral | ||||
| cd /meshcentral | ||||
| npm install meshcentral@${MESH_VER} | ||||
| sudo chown ${USER}:${USER} -R /meshcentral | ||||
|  | ||||
| mesh_pkg="$( | ||||
|   cat <<EOF | ||||
| { | ||||
|   "dependencies": { | ||||
|     "archiver": "5.3.1", | ||||
|     "meshcentral": "${MESH_VER}", | ||||
|     "otplib": "10.2.3", | ||||
|     "pg": "8.7.1", | ||||
|     "pgtools": "0.3.2" | ||||
|   } | ||||
| } | ||||
| EOF | ||||
| )" | ||||
| echo "${mesh_pkg}" >/meshcentral/package.json | ||||
|  | ||||
| meshcfg="$( | ||||
|   cat <<EOF | ||||
| { | ||||
| @@ -382,6 +436,8 @@ EOF | ||||
| )" | ||||
| echo "${meshcfg}" >/meshcentral/meshcentral-data/config.json | ||||
|  | ||||
| npm install | ||||
|  | ||||
| localvars="$( | ||||
|   cat <<EOF | ||||
| SECRET_KEY = "${DJANGO_SEKRET}" | ||||
| @@ -413,7 +469,11 @@ REDIS_HOST    = "localhost" | ||||
| ADMIN_ENABLED = True | ||||
| EOF | ||||
| )" | ||||
| echo "${localvars}" >/rmm/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
| echo "${localvars}" >$local_settings | ||||
|  | ||||
| if [[ "$insecure" = true ]]; then | ||||
|   echo "TRMM_INSECURE = True" | tee --append $local_settings >/dev/null | ||||
| fi | ||||
|  | ||||
| if [ "$arch" = "x86_64" ]; then | ||||
|   natsapi='nats-api' | ||||
| @@ -427,9 +487,26 @@ sudo chmod +x /usr/local/bin/nats-api | ||||
|  | ||||
| print_green 'Installing the backend' | ||||
|  | ||||
| # for weasyprint | ||||
| if [[ "$osname" == "debian" ]]; then | ||||
|   count=$(dpkg -l | grep -E "libpango-1.0-0|libpangoft2-1.0-0" | wc -l) | ||||
|   if ! [ "$count" -eq 2 ]; then | ||||
|     sudo apt install -y libpango-1.0-0 libpangoft2-1.0-0 | ||||
|   fi | ||||
| elif [[ "$osname" == "ubuntu" ]]; then | ||||
|   count=$(dpkg -l | grep -E "libpango-1.0-0|libharfbuzz0b|libpangoft2-1.0-0" | wc -l) | ||||
|   if ! [ "$count" -eq 3 ]; then | ||||
|     sudo apt install -y libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| SETUPTOOLS_VER=$(grep "^SETUPTOOLS_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}') | ||||
| WHEEL_VER=$(grep "^WHEEL_VER" "$SETTINGS_FILE" | awk -F'[= "]' '{print $5}') | ||||
|  | ||||
| sudo mkdir -p /opt/tactical/reporting/assets | ||||
| sudo mkdir -p /opt/tactical/reporting/schemas | ||||
| sudo chown -R ${USER}:${USER} /opt/tactical | ||||
|  | ||||
| cd /rmm/api | ||||
| python3.11 -m venv env | ||||
| source /rmm/api/env/bin/activate | ||||
| @@ -438,15 +515,17 @@ pip install --no-cache-dir --upgrade pip | ||||
| pip install --no-cache-dir setuptools==${SETUPTOOLS_VER} wheel==${WHEEL_VER} | ||||
| pip install --no-cache-dir -r /rmm/api/tacticalrmm/requirements.txt | ||||
| python manage.py migrate | ||||
| python manage.py generate_json_schemas | ||||
| python manage.py collectstatic --no-input | ||||
| python manage.py create_natsapi_conf | ||||
| python manage.py create_uwsgi_conf | ||||
| python manage.py load_chocos | ||||
| python manage.py load_community_scripts | ||||
| WEB_VERSION=$(python manage.py get_config webversion) | ||||
| WEBTAR_URL=$(python manage.py get_webtar_url) | ||||
| printf >&2 "${YELLOW}%0.s*${NC}" {1..80} | ||||
| printf >&2 "\n" | ||||
| printf >&2 "${YELLOW}Please create your login for the RMM website and django admin${NC}\n" | ||||
| printf >&2 "${YELLOW}Please create your login for the RMM website${NC}\n" | ||||
| printf >&2 "${YELLOW}%0.s*${NC}" {1..80} | ||||
| printf >&2 "\n" | ||||
| echo -ne "Username: " | ||||
| @@ -480,10 +559,10 @@ EOF | ||||
| )" | ||||
| echo "${rmmservice}" | sudo tee /etc/systemd/system/rmm.service >/dev/null | ||||
|  | ||||
| daphneservice="$( | ||||
| uviservice="$( | ||||
|   cat <<EOF | ||||
| [Unit] | ||||
| Description=django channels daemon v2 | ||||
| Description=uvicorn daemon v1 | ||||
| After=network.target | ||||
|  | ||||
| [Service] | ||||
| @@ -491,7 +570,7 @@ User=${USER} | ||||
| Group=www-data | ||||
| WorkingDirectory=/rmm/api/tacticalrmm | ||||
| Environment="PATH=/rmm/api/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" | ||||
| ExecStart=/rmm/api/env/bin/daphne -u /rmm/daphne.sock tacticalrmm.asgi:application | ||||
| ExecStart=/rmm/api/env/bin/uvicorn --uds /rmm/daphne.sock --forwarded-allow-ips='*' tacticalrmm.asgi:application | ||||
| ExecStartPre=rm -f /rmm/daphne.sock | ||||
| ExecStartPre=rm -f /rmm/daphne.sock.lock | ||||
| Restart=always | ||||
| @@ -501,7 +580,7 @@ RestartSec=3s | ||||
| WantedBy=multi-user.target | ||||
| EOF | ||||
| )" | ||||
| echo "${daphneservice}" | sudo tee /etc/systemd/system/daphne.service >/dev/null | ||||
| echo "${uviservice}" | sudo tee /etc/systemd/system/daphne.service >/dev/null | ||||
|  | ||||
| natsservice="$( | ||||
|   cat <<EOF | ||||
| @@ -795,7 +874,7 @@ fi | ||||
| print_green 'Installing the frontend' | ||||
|  | ||||
| webtar="trmm-web-v${WEB_VERSION}.tar.gz" | ||||
| wget -q https://github.com/amidaware/tacticalrmm-web/releases/download/v${WEB_VERSION}/${webtar} -O /tmp/${webtar} | ||||
| wget -q ${WEBTAR_URL} -O /tmp/${webtar} | ||||
| sudo mkdir -p /var/www/rmm | ||||
| sudo tar -xzf /tmp/${webtar} -C /var/www/rmm | ||||
| echo "window._env_ = {PROD_URL: \"https://${rmmdomain}\"}" | sudo tee /var/www/rmm/dist/env-config.js >/dev/null | ||||
| @@ -856,7 +935,7 @@ done | ||||
| sleep 5 | ||||
| sudo systemctl enable meshcentral | ||||
|  | ||||
| print_green 'Starting meshcentral and waiting for it to install plugins' | ||||
| print_green 'Starting meshcentral and waiting for it to be ready' | ||||
|  | ||||
| sudo systemctl restart meshcentral | ||||
|  | ||||
| @@ -880,7 +959,7 @@ meshtoken="$( | ||||
| MESH_TOKEN_KEY = "${MESHTOKENKEY}" | ||||
| EOF | ||||
| )" | ||||
| echo "${meshtoken}" | tee --append /rmm/api/tacticalrmm/tacticalrmm/local_settings.py >/dev/null | ||||
| echo "${meshtoken}" | tee --append $local_settings >/dev/null | ||||
|  | ||||
| print_green 'Creating meshcentral account and group' | ||||
|  | ||||
| @@ -917,7 +996,7 @@ sudo systemctl enable nats-api.service | ||||
| sudo systemctl start nats-api.service | ||||
|  | ||||
| ## disable django admin | ||||
| sed -i 's/ADMIN_ENABLED = True/ADMIN_ENABLED = False/g' /rmm/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
| sed -i 's/ADMIN_ENABLED = True/ADMIN_ENABLED = False/g' $local_settings | ||||
|  | ||||
| print_green 'Restarting services' | ||||
| for i in rmm.service daphne.service celery.service celerybeat.service; do | ||||
| @@ -929,7 +1008,6 @@ printf >&2 "${YELLOW}%0.s*${NC}" {1..80} | ||||
| printf >&2 "\n\n" | ||||
| printf >&2 "${YELLOW}Installation complete!${NC}\n\n" | ||||
| printf >&2 "${YELLOW}Access your rmm at: ${GREEN}https://${frontenddomain}${NC}\n\n" | ||||
| printf >&2 "${YELLOW}Django admin url (disabled by default): ${GREEN}https://${rmmdomain}/${ADMINURL}/${NC}\n\n" | ||||
| printf >&2 "${YELLOW}MeshCentral username: ${GREEN}${meshusername}${NC}\n" | ||||
| printf >&2 "${YELLOW}MeshCentral password: ${GREEN}${MESHPASSWD}${NC}\n\n" | ||||
|  | ||||
|   | ||||
							
								
								
									
										2
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								main.go
									
									
									
									
									
								
							| @@ -12,7 +12,7 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	version = "3.4.8" | ||||
| 	version = "3.4.9" | ||||
| 	log     = logrus.New() | ||||
| ) | ||||
|  | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user