arthexis 0.1.21__py3-none-any.whl → 0.1.22__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.21.dist-info → arthexis-0.1.22.dist-info}/METADATA +8 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/RECORD +31 -31
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +139 -19
- core/environment.py +2 -239
- core/models.py +419 -2
- core/system.py +76 -0
- core/tests.py +152 -8
- core/views.py +35 -1
- nodes/admin.py +148 -38
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +214 -1
- nodes/views.py +1 -0
- ocpp/admin.py +20 -1
- ocpp/consumers.py +1 -0
- ocpp/models.py +23 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +227 -2
- ocpp/views.py +281 -3
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -22
- pages/urls.py +5 -0
- pages/views.py +264 -11
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
core/models.py
CHANGED
|
@@ -15,7 +15,13 @@ 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
17
|
from django.views.decorators.debug import sensitive_variables
|
|
18
|
-
from datetime import
|
|
18
|
+
from datetime import (
|
|
19
|
+
time as datetime_time,
|
|
20
|
+
timedelta,
|
|
21
|
+
datetime as datetime_datetime,
|
|
22
|
+
date as datetime_date,
|
|
23
|
+
timezone as datetime_timezone,
|
|
24
|
+
)
|
|
19
25
|
import logging
|
|
20
26
|
import json
|
|
21
27
|
from django.contrib.contenttypes.models import ContentType
|
|
@@ -33,7 +39,8 @@ import uuid
|
|
|
33
39
|
from pathlib import Path
|
|
34
40
|
from django.core import serializers
|
|
35
41
|
from django.core.management.color import no_style
|
|
36
|
-
from urllib.parse import quote_plus, urlparse
|
|
42
|
+
from urllib.parse import quote, quote_plus, urlparse
|
|
43
|
+
from zoneinfo import ZoneInfo
|
|
37
44
|
from utils import revision as revision_utils
|
|
38
45
|
from typing import Any, Type
|
|
39
46
|
from defusedxml import xmlrpc as defused_xmlrpc
|
|
@@ -522,6 +529,10 @@ class User(Entity, AbstractUser):
|
|
|
522
529
|
def social_profile(self):
|
|
523
530
|
return self._direct_profile("SocialProfile")
|
|
524
531
|
|
|
532
|
+
@property
|
|
533
|
+
def google_calendar_profile(self):
|
|
534
|
+
return self._direct_profile("GoogleCalendarProfile")
|
|
535
|
+
|
|
525
536
|
|
|
526
537
|
class UserPhoneNumber(Entity):
|
|
527
538
|
"""Store phone numbers associated with a user."""
|
|
@@ -839,6 +850,184 @@ class OpenPayProfile(Profile):
|
|
|
839
850
|
]
|
|
840
851
|
|
|
841
852
|
|
|
853
|
+
class GoogleCalendarProfile(Profile):
|
|
854
|
+
"""Store Google Calendar configuration for a user or security group."""
|
|
855
|
+
|
|
856
|
+
profile_fields = ("calendar_id", "api_key", "display_name", "timezone")
|
|
857
|
+
|
|
858
|
+
calendar_id = SigilShortAutoField(max_length=255)
|
|
859
|
+
api_key = SigilShortAutoField(max_length=255)
|
|
860
|
+
display_name = models.CharField(max_length=255, blank=True)
|
|
861
|
+
max_events = models.PositiveIntegerField(
|
|
862
|
+
default=5,
|
|
863
|
+
validators=[MinValueValidator(1), MaxValueValidator(20)],
|
|
864
|
+
help_text=_("Number of upcoming events to display (1-20)."),
|
|
865
|
+
)
|
|
866
|
+
timezone = SigilShortAutoField(max_length=100, blank=True)
|
|
867
|
+
|
|
868
|
+
GOOGLE_EVENTS_URL = (
|
|
869
|
+
"https://www.googleapis.com/calendar/v3/calendars/{calendar}/events"
|
|
870
|
+
)
|
|
871
|
+
GOOGLE_EMBED_URL = "https://calendar.google.com/calendar/embed?src={calendar}&ctz={tz}"
|
|
872
|
+
|
|
873
|
+
class Meta:
|
|
874
|
+
verbose_name = _("Google Calendar")
|
|
875
|
+
verbose_name_plural = _("Google Calendars")
|
|
876
|
+
constraints = [
|
|
877
|
+
models.CheckConstraint(
|
|
878
|
+
check=(
|
|
879
|
+
(Q(user__isnull=False) & Q(group__isnull=True))
|
|
880
|
+
| (Q(user__isnull=True) & Q(group__isnull=False))
|
|
881
|
+
),
|
|
882
|
+
name="googlecalendarprofile_requires_owner",
|
|
883
|
+
)
|
|
884
|
+
]
|
|
885
|
+
|
|
886
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
887
|
+
label = self.get_display_name()
|
|
888
|
+
return label or self.resolved_calendar_id()
|
|
889
|
+
|
|
890
|
+
def resolved_calendar_id(self) -> str:
|
|
891
|
+
value = self.resolve_sigils("calendar_id")
|
|
892
|
+
return value or self.calendar_id or ""
|
|
893
|
+
|
|
894
|
+
def resolved_api_key(self) -> str:
|
|
895
|
+
value = self.resolve_sigils("api_key")
|
|
896
|
+
return value or self.api_key or ""
|
|
897
|
+
|
|
898
|
+
def resolved_timezone(self) -> str:
|
|
899
|
+
value = self.resolve_sigils("timezone")
|
|
900
|
+
return value or self.timezone or ""
|
|
901
|
+
|
|
902
|
+
def get_timezone(self) -> ZoneInfo:
|
|
903
|
+
tz_name = self.resolved_timezone() or settings.TIME_ZONE
|
|
904
|
+
try:
|
|
905
|
+
return ZoneInfo(tz_name)
|
|
906
|
+
except Exception:
|
|
907
|
+
return ZoneInfo("UTC")
|
|
908
|
+
|
|
909
|
+
def get_display_name(self) -> str:
|
|
910
|
+
value = self.resolve_sigils("display_name")
|
|
911
|
+
if value:
|
|
912
|
+
return value
|
|
913
|
+
if self.display_name:
|
|
914
|
+
return self.display_name
|
|
915
|
+
return ""
|
|
916
|
+
|
|
917
|
+
def build_events_url(self) -> str:
|
|
918
|
+
calendar = self.resolved_calendar_id().strip()
|
|
919
|
+
if not calendar:
|
|
920
|
+
return ""
|
|
921
|
+
encoded = quote(calendar, safe="@")
|
|
922
|
+
return self.GOOGLE_EVENTS_URL.format(calendar=encoded)
|
|
923
|
+
|
|
924
|
+
def build_calendar_url(self) -> str:
|
|
925
|
+
calendar = self.resolved_calendar_id().strip()
|
|
926
|
+
if not calendar:
|
|
927
|
+
return ""
|
|
928
|
+
tz = self.get_timezone().key
|
|
929
|
+
encoded_calendar = quote_plus(calendar)
|
|
930
|
+
encoded_tz = quote_plus(tz)
|
|
931
|
+
return self.GOOGLE_EMBED_URL.format(calendar=encoded_calendar, tz=encoded_tz)
|
|
932
|
+
|
|
933
|
+
def _parse_event_point(self, data: dict) -> tuple[datetime_datetime | None, bool]:
|
|
934
|
+
if not isinstance(data, dict):
|
|
935
|
+
return None, False
|
|
936
|
+
|
|
937
|
+
tz_name = data.get("timeZone")
|
|
938
|
+
default_tz = self.get_timezone()
|
|
939
|
+
tzinfo = default_tz
|
|
940
|
+
if tz_name:
|
|
941
|
+
try:
|
|
942
|
+
tzinfo = ZoneInfo(tz_name)
|
|
943
|
+
except Exception:
|
|
944
|
+
tzinfo = default_tz
|
|
945
|
+
|
|
946
|
+
timestamp = data.get("dateTime")
|
|
947
|
+
if timestamp:
|
|
948
|
+
dt = parse_datetime(timestamp)
|
|
949
|
+
if dt is None:
|
|
950
|
+
try:
|
|
951
|
+
dt = datetime_datetime.fromisoformat(
|
|
952
|
+
timestamp.replace("Z", "+00:00")
|
|
953
|
+
)
|
|
954
|
+
except ValueError:
|
|
955
|
+
dt = None
|
|
956
|
+
if dt is not None and dt.tzinfo is None:
|
|
957
|
+
dt = dt.replace(tzinfo=tzinfo)
|
|
958
|
+
return dt, False
|
|
959
|
+
|
|
960
|
+
date_value = data.get("date")
|
|
961
|
+
if date_value:
|
|
962
|
+
try:
|
|
963
|
+
day = datetime_date.fromisoformat(date_value)
|
|
964
|
+
except ValueError:
|
|
965
|
+
return None, True
|
|
966
|
+
dt = datetime_datetime.combine(day, datetime_time.min, tzinfo=tzinfo)
|
|
967
|
+
return dt, True
|
|
968
|
+
|
|
969
|
+
return None, False
|
|
970
|
+
|
|
971
|
+
def fetch_events(self, *, max_results: int | None = None) -> list[dict[str, object]]:
|
|
972
|
+
calendar_id = self.resolved_calendar_id().strip()
|
|
973
|
+
api_key = self.resolved_api_key().strip()
|
|
974
|
+
if not calendar_id or not api_key:
|
|
975
|
+
return []
|
|
976
|
+
|
|
977
|
+
url = self.build_events_url()
|
|
978
|
+
if not url:
|
|
979
|
+
return []
|
|
980
|
+
|
|
981
|
+
now = timezone.now().astimezone(datetime_timezone.utc).replace(microsecond=0)
|
|
982
|
+
params = {
|
|
983
|
+
"key": api_key,
|
|
984
|
+
"singleEvents": "true",
|
|
985
|
+
"orderBy": "startTime",
|
|
986
|
+
"timeMin": now.isoformat().replace("+00:00", "Z"),
|
|
987
|
+
"maxResults": max_results or self.max_events or 5,
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
response = requests.get(url, params=params, timeout=10)
|
|
992
|
+
response.raise_for_status()
|
|
993
|
+
payload = response.json()
|
|
994
|
+
except (requests.RequestException, ValueError):
|
|
995
|
+
logger.warning(
|
|
996
|
+
"Failed to fetch Google Calendar events for profile %s", self.pk,
|
|
997
|
+
exc_info=True,
|
|
998
|
+
)
|
|
999
|
+
return []
|
|
1000
|
+
|
|
1001
|
+
items = payload.get("items")
|
|
1002
|
+
if not isinstance(items, list):
|
|
1003
|
+
return []
|
|
1004
|
+
|
|
1005
|
+
events: list[dict[str, object]] = []
|
|
1006
|
+
for item in items:
|
|
1007
|
+
if not isinstance(item, dict):
|
|
1008
|
+
continue
|
|
1009
|
+
start, all_day = self._parse_event_point(item.get("start") or {})
|
|
1010
|
+
end, _ = self._parse_event_point(item.get("end") or {})
|
|
1011
|
+
summary = item.get("summary") or ""
|
|
1012
|
+
link = item.get("htmlLink") or ""
|
|
1013
|
+
location = item.get("location") or ""
|
|
1014
|
+
if start is None:
|
|
1015
|
+
continue
|
|
1016
|
+
events.append(
|
|
1017
|
+
{
|
|
1018
|
+
"summary": summary,
|
|
1019
|
+
"start": start,
|
|
1020
|
+
"end": end,
|
|
1021
|
+
"all_day": all_day,
|
|
1022
|
+
"html_link": link,
|
|
1023
|
+
"location": location,
|
|
1024
|
+
}
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
events.sort(key=lambda event: event.get("start") or timezone.now())
|
|
1028
|
+
return events
|
|
1029
|
+
|
|
1030
|
+
|
|
842
1031
|
class EmailInbox(Profile):
|
|
843
1032
|
"""Credentials and configuration for connecting to an email mailbox."""
|
|
844
1033
|
|
|
@@ -3725,7 +3914,38 @@ class Todo(Entity):
|
|
|
3725
3914
|
generated_for_version = models.CharField(max_length=20, blank=True, default="")
|
|
3726
3915
|
generated_for_revision = models.CharField(max_length=40, blank=True, default="")
|
|
3727
3916
|
done_on = models.DateTimeField(null=True, blank=True)
|
|
3917
|
+
done_node = models.ForeignKey(
|
|
3918
|
+
"nodes.Node",
|
|
3919
|
+
null=True,
|
|
3920
|
+
blank=True,
|
|
3921
|
+
on_delete=models.SET_NULL,
|
|
3922
|
+
related_name="completed_todos",
|
|
3923
|
+
help_text="Node where this TODO was completed.",
|
|
3924
|
+
)
|
|
3925
|
+
done_version = models.CharField(max_length=20, blank=True, default="")
|
|
3926
|
+
done_revision = models.CharField(max_length=40, blank=True, default="")
|
|
3927
|
+
done_username = models.CharField(max_length=150, blank=True, default="")
|
|
3728
3928
|
on_done_condition = ConditionTextField(blank=True, default="")
|
|
3929
|
+
origin_node = models.ForeignKey(
|
|
3930
|
+
"nodes.Node",
|
|
3931
|
+
null=True,
|
|
3932
|
+
blank=True,
|
|
3933
|
+
on_delete=models.SET_NULL,
|
|
3934
|
+
related_name="originated_todos",
|
|
3935
|
+
help_text="Node where this TODO was generated.",
|
|
3936
|
+
)
|
|
3937
|
+
original_user = models.ForeignKey(
|
|
3938
|
+
settings.AUTH_USER_MODEL,
|
|
3939
|
+
null=True,
|
|
3940
|
+
blank=True,
|
|
3941
|
+
on_delete=models.SET_NULL,
|
|
3942
|
+
related_name="originated_todos",
|
|
3943
|
+
help_text="User responsible for creating this TODO.",
|
|
3944
|
+
)
|
|
3945
|
+
original_user_is_authenticated = models.BooleanField(
|
|
3946
|
+
default=False,
|
|
3947
|
+
help_text="Whether the originating user was authenticated during creation.",
|
|
3948
|
+
)
|
|
3729
3949
|
|
|
3730
3950
|
objects = TodoManager()
|
|
3731
3951
|
|
|
@@ -3766,6 +3986,203 @@ class Todo(Entity):
|
|
|
3766
3986
|
return field.evaluate(self)
|
|
3767
3987
|
return ConditionCheckResult(True, "")
|
|
3768
3988
|
|
|
3989
|
+
def save(self, *args, **kwargs):
|
|
3990
|
+
created = self.pk is None
|
|
3991
|
+
tracked_fields = {
|
|
3992
|
+
"done_on",
|
|
3993
|
+
"done_node",
|
|
3994
|
+
"done_node_id",
|
|
3995
|
+
"done_revision",
|
|
3996
|
+
"done_username",
|
|
3997
|
+
"done_version",
|
|
3998
|
+
"is_deleted",
|
|
3999
|
+
}
|
|
4000
|
+
update_fields = kwargs.get("update_fields")
|
|
4001
|
+
monitor_changes = not created and (
|
|
4002
|
+
update_fields is None or tracked_fields.intersection(update_fields)
|
|
4003
|
+
)
|
|
4004
|
+
previous_state = None
|
|
4005
|
+
if monitor_changes:
|
|
4006
|
+
previous_state = (
|
|
4007
|
+
type(self)
|
|
4008
|
+
.all_objects.filter(pk=self.pk)
|
|
4009
|
+
.values(
|
|
4010
|
+
"done_on",
|
|
4011
|
+
"done_node_id",
|
|
4012
|
+
"done_revision",
|
|
4013
|
+
"done_username",
|
|
4014
|
+
"done_version",
|
|
4015
|
+
"is_deleted",
|
|
4016
|
+
)
|
|
4017
|
+
.first()
|
|
4018
|
+
)
|
|
4019
|
+
super().save(*args, **kwargs)
|
|
4020
|
+
|
|
4021
|
+
if created:
|
|
4022
|
+
return
|
|
4023
|
+
|
|
4024
|
+
previous_done_on = previous_state["done_on"] if previous_state else None
|
|
4025
|
+
previous_is_deleted = previous_state["is_deleted"] if previous_state else False
|
|
4026
|
+
previous_done_node = (
|
|
4027
|
+
previous_state["done_node_id"] if previous_state else None
|
|
4028
|
+
)
|
|
4029
|
+
previous_done_revision = (
|
|
4030
|
+
previous_state["done_revision"] if previous_state else ""
|
|
4031
|
+
)
|
|
4032
|
+
previous_done_username = (
|
|
4033
|
+
previous_state["done_username"] if previous_state else ""
|
|
4034
|
+
)
|
|
4035
|
+
previous_done_version = (
|
|
4036
|
+
previous_state["done_version"] if previous_state else ""
|
|
4037
|
+
)
|
|
4038
|
+
if (
|
|
4039
|
+
previous_done_on == self.done_on
|
|
4040
|
+
and previous_is_deleted == self.is_deleted
|
|
4041
|
+
and previous_done_node == getattr(self, "done_node_id", None)
|
|
4042
|
+
and previous_done_revision == self.done_revision
|
|
4043
|
+
and previous_done_username == self.done_username
|
|
4044
|
+
and previous_done_version == self.done_version
|
|
4045
|
+
):
|
|
4046
|
+
return
|
|
4047
|
+
|
|
4048
|
+
self._update_fixture_state()
|
|
4049
|
+
|
|
4050
|
+
def populate_done_metadata(self, user=None) -> None:
|
|
4051
|
+
"""Populate metadata fields for a completed TODO."""
|
|
4052
|
+
|
|
4053
|
+
node = None
|
|
4054
|
+
try: # pragma: no cover - defensive import guard
|
|
4055
|
+
from nodes.models import Node # type: ignore
|
|
4056
|
+
except Exception: # pragma: no cover - when app not ready
|
|
4057
|
+
Node = None
|
|
4058
|
+
|
|
4059
|
+
if Node is not None:
|
|
4060
|
+
try:
|
|
4061
|
+
node = Node.get_local()
|
|
4062
|
+
except Exception: # pragma: no cover - fallback on errors
|
|
4063
|
+
node = None
|
|
4064
|
+
self.done_node = node if node else None
|
|
4065
|
+
|
|
4066
|
+
version_value = ""
|
|
4067
|
+
revision_value = ""
|
|
4068
|
+
if node is not None:
|
|
4069
|
+
version_value = (node.installed_version or "").strip()
|
|
4070
|
+
revision_value = (node.installed_revision or "").strip()
|
|
4071
|
+
|
|
4072
|
+
if not version_value:
|
|
4073
|
+
version_path = Path(settings.BASE_DIR) / "VERSION"
|
|
4074
|
+
try:
|
|
4075
|
+
version_value = version_path.read_text(encoding="utf-8").strip()
|
|
4076
|
+
except OSError:
|
|
4077
|
+
version_value = ""
|
|
4078
|
+
|
|
4079
|
+
if not revision_value:
|
|
4080
|
+
try:
|
|
4081
|
+
revision_value = revision_utils.get_revision() or ""
|
|
4082
|
+
except Exception: # pragma: no cover - defensive fallback
|
|
4083
|
+
revision_value = ""
|
|
4084
|
+
|
|
4085
|
+
username_value = ""
|
|
4086
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
4087
|
+
try:
|
|
4088
|
+
username_value = user.get_username() or ""
|
|
4089
|
+
except Exception: # pragma: no cover - fallback to attribute
|
|
4090
|
+
username_value = getattr(user, "username", "") or ""
|
|
4091
|
+
|
|
4092
|
+
self.done_version = version_value
|
|
4093
|
+
self.done_revision = revision_value
|
|
4094
|
+
self.done_username = username_value
|
|
4095
|
+
|
|
4096
|
+
def _update_fixture_state(self) -> None:
|
|
4097
|
+
if not self.is_seed_data:
|
|
4098
|
+
return
|
|
4099
|
+
|
|
4100
|
+
request_text = (self.request or "").strip()
|
|
4101
|
+
if not request_text:
|
|
4102
|
+
return
|
|
4103
|
+
|
|
4104
|
+
slug = self._fixture_slug(request_text)
|
|
4105
|
+
if not slug:
|
|
4106
|
+
return
|
|
4107
|
+
|
|
4108
|
+
base_dir = Path(settings.BASE_DIR)
|
|
4109
|
+
fixture_path = base_dir / "core" / "fixtures" / f"todo__{slug}.json"
|
|
4110
|
+
if not fixture_path.exists():
|
|
4111
|
+
return
|
|
4112
|
+
|
|
4113
|
+
try:
|
|
4114
|
+
with fixture_path.open("r", encoding="utf-8") as handle:
|
|
4115
|
+
data = json.load(handle)
|
|
4116
|
+
except Exception:
|
|
4117
|
+
logger.exception("Failed to read TODO fixture %s", fixture_path)
|
|
4118
|
+
return
|
|
4119
|
+
|
|
4120
|
+
if not isinstance(data, list):
|
|
4121
|
+
return
|
|
4122
|
+
|
|
4123
|
+
updated = False
|
|
4124
|
+
normalized_request = request_text.lower()
|
|
4125
|
+
for item in data:
|
|
4126
|
+
if not isinstance(item, dict):
|
|
4127
|
+
continue
|
|
4128
|
+
fields = item.get("fields")
|
|
4129
|
+
if not isinstance(fields, dict):
|
|
4130
|
+
continue
|
|
4131
|
+
candidate = (fields.get("request") or "").strip().lower()
|
|
4132
|
+
if candidate != normalized_request:
|
|
4133
|
+
continue
|
|
4134
|
+
if self._apply_fixture_fields(fields):
|
|
4135
|
+
updated = True
|
|
4136
|
+
|
|
4137
|
+
if not updated:
|
|
4138
|
+
return
|
|
4139
|
+
|
|
4140
|
+
content = json.dumps(data, indent=2, ensure_ascii=False)
|
|
4141
|
+
if not content.endswith("\n"):
|
|
4142
|
+
content += "\n"
|
|
4143
|
+
|
|
4144
|
+
try:
|
|
4145
|
+
fixture_path.write_text(content, encoding="utf-8")
|
|
4146
|
+
except OSError:
|
|
4147
|
+
logger.exception("Failed to write TODO fixture %s", fixture_path)
|
|
4148
|
+
|
|
4149
|
+
def _apply_fixture_fields(self, fields: dict[str, object]) -> bool:
|
|
4150
|
+
changed = False
|
|
4151
|
+
|
|
4152
|
+
def _assign(key: str, value: object) -> None:
|
|
4153
|
+
nonlocal changed
|
|
4154
|
+
if fields.get(key) != value:
|
|
4155
|
+
fields[key] = value
|
|
4156
|
+
changed = True
|
|
4157
|
+
|
|
4158
|
+
_assign("request", self.request or "")
|
|
4159
|
+
_assign("url", self.url or "")
|
|
4160
|
+
_assign("request_details", self.request_details or "")
|
|
4161
|
+
_assign("done_version", self.done_version or "")
|
|
4162
|
+
_assign("done_revision", self.done_revision or "")
|
|
4163
|
+
_assign("done_username", self.done_username or "")
|
|
4164
|
+
|
|
4165
|
+
if self.done_on:
|
|
4166
|
+
done_value = timezone.localtime(self.done_on)
|
|
4167
|
+
_assign("done_on", done_value.isoformat())
|
|
4168
|
+
else:
|
|
4169
|
+
if fields.get("done_on") is not None:
|
|
4170
|
+
fields["done_on"] = None
|
|
4171
|
+
changed = True
|
|
4172
|
+
|
|
4173
|
+
if self.is_deleted:
|
|
4174
|
+
_assign("is_deleted", True)
|
|
4175
|
+
elif fields.get("is_deleted"):
|
|
4176
|
+
fields["is_deleted"] = False
|
|
4177
|
+
changed = True
|
|
4178
|
+
|
|
4179
|
+
return changed
|
|
4180
|
+
|
|
4181
|
+
@staticmethod
|
|
4182
|
+
def _fixture_slug(value: str) -> str:
|
|
4183
|
+
slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
|
|
4184
|
+
return slug
|
|
4185
|
+
|
|
3769
4186
|
|
|
3770
4187
|
class TOTPDeviceSettings(models.Model):
|
|
3771
4188
|
"""Per-device configuration options for authenticator enrollments."""
|
core/system.py
CHANGED
|
@@ -101,6 +101,72 @@ def _open_changelog_entries() -> list[dict[str, str]]:
|
|
|
101
101
|
return entries
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
def _latest_release_changelog() -> dict[str, object]:
|
|
105
|
+
"""Return the most recent tagged release entries for display."""
|
|
106
|
+
|
|
107
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
108
|
+
try:
|
|
109
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
110
|
+
except (FileNotFoundError, OSError):
|
|
111
|
+
return {"title": "", "entries": []}
|
|
112
|
+
|
|
113
|
+
lines = text.splitlines()
|
|
114
|
+
state = "before"
|
|
115
|
+
release_title = ""
|
|
116
|
+
entries: list[dict[str, str]] = []
|
|
117
|
+
|
|
118
|
+
for raw_line in lines:
|
|
119
|
+
stripped = raw_line.strip()
|
|
120
|
+
|
|
121
|
+
if state == "before":
|
|
122
|
+
if stripped == "Unreleased":
|
|
123
|
+
state = "unreleased-heading"
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if state == "unreleased-heading":
|
|
127
|
+
if set(stripped) == {"-"}:
|
|
128
|
+
state = "unreleased-body"
|
|
129
|
+
else:
|
|
130
|
+
state = "unreleased-body"
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
if state == "unreleased-body":
|
|
134
|
+
if not stripped:
|
|
135
|
+
state = "after-unreleased"
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if state == "after-unreleased":
|
|
139
|
+
if not stripped:
|
|
140
|
+
continue
|
|
141
|
+
release_title = stripped
|
|
142
|
+
state = "release-heading"
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
if state == "release-heading":
|
|
146
|
+
if set(stripped) == {"-"}:
|
|
147
|
+
state = "release-body"
|
|
148
|
+
else:
|
|
149
|
+
state = "release-body"
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if state == "release-body":
|
|
153
|
+
if not stripped:
|
|
154
|
+
if entries:
|
|
155
|
+
break
|
|
156
|
+
continue
|
|
157
|
+
if not stripped.startswith("- "):
|
|
158
|
+
break
|
|
159
|
+
trimmed = stripped[2:].strip()
|
|
160
|
+
if not trimmed:
|
|
161
|
+
continue
|
|
162
|
+
parts = trimmed.split(" ", 1)
|
|
163
|
+
sha = parts[0]
|
|
164
|
+
message = parts[1] if len(parts) > 1 else ""
|
|
165
|
+
entries.append({"sha": sha, "message": message})
|
|
166
|
+
|
|
167
|
+
return {"title": release_title, "entries": entries}
|
|
168
|
+
|
|
169
|
+
|
|
104
170
|
def _exclude_changelog_entries(shas: Iterable[str]) -> int:
|
|
105
171
|
"""Remove entries matching ``shas`` from the changelog.
|
|
106
172
|
|
|
@@ -1073,6 +1139,7 @@ def _system_changelog_report_view(request):
|
|
|
1073
1139
|
{
|
|
1074
1140
|
"title": _("Changelog Report"),
|
|
1075
1141
|
"open_changelog_entries": _open_changelog_entries(),
|
|
1142
|
+
"latest_release_changelog": _latest_release_changelog(),
|
|
1076
1143
|
}
|
|
1077
1144
|
)
|
|
1078
1145
|
return TemplateResponse(request, "admin/system_changelog_report.html", context)
|
|
@@ -1119,6 +1186,14 @@ class PendingTodoForm(forms.ModelForm):
|
|
|
1119
1186
|
for name in ["request_details", "on_done_condition"]:
|
|
1120
1187
|
self.fields[name].widget.attrs.setdefault("class", "vLargeTextField")
|
|
1121
1188
|
|
|
1189
|
+
mark_done_widget = self.fields["mark_done"].widget
|
|
1190
|
+
existing_classes = mark_done_widget.attrs.get("class", "").split()
|
|
1191
|
+
if "approve-checkbox" not in existing_classes:
|
|
1192
|
+
existing_classes.append("approve-checkbox")
|
|
1193
|
+
mark_done_widget.attrs["class"] = " ".join(
|
|
1194
|
+
class_name for class_name in existing_classes if class_name
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1122
1197
|
|
|
1123
1198
|
PendingTodoFormSet = modelformset_factory(Todo, form=PendingTodoForm, extra=0)
|
|
1124
1199
|
|
|
@@ -1144,6 +1219,7 @@ def _system_pending_todos_report_view(request):
|
|
|
1144
1219
|
has_changes = form.has_changed()
|
|
1145
1220
|
if mark_done and todo.done_on is None:
|
|
1146
1221
|
todo.done_on = timezone.now()
|
|
1222
|
+
todo.populate_done_metadata(request.user)
|
|
1147
1223
|
approved_count += 1
|
|
1148
1224
|
has_changes = True
|
|
1149
1225
|
if has_changes:
|