reporting wip
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
FROM python:3.11.4-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
|
||||
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
|
||||
|
||||
@@ -17,11 +17,11 @@ 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 &&
|
||||
useradd -u 1000 -g 1000 tactical
|
||||
useradd -u 1000 -g 1000 tactical
|
||||
|
||||
# copy community scripts
|
||||
COPY --from=GET_SCRIPTS_STAGE /community-scripts /community-scripts
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
api/tacticalrmm/ee/LICENSE.md
Normal file
19
api/tacticalrmm/ee/LICENSE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Tactical RMM Enterprise Edition (EE) License Agreement (the "EE License Agreement")
|
||||
|
||||
Copyright (c) 2023-present Amidaware Inc.
|
||||
|
||||
This EE License Agreement governs the use of the enterprise features of the Tactical RMM Software ("the Software") provided by Amidaware Inc. ("Amidaware").
|
||||
|
||||
The enterprise features, including but not limited to Reporting and White-labeling, are located within any directories named "ee," "enterprise," or "premium," in any of Amidaware's repositories and/or any files that include the EE License header. The Software is subject to the terms and conditions of the Tactical RMM License, which can be found at https://license.tacticalrmm.com, in addition to the terms and conditions set forth in this EE License Agreement.
|
||||
|
||||
### License Grant
|
||||
|
||||
Amidaware grants you, and any entity that you represent (collectively, "You" or "Licensee"), a limited, non-exclusive, non-transferable, and revocable license to use the Software in a production environment, provided that You have a valid sponsorship token. A production environment is defined as any instance not solely used for development purposes, such as adding new features or fixing bugs.
|
||||
|
||||
### Restrictions
|
||||
|
||||
Subject to the terms and conditions of this EE License Agreement, You are expressly prohibited from:
|
||||
|
||||
a) Copying, merging, publishing, distributing, sublicensing, and/or selling the Software;
|
||||
|
||||
b) Circumventing or bypassing controls to access the enterprise features of the Software without a valid sponsorship token.
|
||||
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
|
||||
"""
|
||||
5
api/tacticalrmm/ee/reporting/__init__.py
Normal file
5
api/tacticalrmm/ee/reporting/__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
|
||||
"""
|
||||
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 ReportTemplate, ReportAsset
|
||||
|
||||
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"
|
||||
30
api/tacticalrmm/ee/reporting/constants.py
Normal file
30
api/tacticalrmm/ee/reporting/constants.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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"),
|
||||
("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,139 @@
|
||||
"""
|
||||
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 apps
|
||||
from django.core.management.base import BaseCommand
|
||||
from reporting.settings import settings
|
||||
|
||||
import json
|
||||
|
||||
from reporting.constants import REPORTING_MODELS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate JSON Schemas"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
generate_schema()
|
||||
|
||||
|
||||
def generate_schema() -> None:
|
||||
oneOf = list()
|
||||
|
||||
for model, app in REPORTING_MODELS:
|
||||
Model = apps.get_model(app_label=app, model_name=model)
|
||||
|
||||
fields = Model._meta.get_fields()
|
||||
|
||||
order_by = []
|
||||
for field in fields:
|
||||
order_by.append(field.name)
|
||||
order_by.append(f"-{field.name}")
|
||||
|
||||
filterObj = {}
|
||||
patternObj = {}
|
||||
select_related = []
|
||||
|
||||
for field in fields:
|
||||
field_type = field.get_internal_type()
|
||||
|
||||
if field_type == "CharField" and field.choices:
|
||||
propDefinition = {
|
||||
"type": "string",
|
||||
"enum": [index for index, _ in field.choices],
|
||||
}
|
||||
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(field.name)
|
||||
|
||||
else:
|
||||
propDefinition = {
|
||||
"type": "string",
|
||||
}
|
||||
|
||||
filterObj[field.name] = propDefinition
|
||||
patternObj["^" + field.name + "(_{1,2}[a-zA-Z]+)*$"] = propDefinition
|
||||
|
||||
oneOf.append(
|
||||
{
|
||||
"properties": {
|
||||
"model": {"type": "string", "enum": [model.lower()]},
|
||||
"filter": {
|
||||
"type": "object",
|
||||
"properties": filterObj,
|
||||
"patternProperties": patternObj,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"exclude": {
|
||||
"type": "object",
|
||||
"properties": filterObj,
|
||||
"patternProperties": patternObj,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"defer": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minimum": 1,
|
||||
"enum": [field.name for field in fields],
|
||||
},
|
||||
},
|
||||
"only": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minimum": 1,
|
||||
"anyOf": [
|
||||
{"pattern": "^" + field.name + "(_{1,2}[a-zA-Z]+)*$"}
|
||||
for field in fields
|
||||
],
|
||||
},
|
||||
},
|
||||
"select_related": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minimum": 1,
|
||||
"enum": select_related,
|
||||
},
|
||||
},
|
||||
"order_by": {"type": "string", "enum": order_by},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
schema = {
|
||||
"$id": "https://example.com/schemas/reporting_query.json",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"enum": [model.lower() for model, _ in REPORTING_MODELS],
|
||||
},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["model"],
|
||||
"oneOf": oneOf,
|
||||
}
|
||||
|
||||
with open(
|
||||
f"{settings.REPORTING_ASSETS_BASE_PATH}/schemas/query_schema.json", "w"
|
||||
) as outfile:
|
||||
outfile.write(json.dumps(schema, indent=4))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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.core.management.base import BaseCommand
|
||||
from django.conf import settings as djangosettings
|
||||
from psycopg2 import connect, extensions, sql
|
||||
from reporting.settings import settings as reportingsettings
|
||||
from reporting.constants import REPORTING_MODELS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Setup reporting databases and users"
|
||||
|
||||
def handle(self, *args, **kwargs) -> None:
|
||||
try:
|
||||
self.trmm_db_conn = djangosettings.DATABASES["default"]
|
||||
self.conn = connect(
|
||||
dbname=self.trmm_db_conn["NAME"],
|
||||
user=self.trmm_db_conn["USER"],
|
||||
host=self.trmm_db_conn["HOST"],
|
||||
password=self.trmm_db_conn["PASSWORD"],
|
||||
port=self.trmm_db_conn["PORT"],
|
||||
)
|
||||
self.cursor = self.conn.cursor()
|
||||
self.create_reporting_db_user()
|
||||
|
||||
self.cursor.close()
|
||||
self.conn.close()
|
||||
except Exception as error:
|
||||
self.stderr.write(str(error))
|
||||
|
||||
def create_reporting_db_user(self) -> None:
|
||||
role_name = "role_reporting"
|
||||
trmm_database_name = self.trmm_db_conn["NAME"]
|
||||
reporting_user = reportingsettings.REPORTING_DB_USER
|
||||
reporting_password = reportingsettings.REPORTING_DB_PASSWORD
|
||||
|
||||
sql_commands = f"""CREATE ROLE {role_name};\n"""
|
||||
sql_commands += (
|
||||
f"""GRANT CONNECT ON DATABASE {trmm_database_name} TO {role_name};\n"""
|
||||
)
|
||||
sql_commands += f"""GRANT USAGE ON SCHEMA public TO {role_name};\n"""
|
||||
|
||||
for model, app in REPORTING_MODELS:
|
||||
sql_commands += (
|
||||
f"""GRANT SELECT ON {app}_{model.lower()} TO {role_name};\n"""
|
||||
)
|
||||
|
||||
sql_commands += (
|
||||
f"""CREATE USER {reporting_user} WITH PASSWORD {reporting_password};\n"""
|
||||
)
|
||||
|
||||
self.cursor.execute(sql_commands)
|
||||
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
|
||||
"""
|
||||
36
api/tacticalrmm/ee/reporting/markdown/config.py
Normal file
36
api/tacticalrmm/ee/reporting/markdown/config.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
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
|
||||
import yaml
|
||||
|
||||
from .djangotable_ext import DjangoTableExtension
|
||||
from .reportasset_ext import ReportAssetExtension
|
||||
|
||||
markdown_ext: "Optional[Sequence[Union[str, markdown.Extension]]]" = [
|
||||
"ocxsect",
|
||||
"tables",
|
||||
"sane_lists",
|
||||
"def_list",
|
||||
"nl2br",
|
||||
"fenced_code",
|
||||
"full_yaml_metadata",
|
||||
"attr_list",
|
||||
ReportAssetExtension(),
|
||||
DjangoTableExtension(),
|
||||
]
|
||||
|
||||
extension_config = {
|
||||
"full_yaml_metadata": {
|
||||
"yaml_loader": yaml.SafeLoader,
|
||||
},
|
||||
}
|
||||
|
||||
# import this into views
|
||||
Markdown = markdown.Markdown(
|
||||
extensions=markdown_ext, extension_configs=extension_config
|
||||
)
|
||||
168
api/tacticalrmm/ee/reporting/markdown/djangotable_ext.py
Normal file
168
api/tacticalrmm/ee/reporting/markdown/djangotable_ext.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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 Dict, List, Any
|
||||
from markdown import Extension, Markdown
|
||||
from markdown.preprocessors import Preprocessor
|
||||
|
||||
from django.apps import apps
|
||||
from ..constants import REPORTING_MODELS
|
||||
from ..settings import settings
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
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.keys():
|
||||
for model, app in REPORTING_MODELS:
|
||||
if data_source["model"].capitalize() == model:
|
||||
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")
|
||||
|
||||
|
||||
ALLOWED_OPERATIONS = (
|
||||
# filtering
|
||||
"only",
|
||||
"defer",
|
||||
"filter",
|
||||
"exclude",
|
||||
"limit",
|
||||
# relations
|
||||
"select_related",
|
||||
"prefetch_related",
|
||||
# operations
|
||||
"aggregate",
|
||||
"annotate",
|
||||
# ordering
|
||||
"order_by",
|
||||
)
|
||||
|
||||
|
||||
class InvalidDBOperationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def build_queryset(*, data_source: Dict[str, Any]) -> Any:
|
||||
local_data_source = data_source
|
||||
Model = local_data_source.pop("model")
|
||||
limit = None
|
||||
columns = local_data_source["only"] if "only" in local_data_source.keys() else None
|
||||
|
||||
# create a base reporting queryset
|
||||
queryset = Model.objects.using(settings.REPORTING_TRMM_CONNECTION_NAME)
|
||||
|
||||
for operation, values in local_data_source.items():
|
||||
if operation not in ALLOWED_OPERATIONS:
|
||||
raise InvalidDBOperationException(
|
||||
f"DB operation: {operation} not allowed. Supported operations: only, defer, filter, exclude, limit, select_related, prefetch_related, annotate, aggregate, order_by"
|
||||
)
|
||||
|
||||
if operation == "meta":
|
||||
continue
|
||||
elif operation == "limit":
|
||||
limit = values
|
||||
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 limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
if columns:
|
||||
queryset = queryset.values(*columns)
|
||||
else:
|
||||
queryset = queryset.values()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
RE = re.compile(r"\\table\((.*)\)")
|
||||
|
||||
|
||||
class DjangoTableExtension(Extension):
|
||||
"""Extension for parsing Django querysets into markdown tables"""
|
||||
|
||||
def extendMarkdown(self, md: Markdown) -> None:
|
||||
"""Add DjangoTableExtension to Markdown instance."""
|
||||
md.preprocessors.register(DjangoTablePreprocessor(md), "djangotable", 0)
|
||||
|
||||
|
||||
class DjangoTablePreprocessor(Preprocessor):
|
||||
"""
|
||||
Looks for \\table(datasource) and executes the query and uses pandas to convert to markdown table.
|
||||
Uses a special reporting connection to limit access to SELECTS and only certain tables.
|
||||
"""
|
||||
|
||||
def run(self, lines: List[str]) -> List[str]:
|
||||
inline_data_sources = (
|
||||
self.md.Meta["data_sources"]
|
||||
if hasattr(self.md, "Meta")
|
||||
and self.md.Meta
|
||||
and "data_sources" in self.md.Meta.keys()
|
||||
else None
|
||||
)
|
||||
|
||||
new_lines: List[str] = []
|
||||
for line in lines:
|
||||
m = RE.search(line)
|
||||
if m:
|
||||
data_query_name = m.group(1)
|
||||
if (
|
||||
inline_data_sources
|
||||
and data_query_name in inline_data_sources.keys()
|
||||
):
|
||||
data_source = inline_data_sources[m.group(1)]
|
||||
else:
|
||||
# no inline data sources found in yaml, check if it exists in DB
|
||||
ReportDataQuery = apps.get_model("reporting", "ReportDataQuery")
|
||||
try:
|
||||
data_source = ReportDataQuery.objects.get(
|
||||
name=data_query_name
|
||||
).json_query
|
||||
except ReportDataQuery.DoesNotExist:
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
meta = data_source.pop("meta") if "meta" in data_source.keys() else None
|
||||
|
||||
modified_datasource = resolve_model(data_source=data_source)
|
||||
queryset = build_queryset(data_source=modified_datasource)
|
||||
df = pd.DataFrame.from_records(queryset)
|
||||
|
||||
if meta:
|
||||
if "rename_columns" in meta.keys():
|
||||
df.rename(columns=meta["rename_columns"], inplace=True)
|
||||
|
||||
new_lines = new_lines + df.to_markdown(index=False).split("\n")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
def makeExtension(*args: Any, **kwargs: Any) -> DjangoTableExtension:
|
||||
"""set up extension."""
|
||||
return DjangoTableExtension(*args, *kwargs)
|
||||
59
api/tacticalrmm/ee/reporting/markdown/reportasset_ext.py
Normal file
59
api/tacticalrmm/ee/reporting/markdown/reportasset_ext.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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 List, Any, TYPE_CHECKING
|
||||
from markdown import Extension, Markdown
|
||||
from markdown.preprocessors import Preprocessor
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models import ReportAsset as ReportAssetType
|
||||
|
||||
# looking for asset://123e4567-e89b-12d3-a456-426614174000 and replaces with actual path to render image
|
||||
RE = 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})"
|
||||
)
|
||||
|
||||
|
||||
class ReportAssetExtension(Extension):
|
||||
"""Extension for looking up asset://{uuid4} urls in the DB"""
|
||||
|
||||
def extendMarkdown(self, md: Markdown) -> None:
|
||||
"""Add ReportAssetExtension to Markdown instance."""
|
||||
md.preprocessors.register(ReportAssetPreprocessor(md), "reportasset", 0)
|
||||
|
||||
|
||||
class ReportAssetPreprocessor(Preprocessor):
|
||||
"""
|
||||
Looks for asset://123e4567-e89b-12d3-a456-426614174000 and replaces with actual
|
||||
path on the file system to render image
|
||||
"""
|
||||
|
||||
def run(self, lines: List[str]) -> List[str]:
|
||||
new_lines: List[str] = []
|
||||
for line in lines:
|
||||
m = RE.search(line)
|
||||
if m:
|
||||
asset_id = m.group(1)
|
||||
ReportAsset = apps.get_model("reporting", "ReportAsset")
|
||||
|
||||
asset: "ReportAssetType" = ReportAsset.objects.get(id=asset_id)
|
||||
|
||||
new_line = line.replace(f"asset://{asset_id}", asset.file.url)
|
||||
|
||||
new_lines.append(new_line)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
def makeExtension(*args: Any, **kwargs: Any) -> ReportAssetExtension:
|
||||
"""set up extension."""
|
||||
return ReportAssetExtension(*args, **kwargs)
|
||||
29
api/tacticalrmm/ee/reporting/migrations/0001_initial.py
Normal file
29
api/tacticalrmm/ee/reporting/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2.6 on 2021-11-19 15:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
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)),
|
||||
("template_md", models.TextField()),
|
||||
("template_css", models.TextField()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.11 on 2022-03-12 19:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reporting', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='reporttemplate',
|
||||
name='template_css',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
39
api/tacticalrmm/ee/reporting/migrations/0003_reportasset.py
Normal file
39
api/tacticalrmm/ee/reporting/migrations/0003_reportasset.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-16 21:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import ee.reporting.storage
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("reporting", "0002_alter_reporttemplate_template_css"),
|
||||
]
|
||||
|
||||
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.ReportAssetStorage(
|
||||
location="/opt/tactical/reporting/assets"
|
||||
),
|
||||
upload_to="",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-18 18:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reporting', '0003_reportasset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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)),
|
||||
('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.AlterField(
|
||||
model_name='reporttemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reporttemplate',
|
||||
name='template_base',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='htmltemplate', to='reporting.reporthtmltemplate'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-18 18:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reporting', '0004_reportdataquery_reporthtmltemplate_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='reportdataquery',
|
||||
name='query',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reportdataquery',
|
||||
name='yaml_query',
|
||||
field=models.TextField(default='this'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-18 21:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reporting', '0005_remove_reportdataquery_query_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='reporttemplate',
|
||||
old_name='template_base',
|
||||
new_name='template_html',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.3 on 2022-12-14 19:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reporting', '0006_rename_template_base_reporttemplate_template_html'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='reportdataquery',
|
||||
name='yaml_query',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='reportdataquery',
|
||||
name='json_query',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.3 on 2022-12-16 04:09
|
||||
|
||||
from django.db import migrations, models
|
||||
import ee.reporting.storage
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("reporting", "0007_remove_reportdataquery_yaml_query_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="reporttemplate",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[("markdown", "Markdown"), ("html", "Html")],
|
||||
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
|
||||
"""
|
||||
58
api/tacticalrmm/ee/reporting/models.py
Normal file
58
api/tacticalrmm/ee/reporting/models.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
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.db import models
|
||||
import uuid
|
||||
from .storage import report_assets_fs
|
||||
|
||||
|
||||
class ReportFormatType(models.TextChoices):
|
||||
MARKDOWN = "markdown", "Markdown"
|
||||
HTML = "html", "Html"
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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=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()
|
||||
57
api/tacticalrmm/ee/reporting/settings.py
Normal file
57
api/tacticalrmm/ee/reporting/settings.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# settings for trmm readonly database account
|
||||
@property
|
||||
def REPORTING_CONNECTION_NAME(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_CONNECTION_NAME", "reporting")
|
||||
|
||||
@property
|
||||
def REPORTING_DB_NAME(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_NAME", "tacticalrmm")
|
||||
|
||||
@property
|
||||
def REPORTING_DB_HOST(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_HOST", "localhost")
|
||||
|
||||
@property
|
||||
def REPORTING_DB_PORT(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_PORT", "5432")
|
||||
|
||||
@property
|
||||
def REPORTING_DB_USER(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_USER", "reporting_user")
|
||||
|
||||
@property
|
||||
def REPORTING_DB_PASSWORD(self) -> str:
|
||||
return getattr(self.settings, "REPORTING_DB_PASSWORD", "reporting_password")
|
||||
|
||||
@property
|
||||
def REPORTING_ASSETS_BASE_PATH(self) -> str:
|
||||
return getattr(
|
||||
self.settings,
|
||||
"REPORTING_ASSETS_BASE_PATH",
|
||||
"/opt/tacticalrmm/reporting",
|
||||
)
|
||||
|
||||
@property
|
||||
def REPORTING_BASE_URL(self) -> str:
|
||||
return getattr(
|
||||
self.settings,
|
||||
"REPORTING_BASE_URL",
|
||||
f"https://{djangosettings.ALLOWED_HOSTS[0]}/assets",
|
||||
)
|
||||
|
||||
|
||||
# import this to load initialized settings during runtime
|
||||
settings = Settings()
|
||||
69
api/tacticalrmm/ee/reporting/storage.py
Normal file
69
api/tacticalrmm/ee/reporting/storage.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
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)
|
||||
else:
|
||||
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/files",
|
||||
)
|
||||
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
|
||||
"""
|
||||
259
api/tacticalrmm/ee/reporting/tests/test_storage.py
Normal file
259
api/tacticalrmm/ee/reporting/tests/test_storage.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Copyright (c) 2023-present Amidaware Inc.
|
||||
This file is subject to the EE License Agreement.
|
||||
For details, see: https://license.tacticalrmm.com/ee
|
||||
"""
|
||||
|
||||
from reporting.storage import ReportAssetStorage
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
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:
|
||||
assert False
|
||||
31
api/tacticalrmm/ee/reporting/urls.py
Normal file
31
api/tacticalrmm/ee/reporting/urls.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
|
||||
"""
|
||||
|
||||
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.GenerateSavedReport.as_view()),
|
||||
path("templates/preview/", views.GenerateReportPreview.as_view()),
|
||||
# report assets
|
||||
path("assets/", views.GetReportAssets.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.redirect_assets_to_nginx_if_authenticated),
|
||||
]
|
||||
58
api/tacticalrmm/ee/reporting/utils.py
Normal file
58
api/tacticalrmm/ee/reporting/utils.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Copyright (c) 2023-present Amidaware Inc.
|
||||
This file is subject to the EE License Agreement.
|
||||
For details, see: https://license.tacticalrmm.com/ee
|
||||
"""
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from jinja2 import Template
|
||||
from .markdown.config import Markdown
|
||||
from typing import Optional
|
||||
|
||||
|
||||
default_template = """
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
{{ css }}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ body }}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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[str] = None
|
||||
) -> str:
|
||||
tm = Template(html_template if html_template else default_template)
|
||||
html_report = tm.render(
|
||||
css=css,
|
||||
body=Markdown.convert(template) if template_type == "markdown" else template,
|
||||
)
|
||||
|
||||
return html_report
|
||||
|
||||
|
||||
def notify_error(msg: str) -> Response:
|
||||
return Response(msg, status=status.HTTP_400_BAD_REQUEST)
|
||||
506
api/tacticalrmm/ee/reporting/views.py
Normal file
506
api/tacticalrmm/ee/reporting/views.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""
|
||||
Copyright (c) 2023-present Amidaware Inc.
|
||||
This file is subject to the EE License Agreement.
|
||||
For details, see: https://license.tacticalrmm.com/ee
|
||||
"""
|
||||
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.serializers import (
|
||||
Serializer,
|
||||
ModelSerializer,
|
||||
CharField,
|
||||
ListField,
|
||||
ValidationError,
|
||||
)
|
||||
from typing import Union, List
|
||||
from django.core.exceptions import (
|
||||
SuspiciousFileOperation,
|
||||
ObjectDoesNotExist,
|
||||
PermissionDenied,
|
||||
)
|
||||
from django.core.files.base import ContentFile
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from .storage import report_assets_fs
|
||||
from .models import ReportTemplate, ReportAsset, ReportHTMLTemplate, ReportDataQuery
|
||||
from .utils import generate_html, generate_pdf, notify_error
|
||||
|
||||
|
||||
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):
|
||||
queryset = ReportTemplate.objects.all()
|
||||
serializer_class = ReportTemplateSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
reports = ReportTemplate.objects.all()
|
||||
return Response(ReportTemplateSerializer(reports, 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):
|
||||
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 GenerateSavedReport(APIView):
|
||||
def post(self, request: Request, pk: int) -> Union[FileResponse, Response]:
|
||||
template = get_object_or_404(ReportTemplate, pk=pk)
|
||||
|
||||
html_report = generate_html(
|
||||
template=template.template_md,
|
||||
template_type=template.type,
|
||||
css=template.template_css if template.template_css else "",
|
||||
html_template=template.template_html.html
|
||||
if template.template_html
|
||||
else None,
|
||||
)
|
||||
|
||||
pdf_bytes = generate_pdf(html=html_report)
|
||||
|
||||
return FileResponse(
|
||||
ContentFile(pdf_bytes),
|
||||
content_type="application/pdf",
|
||||
filename=f"{template.name}.pdf",
|
||||
)
|
||||
|
||||
|
||||
class GenerateReportPreview(APIView):
|
||||
def post(self, request: Request) -> Union[FileResponse, Response]:
|
||||
template_md = request.data["template_md"]
|
||||
template_css = request.data["template_css"]
|
||||
template_type = request.data["type"]
|
||||
template_html = (
|
||||
request.data["template_html"]
|
||||
if "template_html" in request.data.keys()
|
||||
else None
|
||||
)
|
||||
|
||||
response_format = request.data["format"]
|
||||
|
||||
# lookup html template
|
||||
if template_html:
|
||||
try:
|
||||
template_html = ReportHTMLTemplate.objects.get(pk=template_html).html
|
||||
html_report = generate_html(
|
||||
template=template_md,
|
||||
template_type=template_type,
|
||||
css=template_css,
|
||||
html_template=template_html,
|
||||
)
|
||||
except ReportHTMLTemplate.DoesNotExist:
|
||||
html_report = generate_html(
|
||||
template=template_md, template_type=template_type, css=template_css
|
||||
)
|
||||
else:
|
||||
html_report = generate_html(
|
||||
template=template_md, template_type=template_type, css=template_css
|
||||
)
|
||||
|
||||
if response_format == "html":
|
||||
return Response(html_report)
|
||||
else:
|
||||
pdf_bytes = generate_pdf(html=html_report)
|
||||
|
||||
return FileResponse(
|
||||
ContentFile(pdf_bytes),
|
||||
content_type="application/pdf",
|
||||
filename=f"preview.pdf",
|
||||
)
|
||||
|
||||
|
||||
class GetReportAssets(APIView):
|
||||
def get(self, request: Request) -> Response:
|
||||
path = request.query_params.get("path", "").lstrip("/")
|
||||
|
||||
directories, files = report_assets_fs.listdir(path)
|
||||
response = list()
|
||||
|
||||
# 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):
|
||||
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
|
||||
|
||||
response = {}
|
||||
|
||||
# recursively loop over report assets and add them to response
|
||||
try:
|
||||
os.chdir(report_assets_fs.base_location)
|
||||
except FileNotFoundError:
|
||||
return notify_error("Unable to process request")
|
||||
|
||||
for current_dir, subdirs, files in os.walk("."):
|
||||
nodes = list()
|
||||
|
||||
for dirname in subdirs:
|
||||
nodes.append(
|
||||
{
|
||||
"type": "folder",
|
||||
"name": dirname,
|
||||
"path": f"{current_dir}/{dirname}",
|
||||
}
|
||||
)
|
||||
|
||||
if not only_folders:
|
||||
for filename in files:
|
||||
nodes.append(
|
||||
{
|
||||
"type": "file",
|
||||
"name": filename,
|
||||
"path": f"{current_dir}/{filename}",
|
||||
}
|
||||
)
|
||||
|
||||
response[current_dir] = nodes
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class RenameReportAsset(APIView):
|
||||
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):
|
||||
def post(self, request: Request) -> Response:
|
||||
path = request.data["path"].lstrip("/") if "path" in request.data else ""
|
||||
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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()
|
||||
|
||||
|
||||
def redirect_assets_to_nginx_if_authenticated(request, path):
|
||||
if request.user.is_authenticated:
|
||||
response = HttpResponse()
|
||||
response["X-Accel-Redirect"] = "/assets/" + path
|
||||
return response
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
@@ -6,6 +6,7 @@ types-pytz
|
||||
django-silk
|
||||
mypy
|
||||
django-stubs
|
||||
pandas-stubs
|
||||
djangorestframework-stubs
|
||||
django-types
|
||||
djangorestframework-types
|
||||
|
||||
@@ -38,3 +38,11 @@ validators==0.20.0
|
||||
vine==5.0.0
|
||||
websockets==11.0.3
|
||||
zipp==3.17.0
|
||||
meshctrl==0.1.15
|
||||
pandas==1.4.0
|
||||
jinja2==3.0.3
|
||||
markdown==3.3.6
|
||||
markdown-full-yaml-metadata==2.1.0
|
||||
tabulate==0.8.9
|
||||
weasyprint==54.3
|
||||
ocxsect==0.1.5
|
||||
|
||||
@@ -130,6 +130,7 @@ INSTALLED_APPS = [
|
||||
"logs",
|
||||
"scripts",
|
||||
"alerts",
|
||||
"ee.reporting",
|
||||
]
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
|
||||
@@ -38,6 +38,7 @@ 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):
|
||||
|
||||
Reference in New Issue
Block a user