platzky 1.0.1__tar.gz → 1.1.0__tar.gz
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-1.0.1 → platzky-1.1.0}/PKG-INFO +1 -1
- {platzky-1.0.1 → platzky-1.1.0}/platzky/admin/fake_login.py +2 -1
- platzky-1.1.0/platzky/blog/blog.py +112 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/db.py +2 -1
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/json_db.py +14 -6
- {platzky-1.0.1 → platzky-1.1.0}/platzky/engine.py +5 -4
- platzky-1.1.0/platzky/models.py +217 -0
- platzky-1.1.0/platzky/platzky.py +283 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/plugin/plugin.py +39 -3
- platzky-1.1.0/platzky/plugin/plugin_loader.py +204 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/seo/seo.py +1 -1
- {platzky-1.0.1 → platzky-1.1.0}/pyproject.toml +21 -3
- platzky-1.0.1/platzky/blog/blog.py +0 -90
- platzky-1.0.1/platzky/models.py +0 -81
- platzky-1.0.1/platzky/platzky.py +0 -137
- platzky-1.0.1/platzky/plugin/plugin_loader.py +0 -109
- {platzky-1.0.1 → platzky-1.1.0}/LICENSE +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/README.md +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/__init__.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/admin/admin.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/admin/templates/admin.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/admin/templates/login.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/admin/templates/module.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/blog/__init__.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/blog/comment_form.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/config.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/README.md +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/__init__.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/db_loader.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/github_json_db.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/google_json_db.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/graph_ql_db.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/json_file_db.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/db/mongodb_db.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/locale/en/LC_MESSAGES/messages.po +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/locale/pl/LC_MESSAGES/messages.po +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/static/blog.css +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/static/styles.css +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/telemetry.py +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/404.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/base.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/blog.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/body_meta.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/dynamic_css.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/feed.xml +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/head_meta.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/page.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/post.html +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/robots.txt +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/templates/sitemap.xml +0 -0
- {platzky-1.0.1 → platzky-1.1.0}/platzky/www_handler.py +0 -0
|
@@ -6,7 +6,8 @@ environments as it bypasses proper authentication and authorization controls.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import os
|
|
9
|
-
from
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
10
11
|
|
|
11
12
|
from flask import Blueprint, flash, redirect, render_template_string, session, url_for
|
|
12
13
|
from flask_wtf import FlaskForm
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from os.path import dirname
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from flask import Blueprint, abort, make_response, render_template, request
|
|
7
|
+
from markupsafe import Markup
|
|
8
|
+
from werkzeug.exceptions import HTTPException
|
|
9
|
+
from werkzeug.wrappers import Response
|
|
10
|
+
|
|
11
|
+
from . import comment_form
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_blog_blueprint(db, blog_prefix: str, locale_func):
|
|
17
|
+
url_prefix = blog_prefix
|
|
18
|
+
blog = Blueprint(
|
|
19
|
+
"blog",
|
|
20
|
+
__name__,
|
|
21
|
+
url_prefix=url_prefix,
|
|
22
|
+
template_folder=f"{dirname(__file__)}/../templates",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@blog.app_template_filter()
|
|
26
|
+
def markdown(text):
|
|
27
|
+
return Markup(text)
|
|
28
|
+
|
|
29
|
+
@blog.errorhandler(404)
|
|
30
|
+
def page_not_found(_e: HTTPException) -> tuple[str, int]:
|
|
31
|
+
"""Handle 404 Not Found errors in blog routes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
_e: HTTPException object containing error details (unused)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple of rendered 404 template and HTTP 404 status code
|
|
38
|
+
"""
|
|
39
|
+
return render_template("404.html", title="404"), 404
|
|
40
|
+
|
|
41
|
+
@blog.route("/", methods=["GET"])
|
|
42
|
+
def all_posts() -> str:
|
|
43
|
+
lang = locale_func()
|
|
44
|
+
posts = db.get_all_posts(lang)
|
|
45
|
+
if not posts:
|
|
46
|
+
abort(404)
|
|
47
|
+
posts_sorted = sorted(posts, reverse=True)
|
|
48
|
+
return render_template("blog.html", posts=posts_sorted)
|
|
49
|
+
|
|
50
|
+
@blog.route("/feed", methods=["GET"])
|
|
51
|
+
def get_feed() -> Response:
|
|
52
|
+
lang = locale_func()
|
|
53
|
+
response = make_response(render_template("feed.xml", posts=db.get_all_posts(lang)))
|
|
54
|
+
response.headers["Content-Type"] = "application/xml"
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
@blog.route("/<post_slug>", methods=["POST"])
|
|
58
|
+
def post_comment(post_slug: str) -> str:
|
|
59
|
+
comment = request.form.to_dict()
|
|
60
|
+
db.add_comment(
|
|
61
|
+
post_slug=post_slug,
|
|
62
|
+
author_name=comment["author_name"],
|
|
63
|
+
comment=comment["comment"],
|
|
64
|
+
)
|
|
65
|
+
return get_post(post_slug=post_slug)
|
|
66
|
+
|
|
67
|
+
def _get_content_or_404(
|
|
68
|
+
getter_func: Callable[[str], Any],
|
|
69
|
+
slug: str,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Helper to fetch content from database or abort with 404.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
getter_func: Database getter function (e.g., db.get_post, db.get_page)
|
|
75
|
+
slug: Content slug to fetch
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The fetched content object
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
HTTPException: 404 if content not found
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
return getter_func(slug)
|
|
85
|
+
except ValueError as e:
|
|
86
|
+
logger.debug("Content not found for slug '%s': %s", slug, e)
|
|
87
|
+
abort(404)
|
|
88
|
+
|
|
89
|
+
@blog.route("/<post_slug>", methods=["GET"])
|
|
90
|
+
def get_post(post_slug: str) -> str:
|
|
91
|
+
post = _get_content_or_404(db.get_post, post_slug)
|
|
92
|
+
return render_template(
|
|
93
|
+
"post.html",
|
|
94
|
+
post=post,
|
|
95
|
+
post_slug=post_slug,
|
|
96
|
+
form=comment_form.CommentForm(),
|
|
97
|
+
comment_sent=request.args.get("comment_sent"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@blog.route("/page/<path:page_slug>", methods=["GET"])
|
|
101
|
+
def get_page(page_slug: str) -> str:
|
|
102
|
+
page = _get_content_or_404(db.get_page, page_slug)
|
|
103
|
+
cover_image_url = page.coverImage.url if page.coverImage.url else None
|
|
104
|
+
return render_template("page.html", page=page, cover_image=cover_image_url)
|
|
105
|
+
|
|
106
|
+
@blog.route("/tag/<path:tag>", methods=["GET"])
|
|
107
|
+
def get_posts_from_tag(tag: str) -> str:
|
|
108
|
+
lang = locale_func()
|
|
109
|
+
posts = db.get_posts_by_tag(tag, lang)
|
|
110
|
+
return render_template("blog.html", posts=posts, subtitle=f" - tag: {tag}")
|
|
111
|
+
|
|
112
|
+
return blog
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import datetime
|
|
2
|
-
from typing import Any
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
4
|
from pydantic import Field
|
|
5
5
|
|
|
@@ -12,7 +12,7 @@ def db_config_type():
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class JsonDbConfig(DBConfig):
|
|
15
|
-
data:
|
|
15
|
+
data: dict[str, Any] = Field(alias="DATA")
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def get_db(config):
|
|
@@ -30,9 +30,9 @@ def db_from_config(config: JsonDbConfig):
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class Json(DB):
|
|
33
|
-
def __init__(self, data:
|
|
33
|
+
def __init__(self, data: dict[str, Any]):
|
|
34
34
|
super().__init__()
|
|
35
|
-
self.data:
|
|
35
|
+
self.data: dict[str, Any] = data
|
|
36
36
|
self.module_name = "json_db"
|
|
37
37
|
self.db_name = "JsonDb"
|
|
38
38
|
|
|
@@ -62,7 +62,10 @@ class Json(DB):
|
|
|
62
62
|
list_of_pages = (
|
|
63
63
|
page for page in self._get_site_content().get("pages") if page["slug"] == slug
|
|
64
64
|
)
|
|
65
|
-
|
|
65
|
+
wanted_page = next(list_of_pages, None)
|
|
66
|
+
if wanted_page is None:
|
|
67
|
+
raise ValueError(f"Page with slug {slug} not found")
|
|
68
|
+
page = Post.model_validate(wanted_page)
|
|
66
69
|
return page
|
|
67
70
|
|
|
68
71
|
def get_menu_items_in_lang(self, lang) -> list[MenuItem]:
|
|
@@ -101,10 +104,15 @@ class Json(DB):
|
|
|
101
104
|
return self._get_site_content().get("secondary_color", "navy")
|
|
102
105
|
|
|
103
106
|
def add_comment(self, author_name, comment, post_slug):
|
|
107
|
+
# Store dates in UTC with timezone info for consistency with MongoDB backend
|
|
108
|
+
# This ensures accurate time delta calculations regardless of server timezone
|
|
109
|
+
# Legacy dates without timezone info are still supported for backward compatibility
|
|
110
|
+
now_utc = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
|
111
|
+
|
|
104
112
|
comment = {
|
|
105
113
|
"author": str(author_name),
|
|
106
114
|
"comment": str(comment),
|
|
107
|
-
"date":
|
|
115
|
+
"date": now_utc,
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
post_index = next(
|
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
@@ -18,7 +19,7 @@ class Engine(Flask):
|
|
|
18
19
|
self.login_methods = []
|
|
19
20
|
self.dynamic_body = ""
|
|
20
21
|
self.dynamic_head = ""
|
|
21
|
-
self.health_checks:
|
|
22
|
+
self.health_checks: list[tuple[str, Callable[[], None]]] = []
|
|
22
23
|
self.telemetry_instrumented: bool = False
|
|
23
24
|
directory = os.path.dirname(os.path.realpath(__file__))
|
|
24
25
|
locale_dir = os.path.join(directory, "locale")
|
|
@@ -31,7 +32,7 @@ class Engine(Flask):
|
|
|
31
32
|
)
|
|
32
33
|
self._register_default_health_endpoints()
|
|
33
34
|
|
|
34
|
-
self.cms_modules:
|
|
35
|
+
self.cms_modules: list[CmsModule] = []
|
|
35
36
|
# TODO add plugins as CMS Module - all plugins should be visible from
|
|
36
37
|
# admin page at least as configuration
|
|
37
38
|
|
|
@@ -94,7 +95,7 @@ class Engine(Flask):
|
|
|
94
95
|
@health_bp.route("/health/readiness")
|
|
95
96
|
def readiness():
|
|
96
97
|
"""Readiness check - can the app serve traffic?"""
|
|
97
|
-
health_status:
|
|
98
|
+
health_status: dict[str, Any] = {"status": "ready", "checks": {}}
|
|
98
99
|
status_code = 200
|
|
99
100
|
|
|
100
101
|
executor = ThreadPoolExecutor(max_workers=1)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import humanize
|
|
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
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CmsModule(BaseModel):
|
|
88
|
+
"""Represents a CMS module with basic metadata."""
|
|
89
|
+
|
|
90
|
+
name: str
|
|
91
|
+
description: str
|
|
92
|
+
template: str
|
|
93
|
+
slug: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# CmsModuleGroup is also a CmsModule, but it contains other CmsModules
|
|
97
|
+
class CmsModuleGroup(CmsModule):
|
|
98
|
+
"""Represents a group of CMS modules, inheriting module properties."""
|
|
99
|
+
|
|
100
|
+
modules: list[CmsModule] = []
|
|
101
|
+
|
|
102
|
+
|
|
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
|
+
|
|
111
|
+
url: str = ""
|
|
112
|
+
alternateText: str = ""
|
|
113
|
+
|
|
114
|
+
|
|
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
|
+
|
|
123
|
+
name: str
|
|
124
|
+
url: str
|
|
125
|
+
|
|
126
|
+
|
|
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
|
+
|
|
136
|
+
author: str
|
|
137
|
+
comment: str
|
|
138
|
+
date: DateTimeField
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def time_delta(self) -> str:
|
|
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)
|
|
153
|
+
|
|
154
|
+
|
|
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
|
+
|
|
171
|
+
author: str
|
|
172
|
+
slug: str
|
|
173
|
+
title: str
|
|
174
|
+
contentInMarkdown: str
|
|
175
|
+
comments: list[Comment]
|
|
176
|
+
excerpt: str
|
|
177
|
+
tags: list[str]
|
|
178
|
+
language: str
|
|
179
|
+
coverImage: Image
|
|
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
|
|
189
|
+
|
|
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
|
+
"""
|
|
194
|
+
if isinstance(other, Post):
|
|
195
|
+
return self.date < other.date
|
|
196
|
+
return NotImplemented
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
Page = Post # Page is an alias for Post (static pages use the same structure)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Color(BaseModel):
|
|
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
|
+
)
|