arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.

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,12 +4,66 @@ 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
11
15
 
12
16
 
17
+ TOTP_DEVICE_NAME = "authenticator"
18
+
19
+
20
+ class TOTPBackend(ModelBackend):
21
+ """Authenticate using a TOTP code from an enrolled authenticator app."""
22
+
23
+ def authenticate(self, request, username=None, otp_token=None, **kwargs):
24
+ if not username or otp_token in (None, ""):
25
+ return None
26
+
27
+ token = str(otp_token).strip().replace(" ", "")
28
+ if not token:
29
+ return None
30
+
31
+ UserModel = get_user_model()
32
+ try:
33
+ user = UserModel._default_manager.get_by_natural_key(username)
34
+ except UserModel.DoesNotExist:
35
+ return None
36
+
37
+ if not user.is_active:
38
+ return None
39
+
40
+ device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
41
+ if TOTP_DEVICE_NAME:
42
+ device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
43
+
44
+ device = device_qs.order_by("-id").first()
45
+ if device is None:
46
+ return None
47
+
48
+ try:
49
+ verified = device.verify_token(token)
50
+ except Exception:
51
+ return None
52
+
53
+ if not verified:
54
+ return None
55
+
56
+ user.otp_device = device
57
+ return user
58
+
59
+ def get_user(self, user_id):
60
+ UserModel = get_user_model()
61
+ try:
62
+ return UserModel._default_manager.get(pk=user_id)
63
+ except UserModel.DoesNotExist:
64
+ return None
65
+
66
+
13
67
  class RFIDBackend:
14
68
  """Authenticate using a user's RFID."""
15
69
 
@@ -69,15 +123,33 @@ def _collect_local_ip_addresses():
69
123
  class LocalhostAdminBackend(ModelBackend):
70
124
  """Allow default admin credentials only from local networks."""
71
125
 
72
- _ALLOWED_NETWORKS = [
126
+ _ALLOWED_NETWORKS = (
73
127
  ipaddress.ip_network("::1/128"),
74
128
  ipaddress.ip_network("127.0.0.0/8"),
129
+ ipaddress.ip_network("10.42.0.0/16"),
75
130
  ipaddress.ip_network("192.168.0.0/16"),
76
- ]
131
+ )
132
+ _CONTROL_ALLOWED_NETWORKS = (ipaddress.ip_network("10.0.0.0/8"),)
77
133
  _LOCAL_IPS = _collect_local_ip_addresses()
78
134
 
135
+ def _iter_allowed_networks(self):
136
+ yield from self._ALLOWED_NETWORKS
137
+ if getattr(settings, "NODE_ROLE", "") == "Control":
138
+ yield from self._CONTROL_ALLOWED_NETWORKS
139
+
79
140
  def authenticate(self, request, username=None, password=None, **kwargs):
80
141
  if username == "admin" and password == "admin" and request is not None:
142
+ try:
143
+ host = request.get_host()
144
+ except DisallowedHost:
145
+ return None
146
+ host, _port = split_domain_port(host)
147
+ if host.startswith("[") and host.endswith("]"):
148
+ host = host[1:-1]
149
+ try:
150
+ ipaddress.ip_address(host)
151
+ except ValueError:
152
+ return None
81
153
  forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
82
154
  if forwarded:
83
155
  remote = forwarded.split(",")[0].strip()
@@ -87,7 +159,7 @@ class LocalhostAdminBackend(ModelBackend):
87
159
  ip = ipaddress.ip_address(remote)
88
160
  except ValueError:
89
161
  return None
90
- allowed = any(ip in net for net in self._ALLOWED_NETWORKS)
162
+ allowed = any(ip in net for net in self._iter_allowed_networks())
91
163
  if not allowed and ip in self._LOCAL_IPS:
92
164
  allowed = True
93
165
  if not allowed:
@@ -100,6 +172,8 @@ class LocalhostAdminBackend(ModelBackend):
100
172
  "is_superuser": True,
101
173
  },
102
174
  )
175
+ if not created and not user.is_active:
176
+ return None
103
177
  arthexis_user = (
104
178
  User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
105
179
  )
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/models.py CHANGED
@@ -9,12 +9,12 @@ from django.db.models.functions import Lower
9
9
  from django.conf import settings
10
10
  from django.contrib.auth import get_user_model
11
11
  from django.utils.translation import gettext_lazy as _
12
- from django.core.validators import RegexValidator
12
+ from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
13
13
  from django.core.exceptions import ValidationError
14
14
  from django.apps import apps
15
15
  from django.db.models.signals import m2m_changed, post_delete, post_save
16
16
  from django.dispatch import receiver
17
- from datetime import timedelta
17
+ from datetime import time as datetime_time, timedelta
18
18
  from django.contrib.contenttypes.models import ContentType
19
19
  import hashlib
20
20
  import os
@@ -39,7 +39,11 @@ xmlrpc_client = defused_xmlrpc.xmlrpc_client
39
39
  from .entity import Entity, EntityUserManager, EntityManager
40
40
  from .release import Package as ReleasePackage, Credentials, DEFAULT_PACKAGE
41
41
  from . import user_data # noqa: F401 - ensure signal registration
42
- from .fields import SigilShortAutoField
42
+ from .fields import (
43
+ SigilShortAutoField,
44
+ ConditionTextField,
45
+ ConditionCheckResult,
46
+ )
43
47
 
44
48
 
45
49
  class SecurityGroup(Group):
@@ -225,7 +229,7 @@ class InviteLead(Lead):
225
229
 
226
230
 
227
231
  class PublicWifiAccess(Entity):
228
- """Allow public Wi-Fi clients onto the wider internet."""
232
+ """Represent a Wi-Fi lease granted to a client for internet access."""
229
233
 
230
234
  user = models.ForeignKey(
231
235
  settings.AUTH_USER_MODEL,
@@ -239,8 +243,8 @@ class PublicWifiAccess(Entity):
239
243
 
240
244
  class Meta:
241
245
  unique_together = ("user", "mac_address")
242
- verbose_name = "Public Wi-Fi Access"
243
- verbose_name_plural = "Public Wi-Fi Access"
246
+ verbose_name = "Wi-Fi Lease"
247
+ verbose_name_plural = "Wi-Fi Leases"
244
248
 
245
249
  def __str__(self) -> str: # pragma: no cover - simple representation
246
250
  return f"{self.user} -> {self.mac_address}"
@@ -265,7 +269,7 @@ def _cleanup_public_wifi_on_delete(sender, instance, **kwargs):
265
269
  class User(Entity, AbstractUser):
266
270
  SYSTEM_USERNAME = "arthexis"
267
271
  ADMIN_USERNAME = "admin"
268
- PROFILE_RESTRICTED_USERNAMES = frozenset({SYSTEM_USERNAME, ADMIN_USERNAME})
272
+ PROFILE_RESTRICTED_USERNAMES = frozenset()
269
273
 
270
274
  objects = EntityUserManager()
271
275
  all_objects = DjangoUserManager()
@@ -844,6 +848,9 @@ class Reference(Entity):
844
848
  include_in_footer = models.BooleanField(
845
849
  default=False, verbose_name="Include in Footer"
846
850
  )
851
+ show_in_header = models.BooleanField(
852
+ default=False, verbose_name="Show in Header"
853
+ )
847
854
  FOOTER_PUBLIC = "public"
848
855
  FOOTER_PRIVATE = "private"
849
856
  FOOTER_STAFF = "staff"
@@ -1046,6 +1053,195 @@ class RFID(Entity):
1046
1053
  db_table = "core_rfid"
1047
1054
 
1048
1055
 
1056
+ class EnergyTariffManager(EntityManager):
1057
+ def get_by_natural_key(
1058
+ self,
1059
+ year: int,
1060
+ season: str,
1061
+ zone: str,
1062
+ contract_type: str,
1063
+ period: str,
1064
+ unit: str,
1065
+ start_time,
1066
+ end_time,
1067
+ ):
1068
+ if isinstance(start_time, str):
1069
+ start_time = datetime_time.fromisoformat(start_time)
1070
+ if isinstance(end_time, str):
1071
+ end_time = datetime_time.fromisoformat(end_time)
1072
+ return self.get(
1073
+ year=year,
1074
+ season=season,
1075
+ zone=zone,
1076
+ contract_type=contract_type,
1077
+ period=period,
1078
+ unit=unit,
1079
+ start_time=start_time,
1080
+ end_time=end_time,
1081
+ )
1082
+
1083
+
1084
+ class EnergyTariff(Entity):
1085
+ class Zone(models.TextChoices):
1086
+ ONE = "1", _("Zone 1")
1087
+ ONE_A = "1A", _("Zone 1A")
1088
+ ONE_B = "1B", _("Zone 1B")
1089
+ ONE_C = "1C", _("Zone 1C")
1090
+ ONE_D = "1D", _("Zone 1D")
1091
+ ONE_E = "1E", _("Zone 1E")
1092
+ ONE_F = "1F", _("Zone 1F")
1093
+
1094
+ class Season(models.TextChoices):
1095
+ ANNUAL = "annual", _("All year")
1096
+ SUMMER = "summer", _("Summer season")
1097
+ NON_SUMMER = "non_summer", _("Non-summer season")
1098
+
1099
+ class Period(models.TextChoices):
1100
+ FLAT = "flat", _("Flat rate")
1101
+ BASIC = "basic", _("Basic block")
1102
+ INTERMEDIATE_1 = "intermediate_1", _("Intermediate block 1")
1103
+ INTERMEDIATE_2 = "intermediate_2", _("Intermediate block 2")
1104
+ EXCESS = "excess", _("Excess consumption")
1105
+ BASE = "base", _("Base")
1106
+ INTERMEDIATE = "intermediate", _("Intermediate")
1107
+ PEAK = "peak", _("Peak")
1108
+ CRITICAL_PEAK = "critical_peak", _("Critical peak")
1109
+ DEMAND = "demand", _("Demand charge")
1110
+ CAPACITY = "capacity", _("Capacity charge")
1111
+ DISTRIBUTION = "distribution", _("Distribution charge")
1112
+ FIXED = "fixed", _("Fixed charge")
1113
+
1114
+ class ContractType(models.TextChoices):
1115
+ DOMESTIC = "domestic", _("Domestic service (Tarifa 1)")
1116
+ DAC = "dac", _("High consumption domestic (DAC)")
1117
+ PDBT = "pdbt", _("General service low demand (PDBT)")
1118
+ GDBT = "gdbt", _("General service high demand (GDBT)")
1119
+ GDMTO = "gdmto", _("General distribution medium tension (GDMTO)")
1120
+ GDMTH = "gdmth", _("General distribution medium tension hourly (GDMTH)")
1121
+
1122
+ class Unit(models.TextChoices):
1123
+ KWH = "kwh", _("Kilowatt-hour")
1124
+ KW = "kw", _("Kilowatt")
1125
+ MONTH = "month", _("Monthly charge")
1126
+
1127
+ year = models.PositiveIntegerField(
1128
+ validators=[MinValueValidator(2000)],
1129
+ help_text=_("Calendar year when the tariff applies."),
1130
+ )
1131
+ season = models.CharField(
1132
+ max_length=16,
1133
+ choices=Season.choices,
1134
+ default=Season.ANNUAL,
1135
+ help_text=_("Season or applicability window defined by CFE."),
1136
+ )
1137
+ zone = models.CharField(
1138
+ max_length=3,
1139
+ choices=Zone.choices,
1140
+ help_text=_("CFE climate zone associated with the tariff."),
1141
+ )
1142
+ contract_type = models.CharField(
1143
+ max_length=16,
1144
+ choices=ContractType.choices,
1145
+ help_text=_("Type of service contract regulated by CFE."),
1146
+ )
1147
+ period = models.CharField(
1148
+ max_length=32,
1149
+ choices=Period.choices,
1150
+ help_text=_("Tariff block, demand component, or time-of-use period."),
1151
+ )
1152
+ unit = models.CharField(
1153
+ max_length=16,
1154
+ choices=Unit.choices,
1155
+ default=Unit.KWH,
1156
+ help_text=_("Measurement unit for the tariff charge."),
1157
+ )
1158
+ start_time = models.TimeField(
1159
+ help_text=_("Start time for the tariff's applicability window."),
1160
+ )
1161
+ end_time = models.TimeField(
1162
+ help_text=_("End time for the tariff's applicability window."),
1163
+ )
1164
+ price_mxn = models.DecimalField(
1165
+ max_digits=10,
1166
+ decimal_places=4,
1167
+ help_text=_("Customer price per unit in MXN."),
1168
+ )
1169
+ cost_mxn = models.DecimalField(
1170
+ max_digits=10,
1171
+ decimal_places=4,
1172
+ help_text=_("Provider cost per unit in MXN."),
1173
+ )
1174
+ notes = models.TextField(
1175
+ blank=True,
1176
+ default="",
1177
+ help_text=_("Context or special billing conditions published by CFE."),
1178
+ )
1179
+
1180
+ objects = EnergyTariffManager()
1181
+
1182
+ class Meta:
1183
+ verbose_name = _("Energy Tariff")
1184
+ verbose_name_plural = _("Energy Tariffs")
1185
+ ordering = (
1186
+ "-year",
1187
+ "season",
1188
+ "zone",
1189
+ "contract_type",
1190
+ "period",
1191
+ "start_time",
1192
+ )
1193
+ constraints = [
1194
+ models.UniqueConstraint(
1195
+ fields=[
1196
+ "year",
1197
+ "season",
1198
+ "zone",
1199
+ "contract_type",
1200
+ "period",
1201
+ "unit",
1202
+ "start_time",
1203
+ "end_time",
1204
+ ],
1205
+ name="uniq_energy_tariff_schedule",
1206
+ )
1207
+ ]
1208
+ indexes = [
1209
+ models.Index(
1210
+ fields=["year", "season", "zone", "contract_type"],
1211
+ name="energy_tariff_scope_idx",
1212
+ )
1213
+ ]
1214
+
1215
+ def clean(self):
1216
+ super().clean()
1217
+ if self.start_time >= self.end_time:
1218
+ raise ValidationError(
1219
+ {"end_time": _("End time must be after the start time.")}
1220
+ )
1221
+
1222
+ def __str__(self): # pragma: no cover - simple representation
1223
+ return _("%(contract)s %(zone)s %(season)s %(year)s (%(period)s)") % {
1224
+ "contract": self.get_contract_type_display(),
1225
+ "zone": self.zone,
1226
+ "season": self.get_season_display(),
1227
+ "year": self.year,
1228
+ "period": self.get_period_display(),
1229
+ }
1230
+
1231
+ def natural_key(self): # pragma: no cover - simple representation
1232
+ return (
1233
+ self.year,
1234
+ self.season,
1235
+ self.zone,
1236
+ self.contract_type,
1237
+ self.period,
1238
+ self.unit,
1239
+ self.start_time.isoformat(),
1240
+ self.end_time.isoformat(),
1241
+ )
1242
+
1243
+ natural_key.dependencies = [] # type: ignore[attr-defined]
1244
+
1049
1245
  class EnergyAccount(Entity):
1050
1246
  """Track kW energy credits for a user."""
1051
1247
 
@@ -2162,6 +2358,7 @@ class Todo(Entity):
2162
2358
  )
2163
2359
  request_details = models.TextField(blank=True, default="")
2164
2360
  done_on = models.DateTimeField(null=True, blank=True)
2361
+ on_done_condition = ConditionTextField(blank=True, default="")
2165
2362
 
2166
2363
  objects = TodoManager()
2167
2364
 
@@ -2193,3 +2390,11 @@ class Todo(Entity):
2193
2390
  return (self.request,)
2194
2391
 
2195
2392
  natural_key.dependencies = []
2393
+
2394
+ def check_on_done_condition(self) -> ConditionCheckResult:
2395
+ """Evaluate the ``on_done_condition`` field for this TODO."""
2396
+
2397
+ field = self._meta.get_field("on_done_condition")
2398
+ if isinstance(field, ConditionTextField):
2399
+ return field.evaluate(self)
2400
+ return ConditionCheckResult(True, "")
@@ -0,0 +1,97 @@
1
+ """Utility helpers for working with Reference objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, TYPE_CHECKING
6
+
7
+ from django.contrib.sites.models import Site
8
+
9
+ if TYPE_CHECKING: # pragma: no cover - imported only for type checking
10
+ from django.http import HttpRequest
11
+ from nodes.models import Node
12
+ from .models import Reference
13
+
14
+
15
+ def filter_visible_references(
16
+ refs: Iterable["Reference"],
17
+ *,
18
+ request: "HttpRequest | None" = None,
19
+ site: Site | None = None,
20
+ node: "Node | None" = None,
21
+ respect_footer_visibility: bool = True,
22
+ ) -> list["Reference"]:
23
+ """Return references visible for the current context."""
24
+
25
+ if site is None and request is not None:
26
+ try:
27
+ host = request.get_host().split(":")[0]
28
+ except Exception:
29
+ host = ""
30
+ if host:
31
+ site = Site.objects.filter(domain__iexact=host).first()
32
+
33
+ site_id = site.pk if site else None
34
+
35
+ if node is None:
36
+ try:
37
+ from nodes.models import Node # imported lazily to avoid circular import
38
+
39
+ node = Node.get_local()
40
+ except Exception:
41
+ node = None
42
+
43
+ node_role_id = getattr(node, "role_id", None)
44
+ node_feature_ids: set[int] = set()
45
+ if node is not None:
46
+ features_manager = getattr(node, "features", None)
47
+ if features_manager is not None:
48
+ try:
49
+ node_feature_ids = set(
50
+ features_manager.values_list("pk", flat=True)
51
+ )
52
+ except Exception:
53
+ node_feature_ids = set()
54
+
55
+ visible_refs: list["Reference"] = []
56
+ for ref in refs:
57
+ required_roles = {role.pk for role in ref.roles.all()}
58
+ required_features = {feature.pk for feature in ref.features.all()}
59
+ required_sites = {current_site.pk for current_site in ref.sites.all()}
60
+
61
+ if required_roles or required_features or required_sites:
62
+ allowed = False
63
+ if required_roles and node_role_id and node_role_id in required_roles:
64
+ allowed = True
65
+ elif (
66
+ required_features
67
+ and node_feature_ids
68
+ and node_feature_ids.intersection(required_features)
69
+ ):
70
+ allowed = True
71
+ elif required_sites and site_id and site_id in required_sites:
72
+ allowed = True
73
+
74
+ if not allowed:
75
+ continue
76
+
77
+ if respect_footer_visibility:
78
+ if ref.footer_visibility == ref.FOOTER_PUBLIC:
79
+ visible_refs.append(ref)
80
+ elif (
81
+ ref.footer_visibility == ref.FOOTER_PRIVATE
82
+ and request
83
+ and request.user.is_authenticated
84
+ ):
85
+ visible_refs.append(ref)
86
+ elif (
87
+ ref.footer_visibility == ref.FOOTER_STAFF
88
+ and request
89
+ and request.user.is_authenticated
90
+ and request.user.is_staff
91
+ ):
92
+ visible_refs.append(ref)
93
+ else:
94
+ visible_refs.append(ref)
95
+
96
+ return visible_refs
97
+
core/sigil_builder.py CHANGED
@@ -88,16 +88,27 @@ def _sigil_builder_view(request):
88
88
 
89
89
  sigils_text = ""
90
90
  resolved_text = ""
91
+ show_sigils_input = True
92
+ show_result = False
91
93
  if request.method == "POST":
92
94
  sigils_text = request.POST.get("sigils_text", "")
95
+ source_text = sigils_text
93
96
  upload = request.FILES.get("sigils_file")
94
97
  if upload:
95
- sigils_text = upload.read().decode("utf-8", errors="ignore")
98
+ source_text = upload.read().decode("utf-8", errors="ignore")
99
+ show_sigils_input = False
96
100
  else:
97
101
  single = request.POST.get("sigil", "")
98
102
  if single:
99
- sigils_text = f"[{single}]" if not single.startswith("[") else single
100
- resolved_text = resolve_sigils_in_text(sigils_text) if sigils_text else ""
103
+ source_text = (
104
+ f"[{single}]" if not single.startswith("[") else single
105
+ )
106
+ sigils_text = source_text
107
+ if source_text:
108
+ resolved_text = resolve_sigils_in_text(source_text)
109
+ show_result = True
110
+ if upload:
111
+ sigils_text = ""
101
112
 
102
113
  context = admin.site.each_context(request)
103
114
  context.update(
@@ -108,6 +119,8 @@ def _sigil_builder_view(request):
108
119
  "auto_fields": auto_fields,
109
120
  "sigils_text": sigils_text,
110
121
  "resolved_text": resolved_text,
122
+ "show_sigils_input": show_sigils_input,
123
+ "show_result": show_result,
111
124
  }
112
125
  )
113
126
  return TemplateResponse(request, "admin/sigil_builder.html", context)