platzky 1.0.1__py3-none-any.whl → 1.2.0__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.
- platzky/admin/admin.py +25 -3
- platzky/admin/fake_login.py +3 -2
- platzky/blog/blog.py +126 -37
- platzky/blog/comment_form.py +10 -0
- platzky/config.py +1 -1
- platzky/db/db.py +61 -10
- platzky/db/db_loader.py +5 -2
- platzky/db/github_json_db.py +42 -4
- platzky/db/google_json_db.py +63 -7
- platzky/db/graph_ql_db.py +216 -31
- platzky/db/json_db.py +172 -38
- platzky/db/json_file_db.py +46 -6
- platzky/db/mongodb_db.py +134 -6
- platzky/engine.py +9 -7
- platzky/models.py +160 -24
- platzky/platzky.py +169 -23
- platzky/plugin/plugin.py +39 -3
- platzky/plugin/plugin_loader.py +108 -12
- platzky/seo/seo.py +51 -16
- {platzky-1.0.1.dist-info → platzky-1.2.0.dist-info}/METADATA +1 -1
- {platzky-1.0.1.dist-info → platzky-1.2.0.dist-info}/RECORD +23 -23
- {platzky-1.0.1.dist-info → platzky-1.2.0.dist-info}/WHEEL +0 -0
- {platzky-1.0.1.dist-info → platzky-1.2.0.dist-info}/licenses/LICENSE +0 -0
platzky/db/json_file_db.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
"""Local file-based JSON database implementation."""
|
|
2
|
+
|
|
1
3
|
import json
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
6
|
from pydantic import Field
|
|
4
7
|
|
|
@@ -6,25 +9,55 @@ from platzky.db.db import DBConfig
|
|
|
6
9
|
from platzky.db.json_db import Json
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
def db_config_type():
|
|
12
|
+
def db_config_type() -> type["JsonFileDbConfig"]:
|
|
13
|
+
"""Return the configuration class for JSON file database.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
JsonFileDbConfig class
|
|
17
|
+
"""
|
|
10
18
|
return JsonFileDbConfig
|
|
11
19
|
|
|
12
20
|
|
|
13
21
|
class JsonFileDbConfig(DBConfig):
|
|
22
|
+
"""Configuration for JSON file database."""
|
|
23
|
+
|
|
14
24
|
path: str = Field(alias="PATH")
|
|
15
25
|
|
|
16
26
|
|
|
17
|
-
def get_db(config):
|
|
27
|
+
def get_db(config: dict[str, Any]) -> "JsonFile":
|
|
28
|
+
"""Get a JSON file database instance from raw configuration.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Raw configuration dictionary
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Configured JSON file database instance
|
|
35
|
+
"""
|
|
18
36
|
json_file_db_config = JsonFileDbConfig.model_validate(config)
|
|
19
37
|
return JsonFile(json_file_db_config.path)
|
|
20
38
|
|
|
21
39
|
|
|
22
|
-
def db_from_config(config: JsonFileDbConfig):
|
|
40
|
+
def db_from_config(config: JsonFileDbConfig) -> "JsonFile":
|
|
41
|
+
"""Create a JSON file database instance from configuration.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: JSON file database configuration
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Configured JSON file database instance
|
|
48
|
+
"""
|
|
23
49
|
return JsonFile(config.path)
|
|
24
50
|
|
|
25
51
|
|
|
26
52
|
class JsonFile(Json):
|
|
27
|
-
|
|
53
|
+
"""JSON database stored in a local file with read/write support."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, path: str) -> None:
|
|
56
|
+
"""Initialize JSON file database from a local file path.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: Absolute or relative path to the JSON file
|
|
60
|
+
"""
|
|
28
61
|
self.data_file_path = path
|
|
29
62
|
with open(self.data_file_path) as json_file:
|
|
30
63
|
data = json.load(json_file)
|
|
@@ -32,10 +65,17 @@ class JsonFile(Json):
|
|
|
32
65
|
self.module_name = "json_file_db"
|
|
33
66
|
self.db_name = "JsonFileDb"
|
|
34
67
|
|
|
35
|
-
def __save_file(self):
|
|
68
|
+
def __save_file(self) -> None:
|
|
36
69
|
with open(self.data_file_path, "w") as json_file:
|
|
37
70
|
json.dump(self.data, json_file)
|
|
38
71
|
|
|
39
|
-
def add_comment(self, author_name, comment, post_slug):
|
|
72
|
+
def add_comment(self, author_name: str, comment: str, post_slug: str) -> None:
|
|
73
|
+
"""Add a comment to a blog post and persist to file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
author_name: Name of the comment author
|
|
77
|
+
comment: Comment text content
|
|
78
|
+
post_slug: URL-friendly identifier of the post
|
|
79
|
+
"""
|
|
40
80
|
super().add_comment(author_name, comment, post_slug)
|
|
41
81
|
self.__save_file()
|
platzky/db/mongodb_db.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""MongoDB database implementation."""
|
|
2
|
+
|
|
1
3
|
import datetime
|
|
2
4
|
from typing import Any
|
|
3
5
|
|
|
@@ -10,26 +12,57 @@ from platzky.db.db import DB, DBConfig
|
|
|
10
12
|
from platzky.models import MenuItem, Page, Post
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
def db_config_type():
|
|
15
|
+
def db_config_type() -> type["MongoDbConfig"]:
|
|
16
|
+
"""Return the configuration class for MongoDB database.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
MongoDbConfig class
|
|
20
|
+
"""
|
|
14
21
|
return MongoDbConfig
|
|
15
22
|
|
|
16
23
|
|
|
17
24
|
class MongoDbConfig(DBConfig):
|
|
25
|
+
"""Configuration for MongoDB database connection."""
|
|
26
|
+
|
|
18
27
|
connection_string: str = Field(alias="CONNECTION_STRING")
|
|
19
28
|
database_name: str = Field(alias="DATABASE_NAME")
|
|
20
29
|
|
|
21
30
|
|
|
22
|
-
def get_db(config):
|
|
31
|
+
def get_db(config: dict[str, Any]) -> "MongoDB":
|
|
32
|
+
"""Get a MongoDB database instance from raw configuration.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config: Raw configuration dictionary
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Configured MongoDB database instance
|
|
39
|
+
"""
|
|
23
40
|
mongodb_config = MongoDbConfig.model_validate(config)
|
|
24
41
|
return MongoDB(mongodb_config.connection_string, mongodb_config.database_name)
|
|
25
42
|
|
|
26
43
|
|
|
27
|
-
def db_from_config(config: MongoDbConfig):
|
|
44
|
+
def db_from_config(config: MongoDbConfig) -> "MongoDB":
|
|
45
|
+
"""Create a MongoDB database instance from configuration.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config: MongoDB database configuration
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Configured MongoDB database instance
|
|
52
|
+
"""
|
|
28
53
|
return MongoDB(config.connection_string, config.database_name)
|
|
29
54
|
|
|
30
55
|
|
|
31
56
|
class MongoDB(DB):
|
|
57
|
+
"""MongoDB database implementation with connection pooling."""
|
|
58
|
+
|
|
32
59
|
def __init__(self, connection_string: str, database_name: str):
|
|
60
|
+
"""Initialize MongoDB database connection.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
connection_string: MongoDB connection URI
|
|
64
|
+
database_name: Name of the database to use
|
|
65
|
+
"""
|
|
33
66
|
super().__init__()
|
|
34
67
|
self.connection_string = connection_string
|
|
35
68
|
self.database_name = database_name
|
|
@@ -46,38 +79,103 @@ class MongoDB(DB):
|
|
|
46
79
|
self.plugins: Collection[Any] = self.db.plugins
|
|
47
80
|
|
|
48
81
|
def get_app_description(self, lang: str) -> str:
|
|
82
|
+
"""Retrieve the application description for a specific language.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
lang: Language code (e.g., 'en', 'pl')
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Application description text or empty string if not found
|
|
89
|
+
"""
|
|
49
90
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
50
91
|
if site_content and "app_description" in site_content:
|
|
51
92
|
return site_content["app_description"].get(lang, "")
|
|
52
93
|
return ""
|
|
53
94
|
|
|
54
95
|
def get_all_posts(self, lang: str) -> list[Post]:
|
|
96
|
+
"""Retrieve all posts for a specific language.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
lang: Language code (e.g., 'en', 'pl')
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of Post objects
|
|
103
|
+
"""
|
|
55
104
|
posts_cursor = self.posts.find({"language": lang})
|
|
56
105
|
return [Post.model_validate(post) for post in posts_cursor]
|
|
57
106
|
|
|
58
107
|
def get_menu_items_in_lang(self, lang: str) -> list[MenuItem]:
|
|
108
|
+
"""Retrieve menu items for a specific language.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
lang: Language code (e.g., 'en', 'pl')
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of MenuItem objects
|
|
115
|
+
"""
|
|
59
116
|
menu_items_doc = self.menu_items.find_one({"_id": lang})
|
|
60
117
|
if menu_items_doc and "items" in menu_items_doc:
|
|
61
118
|
return [MenuItem.model_validate(item) for item in menu_items_doc["items"]]
|
|
62
119
|
return []
|
|
63
120
|
|
|
64
121
|
def get_post(self, slug: str) -> Post:
|
|
122
|
+
"""Retrieve a single post by its slug.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
slug: URL-friendly identifier for the post
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Post object
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If post not found
|
|
132
|
+
"""
|
|
65
133
|
post_doc = self.posts.find_one({"slug": slug})
|
|
66
134
|
if post_doc is None:
|
|
67
135
|
raise ValueError(f"Post with slug {slug} not found")
|
|
68
136
|
return Post.model_validate(post_doc)
|
|
69
137
|
|
|
70
138
|
def get_page(self, slug: str) -> Page:
|
|
139
|
+
"""Retrieve a page by its slug.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
slug: URL-friendly identifier for the page
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Page object
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If page not found
|
|
149
|
+
"""
|
|
71
150
|
page_doc = self.pages.find_one({"slug": slug})
|
|
72
151
|
if page_doc is None:
|
|
73
152
|
raise ValueError(f"Page with slug {slug} not found")
|
|
74
153
|
return Page.model_validate(page_doc)
|
|
75
154
|
|
|
76
|
-
def get_posts_by_tag(self, tag: str, lang: str) ->
|
|
155
|
+
def get_posts_by_tag(self, tag: str, lang: str) -> list[Post]:
|
|
156
|
+
"""Retrieve posts filtered by tag and language.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
tag: Tag name to filter by
|
|
160
|
+
lang: Language code (e.g., 'en', 'pl')
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of Post objects matching the tag and language
|
|
164
|
+
"""
|
|
77
165
|
posts_cursor = self.posts.find({"tags": tag, "language": lang})
|
|
78
|
-
return posts_cursor
|
|
166
|
+
return [Post.model_validate(post) for post in posts_cursor]
|
|
79
167
|
|
|
80
168
|
def add_comment(self, author_name: str, comment: str, post_slug: str) -> None:
|
|
169
|
+
"""Add a new comment to a post.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
author_name: Name of the comment author
|
|
173
|
+
comment: Comment text content
|
|
174
|
+
post_slug: URL-friendly identifier of the post
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If post not found
|
|
178
|
+
"""
|
|
81
179
|
now_utc = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
|
82
180
|
comment_doc = {
|
|
83
181
|
"author": str(author_name),
|
|
@@ -90,36 +188,66 @@ class MongoDB(DB):
|
|
|
90
188
|
raise ValueError(f"Post with slug {post_slug} not found")
|
|
91
189
|
|
|
92
190
|
def get_logo_url(self) -> str:
|
|
191
|
+
"""Retrieve the URL of the application logo.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Logo image URL or empty string if not found
|
|
195
|
+
"""
|
|
93
196
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
94
197
|
if site_content:
|
|
95
198
|
return site_content.get("logo_url", "")
|
|
96
199
|
return ""
|
|
97
200
|
|
|
98
201
|
def get_favicon_url(self) -> str:
|
|
202
|
+
"""Retrieve the URL of the application favicon.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Favicon URL or empty string if not found
|
|
206
|
+
"""
|
|
99
207
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
100
208
|
if site_content:
|
|
101
209
|
return site_content.get("favicon_url", "")
|
|
102
210
|
return ""
|
|
103
211
|
|
|
104
212
|
def get_primary_color(self) -> str:
|
|
213
|
+
"""Retrieve the primary color for the application theme.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Primary color value, defaults to 'white'
|
|
217
|
+
"""
|
|
105
218
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
106
219
|
if site_content:
|
|
107
220
|
return site_content.get("primary_color", "white")
|
|
108
221
|
return "white"
|
|
109
222
|
|
|
110
223
|
def get_secondary_color(self) -> str:
|
|
224
|
+
"""Retrieve the secondary color for the application theme.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Secondary color value, defaults to 'navy'
|
|
228
|
+
"""
|
|
111
229
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
112
230
|
if site_content:
|
|
113
231
|
return site_content.get("secondary_color", "navy")
|
|
114
232
|
return "navy"
|
|
115
233
|
|
|
116
|
-
def get_plugins_data(self) -> list[Any]:
|
|
234
|
+
def get_plugins_data(self) -> list[dict[str, Any]]:
|
|
235
|
+
"""Retrieve configuration data for all plugins.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of plugin configuration dictionaries
|
|
239
|
+
"""
|
|
117
240
|
plugins_doc = self.plugins.find_one({"_id": "config"})
|
|
118
241
|
if plugins_doc and "data" in plugins_doc:
|
|
119
242
|
return plugins_doc["data"]
|
|
120
243
|
return []
|
|
121
244
|
|
|
122
245
|
def get_font(self) -> str:
|
|
246
|
+
"""Get the font configuration for the application.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Font name or empty string if not configured
|
|
250
|
+
"""
|
|
123
251
|
site_content = self.site_content.find_one({"_id": "config"})
|
|
124
252
|
if site_content:
|
|
125
253
|
return site_content.get("font", "")
|
platzky/engine.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from collections.abc import Callable
|
|
2
3
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
|
3
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from flask import Blueprint, Flask, jsonify, make_response, request, session
|
|
6
7
|
from flask_babel import Babel
|
|
7
8
|
|
|
8
9
|
from platzky.config import Config
|
|
10
|
+
from platzky.db.db import DB
|
|
9
11
|
from platzky.models import CmsModule
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class Engine(Flask):
|
|
13
|
-
def __init__(self, config: Config, db, import_name):
|
|
15
|
+
def __init__(self, config: Config, db: DB, import_name: str) -> None:
|
|
14
16
|
super().__init__(import_name)
|
|
15
17
|
self.config.from_mapping(config.model_dump(by_alias=True))
|
|
16
18
|
self.db = db
|
|
@@ -18,7 +20,7 @@ class Engine(Flask):
|
|
|
18
20
|
self.login_methods = []
|
|
19
21
|
self.dynamic_body = ""
|
|
20
22
|
self.dynamic_head = ""
|
|
21
|
-
self.health_checks:
|
|
23
|
+
self.health_checks: list[tuple[str, Callable[[], None]]] = []
|
|
22
24
|
self.telemetry_instrumented: bool = False
|
|
23
25
|
directory = os.path.dirname(os.path.realpath(__file__))
|
|
24
26
|
locale_dir = os.path.join(directory, "locale")
|
|
@@ -31,7 +33,7 @@ class Engine(Flask):
|
|
|
31
33
|
)
|
|
32
34
|
self._register_default_health_endpoints()
|
|
33
35
|
|
|
34
|
-
self.cms_modules:
|
|
36
|
+
self.cms_modules: list[CmsModule] = []
|
|
35
37
|
# TODO add plugins as CMS Module - all plugins should be visible from
|
|
36
38
|
# admin page at least as configuration
|
|
37
39
|
|
|
@@ -39,7 +41,7 @@ class Engine(Flask):
|
|
|
39
41
|
for notifier in self.notifiers:
|
|
40
42
|
notifier(message)
|
|
41
43
|
|
|
42
|
-
def add_notifier(self, notifier):
|
|
44
|
+
def add_notifier(self, notifier: Callable[[str], None]) -> None:
|
|
43
45
|
self.notifiers.append(notifier)
|
|
44
46
|
|
|
45
47
|
def add_cms_module(self, module: CmsModule):
|
|
@@ -47,7 +49,7 @@ class Engine(Flask):
|
|
|
47
49
|
self.cms_modules.append(module)
|
|
48
50
|
|
|
49
51
|
# TODO login_method should be interface
|
|
50
|
-
def add_login_method(self, login_method):
|
|
52
|
+
def add_login_method(self, login_method: Callable[[], str]) -> None:
|
|
51
53
|
self.login_methods.append(login_method)
|
|
52
54
|
|
|
53
55
|
def add_dynamic_body(self, body: str):
|
|
@@ -94,7 +96,7 @@ class Engine(Flask):
|
|
|
94
96
|
@health_bp.route("/health/readiness")
|
|
95
97
|
def readiness():
|
|
96
98
|
"""Readiness check - can the app serve traffic?"""
|
|
97
|
-
health_status:
|
|
99
|
+
health_status: dict[str, Any] = {"status": "ready", "checks": {}}
|
|
98
100
|
status_code = 200
|
|
99
101
|
|
|
100
102
|
executor = ThreadPoolExecutor(max_workers=1)
|
platzky/models.py
CHANGED
|
@@ -1,7 +1,87 @@
|
|
|
1
1
|
import datetime
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Annotated
|
|
2
4
|
|
|
3
5
|
import humanize
|
|
4
|
-
from pydantic import BaseModel
|
|
6
|
+
from pydantic import BaseModel, BeforeValidator, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_date_string(v: str | datetime.datetime) -> datetime.datetime:
|
|
10
|
+
"""Parse date string to datetime for backward compatibility.
|
|
11
|
+
|
|
12
|
+
Handles string dates in various ISO 8601 formats for backward compatibility.
|
|
13
|
+
Emits deprecation warning when parsing strings.
|
|
14
|
+
|
|
15
|
+
In version 2.0.0, only datetime objects will be accepted.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
v: Either a datetime object or an ISO 8601 date string
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Timezone-aware datetime object
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the date string cannot be parsed
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(v, datetime.datetime):
|
|
27
|
+
# If already a datetime object, ensure it's timezone-aware
|
|
28
|
+
if v.tzinfo is None:
|
|
29
|
+
# Naive datetime - make timezone-aware using UTC
|
|
30
|
+
return v.replace(tzinfo=datetime.timezone.utc)
|
|
31
|
+
return v
|
|
32
|
+
|
|
33
|
+
# v must be a string (based on type annotation)
|
|
34
|
+
# Emit deprecation warning for string dates
|
|
35
|
+
warnings.warn(
|
|
36
|
+
f"Passing date as string ('{v}') is deprecated. "
|
|
37
|
+
"Please use datetime objects instead. "
|
|
38
|
+
"String support will be removed in version 2.0.0.",
|
|
39
|
+
DeprecationWarning,
|
|
40
|
+
stacklevel=2,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Check for timezone in the original string (before any manipulation)
|
|
44
|
+
# Check for: +HH:MM, -HH:MM, or Z suffix
|
|
45
|
+
time_part = v.split("T")[-1] if "T" in v else ""
|
|
46
|
+
has_timezone = (
|
|
47
|
+
v.endswith("Z")
|
|
48
|
+
or "+" in time_part
|
|
49
|
+
or ("-" in time_part and ":" in time_part.split("-")[-1])
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Normalize 'Z' suffix to '+00:00' for fromisoformat
|
|
53
|
+
normalized = v.replace("Z", "+00:00") if v.endswith("Z") else v
|
|
54
|
+
|
|
55
|
+
if has_timezone:
|
|
56
|
+
# Parse timezone-aware datetime (handles microseconds automatically)
|
|
57
|
+
return datetime.datetime.fromisoformat(normalized)
|
|
58
|
+
else:
|
|
59
|
+
# Legacy format: naive datetime - make timezone-aware using UTC
|
|
60
|
+
warnings.warn(
|
|
61
|
+
f"Naive datetime '{v}' interpreted as UTC. "
|
|
62
|
+
"Explicitly specify timezone in future versions for clarity.",
|
|
63
|
+
DeprecationWarning,
|
|
64
|
+
stacklevel=2,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
parsed = datetime.datetime.fromisoformat(normalized)
|
|
68
|
+
return parsed.replace(tzinfo=datetime.timezone.utc)
|
|
69
|
+
except ValueError:
|
|
70
|
+
# Fallback: date-only format
|
|
71
|
+
parsed_date = datetime.date.fromisoformat(normalized)
|
|
72
|
+
return datetime.datetime.combine(
|
|
73
|
+
parsed_date, datetime.time.min, tzinfo=datetime.timezone.utc
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Type alias for datetime fields that accept strings for backward compatibility
|
|
78
|
+
# Input: str | datetime.datetime
|
|
79
|
+
# Output (after validation): datetime.datetime
|
|
80
|
+
DateTimeField = Annotated[
|
|
81
|
+
datetime.datetime,
|
|
82
|
+
BeforeValidator(_parse_date_string),
|
|
83
|
+
# This allows str at the type-checker level while ensuring datetime after validation
|
|
84
|
+
]
|
|
5
85
|
|
|
6
86
|
|
|
7
87
|
class CmsModule(BaseModel):
|
|
@@ -21,28 +101,73 @@ class CmsModuleGroup(CmsModule):
|
|
|
21
101
|
|
|
22
102
|
|
|
23
103
|
class Image(BaseModel):
|
|
104
|
+
"""Represents an image with URL and alternate text.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
url: URL path to the image resource
|
|
108
|
+
alternateText: Descriptive text for accessibility and SEO
|
|
109
|
+
"""
|
|
110
|
+
|
|
24
111
|
url: str = ""
|
|
25
112
|
alternateText: str = ""
|
|
26
113
|
|
|
27
114
|
|
|
28
115
|
class MenuItem(BaseModel):
|
|
116
|
+
"""Represents a navigation menu item.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
name: Display name of the menu item
|
|
120
|
+
url: Target URL for the menu item link
|
|
121
|
+
"""
|
|
122
|
+
|
|
29
123
|
name: str
|
|
30
124
|
url: str
|
|
31
125
|
|
|
32
126
|
|
|
33
127
|
class Comment(BaseModel):
|
|
128
|
+
"""Represents a user comment on a blog post or page.
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
author: Name of the comment author
|
|
132
|
+
comment: The comment text content
|
|
133
|
+
date: Datetime when the comment was posted (timezone-aware recommended)
|
|
134
|
+
"""
|
|
135
|
+
|
|
34
136
|
author: str
|
|
35
137
|
comment: str
|
|
36
|
-
date:
|
|
138
|
+
date: DateTimeField
|
|
37
139
|
|
|
38
140
|
@property
|
|
39
141
|
def time_delta(self) -> str:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
142
|
+
"""Calculate human-readable time since the comment was posted.
|
|
143
|
+
|
|
144
|
+
Uses timezone-aware datetimes to ensure accurate time delta calculations.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Human-friendly time description (e.g., "2 hours ago", "3 days ago")
|
|
148
|
+
"""
|
|
149
|
+
# self.date is already a datetime object (parsed by field_validator)
|
|
150
|
+
# Always use timezone-aware datetime for consistency
|
|
151
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
152
|
+
return humanize.naturaltime(now - self.date)
|
|
43
153
|
|
|
44
154
|
|
|
45
155
|
class Post(BaseModel):
|
|
156
|
+
"""Represents a blog post with metadata, content, and comments.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
author: Name of the post author
|
|
160
|
+
slug: URL-friendly unique identifier for the post
|
|
161
|
+
title: Post title
|
|
162
|
+
contentInMarkdown: Post content in Markdown format
|
|
163
|
+
comments: List of comments on this post
|
|
164
|
+
excerpt: Short summary or preview of the post
|
|
165
|
+
tags: List of tags for categorization
|
|
166
|
+
language: Language code for the post content
|
|
167
|
+
coverImage: Cover image for the post
|
|
168
|
+
date: Datetime when the post was published (timezone-aware recommended)
|
|
169
|
+
"""
|
|
170
|
+
|
|
46
171
|
author: str
|
|
47
172
|
slug: str
|
|
48
173
|
title: str
|
|
@@ -52,30 +177,41 @@ class Post(BaseModel):
|
|
|
52
177
|
tags: list[str]
|
|
53
178
|
language: str
|
|
54
179
|
coverImage: Image
|
|
55
|
-
date:
|
|
180
|
+
date: DateTimeField
|
|
181
|
+
|
|
182
|
+
def __lt__(self, other: object) -> bool:
|
|
183
|
+
"""Compare posts by date for sorting.
|
|
184
|
+
|
|
185
|
+
Uses datetime comparison to ensure robust and correct ordering.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
other: Another Post instance to compare against
|
|
56
189
|
|
|
57
|
-
|
|
190
|
+
Returns:
|
|
191
|
+
True if this post's date is earlier than the other post's date,
|
|
192
|
+
or NotImplemented if comparing with a non-Post object
|
|
193
|
+
"""
|
|
58
194
|
if isinstance(other, Post):
|
|
59
195
|
return self.date < other.date
|
|
60
|
-
|
|
196
|
+
return NotImplemented
|
|
61
197
|
|
|
62
198
|
|
|
63
|
-
Page = Post
|
|
199
|
+
Page = Post # Page is an alias for Post (static pages use the same structure)
|
|
64
200
|
|
|
65
201
|
|
|
66
202
|
class Color(BaseModel):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
203
|
+
"""Represents an RGBA color value.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
r: Red component (0-255)
|
|
207
|
+
g: Green component (0-255)
|
|
208
|
+
b: Blue component (0-255)
|
|
209
|
+
a: Alpha/transparency component (0-255, where 255 is fully opaque)
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
r: int = Field(default=0, ge=0, le=255, description="Red component (0-255)")
|
|
213
|
+
g: int = Field(default=0, ge=0, le=255, description="Green component (0-255)")
|
|
214
|
+
b: int = Field(default=0, ge=0, le=255, description="Blue component (0-255)")
|
|
215
|
+
a: int = Field(
|
|
216
|
+
default=255, ge=0, le=255, description="Alpha component (0-255, where 255 is fully opaque)"
|
|
217
|
+
)
|