refactor(datetime): introduce UtcDateTime, remove naive-strip workarounds
Adds a UtcDateTime TypeDecorator (models.py) that enforces aware-UTC on write and stamps tzinfo=UTC on read. Replaces 26 DateTime column declarations. Removes 5 production sites that defensively stripped tzinfo to match SQLite's lossy round-trip. auth.py now coerces legacy session cookies upward (stamp UTC on parsed naive marker) instead of stripping live aware markers downward. The change is Python-side only: UtcDateTime.impl = DateTime, so DDL and emitted SQL are unchanged. No Alembic migration needed. Adds 2 unit tests in test_models.py pinning the decorator's contract independently of the column declarations. The three deliberately-naive test_timeago.py fixtures (lines 67, 73, 113) remain naive on purpose -- they exercise _ensure_utc's normalize-up path at the public filter boundary, which stays as belt-and-braces defense. See docs/superpowers/specs/2026-05-16-tz-aware-datetime-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5436deaf0
commit
18113637e9
7 changed files with 83 additions and 39 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
@ -53,11 +53,12 @@ def load_current_user() -> None:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
g.user = None
|
g.user = None
|
||||||
return
|
return
|
||||||
# user.password_changed_at comes back naive from SQLite; strip tz from the
|
# Legacy sessions minted before the UtcDateTime migration carry naive
|
||||||
# marker so an aware-marker session (just stamped from an in-memory user)
|
# ISO markers; stamp UTC so the comparison against the now-aware DB
|
||||||
# compares cleanly with a freshly-loaded user row.
|
# value works. Cheap permanent defense; no reliable signal for "all
|
||||||
if marker_dt.tzinfo is not None:
|
# naive cookies have expired."
|
||||||
marker_dt = marker_dt.replace(tzinfo=None)
|
if marker_dt.tzinfo is None:
|
||||||
|
marker_dt = marker_dt.replace(tzinfo=UTC)
|
||||||
if marker_dt < user.password_changed_at:
|
if marker_dt < user.password_changed_at:
|
||||||
g.user = None
|
g.user = None
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import (
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
TypeDecorator,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
@ -23,6 +24,34 @@ def now_utc() -> datetime:
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class UtcDateTime(TypeDecorator):
|
||||||
|
"""Always store and surface UTC-aware datetimes.
|
||||||
|
|
||||||
|
SQLite has no native tz-aware type and pysqlite strips tzinfo on
|
||||||
|
round-trip. We convert inputs to UTC, persist them as naive UTC under
|
||||||
|
the hood, and re-stamp tzinfo=UTC on read. The result: every datetime
|
||||||
|
on a model attribute is aware UTC, always.
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = DateTime
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise TypeError(
|
||||||
|
"naive datetime passed to UtcDateTime column; "
|
||||||
|
"all writes must be UTC-aware"
|
||||||
|
)
|
||||||
|
return value.astimezone(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
|
@ -33,9 +62,9 @@ class User(Base):
|
||||||
active: Mapped[bool] = mapped_column(
|
active: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=True, nullable=False, server_default=text("1"),
|
Boolean, default=True, nullable=False, server_default=text("1"),
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
password_changed_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
password_changed_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Overlay(Base):
|
class Overlay(Base):
|
||||||
|
|
@ -65,8 +94,8 @@ class Overlay(Base):
|
||||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
script: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
script: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
last_build_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class WorkshopItem(Base):
|
class WorkshopItem(Base):
|
||||||
|
|
@ -80,10 +109,10 @@ class WorkshopItem(Base):
|
||||||
file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
file_size: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
|
||||||
time_updated: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
time_updated: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
preview_url: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
preview_url: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
last_downloaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_downloaded_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class OverlayWorkshopItem(Base):
|
class OverlayWorkshopItem(Base):
|
||||||
|
|
@ -110,8 +139,8 @@ class Blueprint(Base):
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
arguments: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
||||||
config: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
config: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class BlueprintOverlay(Base):
|
class BlueprintOverlay(Base):
|
||||||
|
|
@ -124,8 +153,8 @@ class BlueprintOverlay(Base):
|
||||||
expose_server_cfg: Mapped[bool] = mapped_column(
|
expose_server_cfg: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False, server_default=text("0")
|
Boolean, nullable=False, default=False, server_default=text("0")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Server(Base):
|
class Server(Base):
|
||||||
|
|
@ -141,7 +170,7 @@ class Server(Base):
|
||||||
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||||
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
|
desired_state: Mapped[str] = mapped_column(String(16), default="stopped", nullable=False)
|
||||||
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
actual_state: Mapped[str] = mapped_column(String(16), default="unknown", nullable=False)
|
||||||
actual_state_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
actual_state_updated_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
last_error: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
rcon_password: Mapped[str] = mapped_column(
|
rcon_password: Mapped[str] = mapped_column(
|
||||||
String(64), nullable=False, default="", server_default=""
|
String(64), nullable=False, default="", server_default=""
|
||||||
|
|
@ -149,8 +178,8 @@ class Server(Base):
|
||||||
hostname: Mapped[str] = mapped_column(
|
hostname: Mapped[str] = mapped_column(
|
||||||
String(128), nullable=False, default="", server_default=""
|
String(128), nullable=False, default="", server_default=""
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class Job(Base):
|
class Job(Base):
|
||||||
|
|
@ -163,10 +192,10 @@ class Job(Base):
|
||||||
operation: Mapped[str] = mapped_column(String(32), nullable=False)
|
operation: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
|
state: Mapped[str] = mapped_column(String(16), default="queued", nullable=False)
|
||||||
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
started_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
finished_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class JobLog(Base):
|
class JobLog(Base):
|
||||||
|
|
@ -177,7 +206,7 @@ class JobLog(Base):
|
||||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
stream: Mapped[str] = mapped_column(String(8), nullable=False)
|
stream: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
line: Mapped[str] = mapped_column(Text, nullable=False)
|
line: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=now_utc, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class ServerLiveState(Base):
|
class ServerLiveState(Base):
|
||||||
|
|
@ -191,8 +220,8 @@ class ServerLiveState(Base):
|
||||||
server_id: Mapped[int] = mapped_column(
|
server_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
started_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
last_seen_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
|
||||||
players: Mapped[int] = mapped_column(Integer, nullable=False)
|
players: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
max_players: Mapped[int] = mapped_column(Integer, nullable=False)
|
max_players: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
bots: Mapped[int] = mapped_column(Integer, nullable=False)
|
bots: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
@ -213,8 +242,8 @@ class ServerPlayerSession(Base):
|
||||||
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("servers.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
|
steam_id_64: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
joined_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
|
||||||
left_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
left_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
|
name_at_join: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
min_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
max_ping: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
@ -226,7 +255,7 @@ class SteamUserProfile(Base):
|
||||||
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
|
steam_id_64: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||||
persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
persona_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
|
avatar_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
fetched_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class CommandHistory(Base):
|
class CommandHistory(Base):
|
||||||
|
|
@ -248,5 +277,5 @@ class CommandHistory(Base):
|
||||||
Boolean, nullable=False, default=False, server_default=text("0")
|
Boolean, nullable=False, default=False, server_default=text("0")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=now_utc, nullable=False
|
UtcDateTime, default=now_utc, nullable=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ def servers_page() -> str:
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
||||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
|
cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds)
|
||||||
|
|
||||||
server_ids = [s.id for s, _bp in rows]
|
server_ids = [s.id for s, _bp in rows]
|
||||||
latest_rows: dict[int, ServerLiveState] = {}
|
latest_rows: dict[int, ServerLiveState] = {}
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,7 @@ def profile_password_change() -> Response:
|
||||||
if user is None or not verify_password(current_password, user.password_digest):
|
if user is None or not verify_password(current_password, user.password_digest):
|
||||||
return _redirect_with_error("wrong_current")
|
return _redirect_with_error("wrong_current")
|
||||||
user.password_digest = hash_password(new_password)
|
user.password_digest = hash_password(new_password)
|
||||||
# Strip tz so the marker matches what a subsequent DB read returns
|
user.password_changed_at = now_utc()
|
||||||
# (SQLite DateTime columns don't preserve tzinfo).
|
|
||||||
user.password_changed_at = now_utc().replace(tzinfo=None)
|
|
||||||
new_marker = user.password_changed_at.isoformat()
|
new_marker = user.password_changed_at.isoformat()
|
||||||
|
|
||||||
session["pw_changed_at"] = new_marker
|
session["pw_changed_at"] = new_marker
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ def live_state_fragment(server_id: int) -> Response:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
|
||||||
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
stale_seconds = current_app.config.get("LIVE_STATE_STALE_SECONDS", 30)
|
||||||
cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=stale_seconds)
|
cutoff = datetime.now(UTC) - timedelta(seconds=stale_seconds)
|
||||||
|
|
||||||
latest = db.scalar(
|
latest = db.scalar(
|
||||||
select(ServerLiveState)
|
select(ServerLiveState)
|
||||||
|
|
@ -231,7 +231,7 @@ def live_state_fragment(server_id: int) -> Response:
|
||||||
|
|
||||||
current_ids = [r[0].steam_id_64 for r in current_rows]
|
current_ids = [r[0].steam_id_64 for r in current_rows]
|
||||||
|
|
||||||
recent_cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(
|
recent_cutoff = datetime.now(UTC) - timedelta(
|
||||||
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
|
days=current_app.config.get("LIVE_STATE_HISTORY_DAYS", 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def poll_once() -> None:
|
def poll_once() -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from l4d2web.db import init_db, session_scope
|
from l4d2web.db import init_db, session_scope
|
||||||
|
|
@ -8,6 +11,7 @@ from l4d2web.models import (
|
||||||
ServerPlayerSession,
|
ServerPlayerSession,
|
||||||
SteamUserProfile,
|
SteamUserProfile,
|
||||||
User,
|
User,
|
||||||
|
UtcDateTime,
|
||||||
now_utc as now_utc_aware,
|
now_utc as now_utc_aware,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -108,3 +112,15 @@ def test_steam_user_profile_table_columns(tmp_path, monkeypatch) -> None:
|
||||||
fetched_at=now_utc_aware(),
|
fetched_at=now_utc_aware(),
|
||||||
)
|
)
|
||||||
db.add(row); db.flush()
|
db.add(row); db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_datetime_rejects_naive_bind() -> None:
|
||||||
|
with pytest.raises(TypeError, match="naive"):
|
||||||
|
UtcDateTime().process_bind_param(datetime(2026, 5, 16, 12, 0), None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_datetime_stamps_utc_on_read() -> None:
|
||||||
|
naive = datetime(2026, 5, 16, 12, 0)
|
||||||
|
result = UtcDateTime().process_result_value(naive, None)
|
||||||
|
assert result == datetime(2026, 5, 16, 12, 0, tzinfo=UTC)
|
||||||
|
assert result.tzinfo == UTC
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue