arthexis 0.1.9__py3-none-any.whl → 0.1.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/apps.py
CHANGED
|
@@ -104,7 +104,11 @@ class CoreConfig(AppConfig):
|
|
|
104
104
|
|
|
105
105
|
lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
|
|
106
106
|
|
|
107
|
+
from django.db.backends.signals import connection_created
|
|
108
|
+
|
|
107
109
|
if lock.exists():
|
|
110
|
+
from .auto_upgrade import ensure_auto_upgrade_periodic_task
|
|
111
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
108
112
|
|
|
109
113
|
def ensure_email_collector_task(**kwargs):
|
|
110
114
|
try: # pragma: no cover - optional dependency
|
|
@@ -131,8 +135,32 @@ class CoreConfig(AppConfig):
|
|
|
131
135
|
pass
|
|
132
136
|
|
|
133
137
|
post_migrate.connect(ensure_email_collector_task, sender=self)
|
|
138
|
+
post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
auto_upgrade_dispatch_uid = "core.apps.ensure_auto_upgrade_periodic_task"
|
|
141
|
+
|
|
142
|
+
def ensure_auto_upgrade_on_connection(**kwargs):
|
|
143
|
+
connection = kwargs.get("connection")
|
|
144
|
+
if connection is not None and connection.alias != "default":
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
ensure_auto_upgrade_periodic_task()
|
|
149
|
+
finally:
|
|
150
|
+
connection_created.disconnect(
|
|
151
|
+
receiver=ensure_auto_upgrade_on_connection,
|
|
152
|
+
dispatch_uid=auto_upgrade_dispatch_uid,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
connection_created.connect(
|
|
156
|
+
ensure_auto_upgrade_on_connection,
|
|
157
|
+
dispatch_uid=auto_upgrade_dispatch_uid,
|
|
158
|
+
weak=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
default_connection = connections[DEFAULT_DB_ALIAS]
|
|
162
|
+
if default_connection.connection is not None:
|
|
163
|
+
ensure_auto_upgrade_on_connection(connection=default_connection)
|
|
136
164
|
|
|
137
165
|
def enable_sqlite_wal(**kwargs):
|
|
138
166
|
connection = kwargs.get("connection")
|
core/auto_upgrade.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Helpers for managing the auto-upgrade scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
AUTO_UPGRADE_TASK_NAME = "auto-upgrade-check"
|
|
11
|
+
AUTO_UPGRADE_TASK_PATH = "core.tasks.check_github_updates"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_auto_upgrade_periodic_task(
|
|
15
|
+
sender=None, *, base_dir: Path | None = None, **kwargs
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Ensure the auto-upgrade periodic task exists.
|
|
18
|
+
|
|
19
|
+
The function is signal-safe so it can be wired to Django's
|
|
20
|
+
``post_migrate`` hook. When called directly the ``sender`` and
|
|
21
|
+
``**kwargs`` parameters are ignored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
del sender, kwargs # Unused when invoked as a Django signal handler.
|
|
25
|
+
|
|
26
|
+
if base_dir is None:
|
|
27
|
+
base_dir = Path(settings.BASE_DIR)
|
|
28
|
+
else:
|
|
29
|
+
base_dir = Path(base_dir)
|
|
30
|
+
|
|
31
|
+
lock_dir = base_dir / "locks"
|
|
32
|
+
mode_file = lock_dir / "auto_upgrade.lck"
|
|
33
|
+
if not mode_file.exists():
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
try: # pragma: no cover - optional dependency failures
|
|
37
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
38
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
39
|
+
except Exception:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
mode = mode_file.read_text().strip() or "version"
|
|
43
|
+
interval_minutes = 5 if mode == "latest" else 10
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
47
|
+
every=interval_minutes, period=IntervalSchedule.MINUTES
|
|
48
|
+
)
|
|
49
|
+
PeriodicTask.objects.update_or_create(
|
|
50
|
+
name=AUTO_UPGRADE_TASK_NAME,
|
|
51
|
+
defaults={
|
|
52
|
+
"interval": schedule,
|
|
53
|
+
"task": AUTO_UPGRADE_TASK_PATH,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
except (OperationalError, ProgrammingError): # pragma: no cover - DB not ready
|
|
57
|
+
return
|
core/backends.py
CHANGED
|
@@ -4,10 +4,65 @@ import contextlib
|
|
|
4
4
|
import ipaddress
|
|
5
5
|
import socket
|
|
6
6
|
|
|
7
|
+
from django.conf import settings
|
|
7
8
|
from django.contrib.auth import get_user_model
|
|
8
9
|
from django.contrib.auth.backends import ModelBackend
|
|
10
|
+
from django.core.exceptions import DisallowedHost
|
|
11
|
+
from django.http.request import split_domain_port
|
|
12
|
+
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
9
13
|
|
|
10
14
|
from .models import EnergyAccount
|
|
15
|
+
from . import temp_passwords
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TOTP_DEVICE_NAME = "authenticator"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TOTPBackend(ModelBackend):
|
|
22
|
+
"""Authenticate using a TOTP code from an enrolled authenticator app."""
|
|
23
|
+
|
|
24
|
+
def authenticate(self, request, username=None, otp_token=None, **kwargs):
|
|
25
|
+
if not username or otp_token in (None, ""):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
token = str(otp_token).strip().replace(" ", "")
|
|
29
|
+
if not token:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
UserModel = get_user_model()
|
|
33
|
+
try:
|
|
34
|
+
user = UserModel._default_manager.get_by_natural_key(username)
|
|
35
|
+
except UserModel.DoesNotExist:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if not user.is_active:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
|
|
42
|
+
if TOTP_DEVICE_NAME:
|
|
43
|
+
device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
|
|
44
|
+
|
|
45
|
+
device = device_qs.order_by("-id").first()
|
|
46
|
+
if device is None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
verified = device.verify_token(token)
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if not verified:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
user.otp_device = device
|
|
58
|
+
return user
|
|
59
|
+
|
|
60
|
+
def get_user(self, user_id):
|
|
61
|
+
UserModel = get_user_model()
|
|
62
|
+
try:
|
|
63
|
+
return UserModel._default_manager.get(pk=user_id)
|
|
64
|
+
except UserModel.DoesNotExist:
|
|
65
|
+
return None
|
|
11
66
|
|
|
12
67
|
|
|
13
68
|
class RFIDBackend:
|
|
@@ -69,15 +124,33 @@ def _collect_local_ip_addresses():
|
|
|
69
124
|
class LocalhostAdminBackend(ModelBackend):
|
|
70
125
|
"""Allow default admin credentials only from local networks."""
|
|
71
126
|
|
|
72
|
-
_ALLOWED_NETWORKS =
|
|
127
|
+
_ALLOWED_NETWORKS = (
|
|
73
128
|
ipaddress.ip_network("::1/128"),
|
|
74
129
|
ipaddress.ip_network("127.0.0.0/8"),
|
|
130
|
+
ipaddress.ip_network("10.42.0.0/16"),
|
|
75
131
|
ipaddress.ip_network("192.168.0.0/16"),
|
|
76
|
-
|
|
132
|
+
)
|
|
133
|
+
_CONTROL_ALLOWED_NETWORKS = (ipaddress.ip_network("10.0.0.0/8"),)
|
|
77
134
|
_LOCAL_IPS = _collect_local_ip_addresses()
|
|
78
135
|
|
|
136
|
+
def _iter_allowed_networks(self):
|
|
137
|
+
yield from self._ALLOWED_NETWORKS
|
|
138
|
+
if getattr(settings, "NODE_ROLE", "") == "Control":
|
|
139
|
+
yield from self._CONTROL_ALLOWED_NETWORKS
|
|
140
|
+
|
|
79
141
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
80
142
|
if username == "admin" and password == "admin" and request is not None:
|
|
143
|
+
try:
|
|
144
|
+
host = request.get_host()
|
|
145
|
+
except DisallowedHost:
|
|
146
|
+
return None
|
|
147
|
+
host, _port = split_domain_port(host)
|
|
148
|
+
if host.startswith("[") and host.endswith("]"):
|
|
149
|
+
host = host[1:-1]
|
|
150
|
+
try:
|
|
151
|
+
ipaddress.ip_address(host)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
81
154
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
82
155
|
if forwarded:
|
|
83
156
|
remote = forwarded.split(",")[0].strip()
|
|
@@ -87,7 +160,7 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
87
160
|
ip = ipaddress.ip_address(remote)
|
|
88
161
|
except ValueError:
|
|
89
162
|
return None
|
|
90
|
-
allowed = any(ip in net for net in self.
|
|
163
|
+
allowed = any(ip in net for net in self._iter_allowed_networks())
|
|
91
164
|
if not allowed and ip in self._LOCAL_IPS:
|
|
92
165
|
allowed = True
|
|
93
166
|
if not allowed:
|
|
@@ -100,6 +173,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
100
173
|
"is_superuser": True,
|
|
101
174
|
},
|
|
102
175
|
)
|
|
176
|
+
if not created and not user.is_active:
|
|
177
|
+
return None
|
|
103
178
|
arthexis_user = (
|
|
104
179
|
User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
|
|
105
180
|
)
|
|
@@ -122,3 +197,40 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
122
197
|
return User.all_objects.get(pk=user_id)
|
|
123
198
|
except User.DoesNotExist:
|
|
124
199
|
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TempPasswordBackend(ModelBackend):
|
|
203
|
+
"""Authenticate using a temporary password stored in a lockfile."""
|
|
204
|
+
|
|
205
|
+
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
206
|
+
if not username or not password:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
UserModel = get_user_model()
|
|
210
|
+
manager = getattr(UserModel, "all_objects", UserModel._default_manager)
|
|
211
|
+
try:
|
|
212
|
+
user = manager.get_by_natural_key(username)
|
|
213
|
+
except UserModel.DoesNotExist:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
entry = temp_passwords.load_temp_password(user.username)
|
|
217
|
+
if entry is None:
|
|
218
|
+
return None
|
|
219
|
+
if entry.is_expired:
|
|
220
|
+
temp_passwords.discard_temp_password(user.username)
|
|
221
|
+
return None
|
|
222
|
+
if not entry.check_password(password):
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
if not user.is_active:
|
|
226
|
+
user.is_active = True
|
|
227
|
+
user.save(update_fields=["is_active"])
|
|
228
|
+
return user
|
|
229
|
+
|
|
230
|
+
def get_user(self, user_id):
|
|
231
|
+
UserModel = get_user_model()
|
|
232
|
+
manager = getattr(UserModel, "all_objects", UserModel._default_manager)
|
|
233
|
+
try:
|
|
234
|
+
return manager.get(pk=user_id)
|
|
235
|
+
except UserModel.DoesNotExist:
|
|
236
|
+
return None
|
core/environment.py
CHANGED
|
@@ -9,22 +9,35 @@ from django.urls import path
|
|
|
9
9
|
from django.utils.translation import gettext_lazy as _
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
django_settings = sorted(
|
|
12
|
+
def _get_django_settings():
|
|
13
|
+
return sorted(
|
|
15
14
|
[(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
|
|
16
15
|
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _environment_view(request):
|
|
19
|
+
env_vars = sorted(os.environ.items())
|
|
17
20
|
context = admin.site.each_context(request)
|
|
18
21
|
context.update(
|
|
19
22
|
{
|
|
20
|
-
"title": _("
|
|
23
|
+
"title": _("Environ"),
|
|
21
24
|
"env_vars": env_vars,
|
|
22
|
-
"django_settings": django_settings,
|
|
23
25
|
}
|
|
24
26
|
)
|
|
25
27
|
return TemplateResponse(request, "admin/environment.html", context)
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def _config_view(request):
|
|
31
|
+
context = admin.site.each_context(request)
|
|
32
|
+
context.update(
|
|
33
|
+
{
|
|
34
|
+
"title": _("Config"),
|
|
35
|
+
"django_settings": _get_django_settings(),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
return TemplateResponse(request, "admin/config.html", context)
|
|
39
|
+
|
|
40
|
+
|
|
28
41
|
def patch_admin_environment_view() -> None:
|
|
29
42
|
"""Add custom admin view for environment information."""
|
|
30
43
|
original_get_urls = admin.site.get_urls
|
|
@@ -37,6 +50,11 @@ def patch_admin_environment_view() -> None:
|
|
|
37
50
|
admin.site.admin_view(_environment_view),
|
|
38
51
|
name="environment",
|
|
39
52
|
),
|
|
53
|
+
path(
|
|
54
|
+
"config/",
|
|
55
|
+
admin.site.admin_view(_config_view),
|
|
56
|
+
name="config",
|
|
57
|
+
),
|
|
40
58
|
]
|
|
41
59
|
return custom + urls
|
|
42
60
|
|
core/fields.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import re
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
1
5
|
from django.db import models
|
|
2
6
|
from django.db.models.fields import DeferredAttribute
|
|
7
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
class _BaseSigilDescriptor(DeferredAttribute):
|
|
@@ -73,3 +78,91 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
|
|
|
73
78
|
|
|
74
79
|
class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
|
|
75
80
|
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConditionEvaluationError(Exception):
|
|
84
|
+
"""Raised when a condition expression cannot be evaluated."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ConditionCheckResult:
|
|
89
|
+
"""Represents the outcome of evaluating a condition field."""
|
|
90
|
+
|
|
91
|
+
passed: bool
|
|
92
|
+
resolved: str
|
|
93
|
+
error: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_COMMENT_PATTERN = re.compile(r"(--|/\*)")
|
|
97
|
+
_FORBIDDEN_KEYWORDS = re.compile(
|
|
98
|
+
r"\b(ATTACH|DETACH|ALTER|ANALYZE|CREATE|DROP|INSERT|UPDATE|DELETE|REPLACE|"
|
|
99
|
+
r"VACUUM|TRIGGER|TABLE|INDEX|VIEW|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|WITH)\b",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _evaluate_sql_condition(expression: str) -> bool:
|
|
105
|
+
"""Evaluate a SQL expression in an isolated SQLite connection."""
|
|
106
|
+
|
|
107
|
+
if ";" in expression:
|
|
108
|
+
raise ConditionEvaluationError(
|
|
109
|
+
_("Semicolons are not allowed in conditions."),
|
|
110
|
+
)
|
|
111
|
+
if _COMMENT_PATTERN.search(expression):
|
|
112
|
+
raise ConditionEvaluationError(
|
|
113
|
+
_("SQL comments are not allowed in conditions."),
|
|
114
|
+
)
|
|
115
|
+
match = _FORBIDDEN_KEYWORDS.search(expression)
|
|
116
|
+
if match:
|
|
117
|
+
raise ConditionEvaluationError(
|
|
118
|
+
_("Disallowed keyword in condition: %(keyword)s")
|
|
119
|
+
% {"keyword": match.group(1)},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
conn = sqlite3.connect(":memory:")
|
|
124
|
+
try:
|
|
125
|
+
conn.execute("PRAGMA trusted_schema = OFF")
|
|
126
|
+
conn.execute("PRAGMA foreign_keys = OFF")
|
|
127
|
+
try:
|
|
128
|
+
conn.enable_load_extension(False)
|
|
129
|
+
except AttributeError:
|
|
130
|
+
# ``enable_load_extension`` is not available on some platforms.
|
|
131
|
+
pass
|
|
132
|
+
cursor = conn.execute(
|
|
133
|
+
f"SELECT CASE WHEN ({expression}) THEN 1 ELSE 0 END"
|
|
134
|
+
)
|
|
135
|
+
row = cursor.fetchone()
|
|
136
|
+
return bool(row[0]) if row else False
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
except sqlite3.Error as exc: # pragma: no cover - exact error message varies
|
|
140
|
+
raise ConditionEvaluationError(str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ConditionTextField(models.TextField):
|
|
144
|
+
"""Field storing a conditional SQL expression resolved through [sigils]."""
|
|
145
|
+
|
|
146
|
+
def evaluate(self, instance) -> ConditionCheckResult:
|
|
147
|
+
"""Evaluate the stored expression for ``instance``."""
|
|
148
|
+
|
|
149
|
+
value = self.value_from_object(instance)
|
|
150
|
+
if hasattr(instance, "resolve_sigils"):
|
|
151
|
+
resolved = instance.resolve_sigils(self.name)
|
|
152
|
+
else:
|
|
153
|
+
resolved = value
|
|
154
|
+
|
|
155
|
+
if resolved is None:
|
|
156
|
+
resolved_text = ""
|
|
157
|
+
else:
|
|
158
|
+
resolved_text = str(resolved)
|
|
159
|
+
|
|
160
|
+
resolved_text = resolved_text.strip()
|
|
161
|
+
if not resolved_text:
|
|
162
|
+
return ConditionCheckResult(True, resolved_text)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
passed = _evaluate_sql_condition(resolved_text)
|
|
166
|
+
return ConditionCheckResult(passed, resolved_text)
|
|
167
|
+
except ConditionEvaluationError as exc:
|
|
168
|
+
return ConditionCheckResult(False, resolved_text, str(exc))
|
core/mailer.py
CHANGED
|
@@ -61,7 +61,9 @@ def can_send_email() -> bool:
|
|
|
61
61
|
|
|
62
62
|
from nodes.models import EmailOutbox # imported lazily to avoid circular deps
|
|
63
63
|
|
|
64
|
-
has_outbox =
|
|
64
|
+
has_outbox = (
|
|
65
|
+
EmailOutbox.objects.filter(is_enabled=True).exclude(host="").exists()
|
|
66
|
+
)
|
|
65
67
|
if has_outbox:
|
|
66
68
|
return True
|
|
67
69
|
|