reporting wip

This commit is contained in:
sadnub
2023-04-29 15:32:39 -04:00
parent 3a61430e44
commit 3403d76aae
37 changed files with 1842 additions and 5 deletions

View File

@@ -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

View File

@@ -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'
}
}
}

View 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.

View 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
"""

View 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
"""

View 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)

View 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"

View 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"),
)

View 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
"""

View 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
"""

View File

@@ -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))

View File

@@ -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)

View 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
"""

View 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
)

View 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)

View 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)

View 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()),
],
),
]

View File

@@ -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),
),
]

View 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,
),
),
],
),
]

View File

@@ -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'),
),
]

View File

@@ -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,
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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,
),
)
]

View 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
"""

View 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()

View 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()

View 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",
)

View 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
"""

View 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

View 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),
]

View 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)

View 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()

View File

@@ -6,6 +6,7 @@ types-pytz
django-silk
mypy
django-stubs
pandas-stubs
djangorestframework-stubs
django-types
djangorestframework-types

View File

@@ -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

View File

@@ -130,6 +130,7 @@ INSTALLED_APPS = [
"logs",
"scripts",
"alerts",
"ee.reporting",
]
CHANNEL_LAYERS = {

View File

@@ -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):