From e15b3c12934cd63e6aed37e604100fc3964e8cde Mon Sep 17 00:00:00 2001 From: GGThed Date: Mon, 17 Aug 2026 15:21:08 -0400 Subject: [PATCH] fix(audit): centraliser l'horloge UTC --- app/discord_bot.py | 6 +++-- app/models/availability/base.py | 7 +++--- app/models/contract.py | 5 ++-- app/models/evaluation.py | 7 +++--- app/models/match_model/base.py | 5 ++-- app/models/one_on_one_request.py | 5 ++-- app/models/org_team/org_team.py | 5 ++-- app/models/org_team/team_player.py | 5 ++-- app/models/participant/base.py | 5 ++-- app/models/personal_note.py | 7 +++--- app/models/team/team.py | 5 ++-- app/models/team/team_member.py | 5 ++-- app/models/team_note.py | 7 +++--- app/models/tryout/tryout.py | 5 ++-- app/models/tryout/tryout_registration.py | 5 ++-- app/models/user_model/user.py | 5 ++-- app/routes/auth.py | 5 ++-- app/routes/team_matches.py | 5 ++-- app/routes/teams.py | 5 ++-- app/routes/tryouts.py | 7 +++--- app/routes/users/contracts.py | 4 ++-- app/routes/users/one_on_one.py | 7 +++--- app/time_utils.py | 19 ++++++++++++++++ pyproject.toml | 3 --- tests/test_time_utils.py | 29 ++++++++++++++++++++++++ 25 files changed, 101 insertions(+), 72 deletions(-) create mode 100644 app/time_utils.py create mode 100644 tests/test_time_utils.py diff --git a/app/discord_bot.py b/app/discord_bot.py index b3102b3..8ba7562 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -65,6 +65,8 @@ from discord.ext import commands from dotenv import load_dotenv from sqlalchemy.exc import SQLAlchemyError +from app.time_utils import utc_now_naive + load_dotenv() DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') @@ -780,7 +782,7 @@ class TeamTryoutsBot(commands.Bot): coach_obj = request.coach request.status = 'approved' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() except SQLAlchemyError: db.session.rollback() logger.exception('Could not read One on One request %s to approve it', request_id) @@ -875,7 +877,7 @@ class TeamTryoutsBot(commands.Bot): try: request.status = 'rejected' - request.responded_at = datetime.utcnow() + request.responded_at = utc_now_naive() if refusal_note: request.coach_rejection_message = refusal_note except SQLAlchemyError: diff --git a/app/models/availability/base.py b/app/models/availability/base.py index 4aee7ce..dc1cff4 100644 --- a/app/models/availability/base.py +++ b/app/models/availability/base.py @@ -1,8 +1,7 @@ """Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseAvailability(db.Model): @@ -13,5 +12,5 @@ class BaseAvailability(db.Model): day_of_week = db.Column(db.Integer, nullable=False) start_time = db.Column(db.Time, nullable=False) end_time = db.Column(db.Time, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) diff --git a/app/models/contract.py b/app/models/contract.py index c18ea94..da90e61 100644 --- a/app/models/contract.py +++ b/app/models/contract.py @@ -1,8 +1,7 @@ """Contract documents for players to sign.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Contract(db.Model): @@ -23,7 +22,7 @@ class Contract(db.Model): status = db.Column(db.String(20), default='pending') notes = db.Column(db.Text, nullable=True) - uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + uploaded_at = db.Column(db.DateTime, default=utc_now_naive) signed_at = db.Column(db.DateTime, nullable=True) player = db.relationship('User', foreign_keys=[player_id], backref='contracts') diff --git a/app/models/evaluation.py b/app/models/evaluation.py index 4feb240..9ef8f0e 100644 --- a/app/models/evaluation.py +++ b/app/models/evaluation.py @@ -1,8 +1,7 @@ """Player evaluation record.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Evaluation(db.Model): @@ -25,8 +24,8 @@ class Evaluation(db.Model): overall_score = db.Column(db.Float, nullable=True) comments = db.Column(db.Text, nullable=True) position_recommendation = db.Column(db.String(50), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) __table_args__ = ( db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py index f95232c..4716064 100644 --- a/app/models/match_model/base.py +++ b/app/models/match_model/base.py @@ -1,8 +1,7 @@ """Abstract base class for match models (Match + TeamMatch).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseMatch(db.Model): @@ -18,4 +17,4 @@ class BaseMatch(db.Model): location = db.Column(db.String(200), nullable=True) status = db.Column(db.String(20), default='scheduled') created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py index 1b1e94f..13f8c00 100644 --- a/app/models/one_on_one_request.py +++ b/app/models/one_on_one_request.py @@ -1,8 +1,7 @@ """Request from player to coach for a One on One session.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class OneOnOneRequest(db.Model): @@ -18,7 +17,7 @@ class OneOnOneRequest(db.Model): end_time = db.Column(db.Time, nullable=False) points = db.Column(db.Text, nullable=True) status = db.Column(db.String(20), default='pending') - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) responded_at = db.Column(db.DateTime, nullable=True) discord_message_id = db.Column(db.BigInteger, nullable=True) coach_rejection_message = db.Column(db.Text, nullable=True) diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index ae0a0b1..efb601e 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -1,9 +1,8 @@ """Persistent organisation team (e.g. Varsity, JV).""" -from datetime import datetime - from app.extensions import db from app.models._associations import org_team_coaches, org_team_managers +from app.time_utils import utc_now_naive class OrgTeam(db.Model): @@ -13,7 +12,7 @@ class OrgTeam(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index d07865c..04c3242 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -1,8 +1,7 @@ """Many-to-many junction: player to org-team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamPlayer(db.Model): @@ -14,7 +13,7 @@ class TeamPlayer(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) status = db.Column(db.String(20), nullable=False, default='starter') position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') diff --git a/app/models/participant/base.py b/app/models/participant/base.py index 7293699..2b4f1f2 100644 --- a/app/models/participant/base.py +++ b/app/models/participant/base.py @@ -1,8 +1,7 @@ """Abstract base class for match participant models.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class BaseParticipant(db.Model): @@ -11,4 +10,4 @@ class BaseParticipant(db.Model): __abstract__ = True player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) diff --git a/app/models/personal_note.py b/app/models/personal_note.py index 4745202..54491ca 100644 --- a/app/models/personal_note.py +++ b/app/models/personal_note.py @@ -1,8 +1,7 @@ """Personal notes from coach to individual player.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class PersonalNote(db.Model): @@ -13,8 +12,8 @@ class PersonalNote(db.Model): player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True) team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) diff --git a/app/models/team/team.py b/app/models/team/team.py index 4611fe1..47e1b1d 100644 --- a/app/models/team/team.py +++ b/app/models/team/team.py @@ -1,8 +1,7 @@ """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class Team(db.Model): @@ -13,7 +12,7 @@ class Team(db.Model): tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) name = db.Column(db.String(100), nullable=False) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', backref='created_teams') members = db.relationship('TeamMember', backref='team', lazy='dynamic') diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py index 391048f..0c1a1be 100644 --- a/app/models/team/team_member.py +++ b/app/models/team/team_member.py @@ -1,8 +1,7 @@ """Link between a player and a tryout-specific team.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamMember(db.Model): @@ -13,6 +12,6 @@ class TeamMember(db.Model): team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) position = db.Column(db.String(50), nullable=True) - added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utc_now_naive) player = db.relationship('User', overlaps="player_ref,team_assignments") diff --git a/app/models/team_note.py b/app/models/team_note.py index 76c8c21..5f66f31 100644 --- a/app/models/team_note.py +++ b/app/models/team_note.py @@ -1,8 +1,7 @@ """Team improvement notes from coach.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TeamNote(db.Model): @@ -13,8 +12,8 @@ class TeamNote(db.Model): org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) team = db.relationship('OrgTeam', backref='team_notes') coach = db.relationship('User', foreign_keys=[coach_id]) diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index e9f4a74..6a6cbf2 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,9 +1,8 @@ """Tryout event for player evaluations and team formation.""" -from datetime import datetime - from app.extensions import db from app.models._associations import tryout_coaches +from app.time_utils import utc_now_naive class Tryout(db.Model): @@ -25,7 +24,7 @@ class Tryout(db.Model): coach_id = db.Column( db.Integer, db.ForeignKey('users.id'), nullable=True ) # deprecated, kept for migration - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py index 007e77b..c8b3942 100644 --- a/app/models/tryout/tryout_registration.py +++ b/app/models/tryout/tryout_registration.py @@ -1,8 +1,7 @@ """Registration linking a player to a tryout.""" -from datetime import datetime - from app.extensions import db +from app.time_utils import utc_now_naive class TryoutRegistration(db.Model): @@ -12,6 +11,6 @@ class TryoutRegistration(db.Model): id = db.Column(db.Integer, primary_key=True) tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - registered_at = db.Column(db.DateTime, default=datetime.utcnow) + registered_at = db.Column(db.DateTime, default=utc_now_naive) status = db.Column(db.String(20), default='registered') notes = db.Column(db.Text, nullable=True) diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index 231a726..bcdff98 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -1,10 +1,9 @@ """Base User model — shared fields and polymorphic configuration.""" -from datetime import datetime - from flask_login import UserMixin from app.extensions import db +from app.time_utils import utc_now_naive class User(UserMixin, db.Model): @@ -25,7 +24,7 @@ class User(UserMixin, db.Model): email = db.Column(db.String(120), unique=True, nullable=False) phone = db.Column(db.String(20), nullable=True) is_active_account = db.Column(db.Boolean, default=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=utc_now_naive) failed_login_attempts = db.Column(db.Integer, default=0) locked_until = db.Column(db.DateTime, nullable=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 2925040..b0666af 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -8,7 +8,7 @@ password policy enforcement and sign-up screening. import os import secrets import time -from datetime import datetime, timedelta +from datetime import timedelta from urllib.parse import urlencode, urlparse import requests @@ -22,6 +22,7 @@ from app.forms import form_gamertags from app.i18n import LOCALE_SESSION_KEY from app.logging_config import log_auth_event from app.models import ESPORT_GAMES, Player, User +from app.time_utils import utc_now_naive from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id #: Session key holding the pending OAuth2 anti-forgery token. @@ -294,7 +295,7 @@ def login(): ) if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: minutes = cooloff_minutes(user.failed_login_attempts) - user.locked_until = datetime.utcnow() + timedelta(minutes=minutes) + user.locked_until = utc_now_naive() + timedelta(minutes=minutes) log_auth_event( 'account.throttled', username=username, diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 7671c94..0d87607 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -3,8 +3,6 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -27,6 +25,7 @@ from app.pagination import paginate from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams from app.routes.matches import default_end_time from app.services.scheduling import notify_participants, zip_participants +from app.time_utils import utc_now_naive from app.validators import TeamMatchSchema team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') @@ -100,7 +99,7 @@ def list_matches(): teams=teams, match_data=match_data, pagination=matches_page, - now=datetime.utcnow(), + now=utc_now_naive(), ) diff --git a/app/routes/teams.py b/app/routes/teams.py index 9698d2a..5c677ad 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -3,8 +3,6 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, flash, jsonify, redirect, render_template, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -29,6 +27,7 @@ from app.models import ( User, ) from app.permissions import visible_org_teams +from app.time_utils import utc_now_naive from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema teams_bp = Blueprint('teams', __name__, url_prefix='/teams') @@ -82,7 +81,7 @@ def my_teams(): from app.models import TeamMatch, TeamMatchParticipant player_teams = current_user.get_org_teams() - now = datetime.utcnow() + now = utc_now_naive() team_data = [] for org_team in player_teams: diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index af99be7..db2468c 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -4,8 +4,6 @@ This module handles CRUD operations for tryouts and player registrations. Uses polymorphic isinstance checks instead of role-string comparisons. """ -from datetime import datetime - from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -33,6 +31,7 @@ from app.models import ( TryoutRegistration, User, ) +from app.time_utils import utc_now_naive from app.validators import ( PlayerSelectionSchema, TryoutRegistrationStatusSchema, @@ -114,7 +113,7 @@ def list_tryouts(): Delegates to the polymorphic User subclass's get_visible_tryouts() method. """ tryouts = current_user.get_visible_tryouts() - return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) + return render_template('pages/tryouts.html', tryouts=tryouts, now=utc_now_naive()) @tryouts_bp.route('/create', methods=['GET', 'POST']) @@ -425,7 +424,7 @@ def view_tryout(tryout_id): matches=matches, match_data=match_data, game_positions=GAME_POSITIONS, - now=datetime.utcnow(), + now=utc_now_naive(), ) diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py index ec9df0e..62e227d 100644 --- a/app/routes/users/contracts.py +++ b/app/routes/users/contracts.py @@ -2,7 +2,6 @@ import os import uuid -from datetime import datetime from flask import flash, redirect, render_template, request, send_file, url_for from flask_babel import gettext as _ @@ -20,6 +19,7 @@ from app.routes.users._shared import ( ) from app.routes.users.blueprint import users_bp from app.storage import CONTRACTS_DIR, document_path +from app.time_utils import utc_now_naive from app.validators import UploadContractSchema @@ -172,7 +172,7 @@ def upload_signed_contract(contract_id): contract.signed_filename = signed_filename contract.signed_file_path = signed_path contract.status = 'signed' - contract.signed_at = datetime.utcnow() + contract.signed_at = utc_now_naive() db.session.commit() flash(_('Signed contract uploaded successfully!'), 'success') return redirect(url_for('users.list_contracts')) diff --git a/app/routes/users/one_on_one.py b/app/routes/users/one_on_one.py index cd3a612..6211b19 100644 --- a/app/routes/users/one_on_one.py +++ b/app/routes/users/one_on_one.py @@ -1,7 +1,5 @@ """One-on-one sessions between a player and their coach.""" -from datetime import datetime - from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -12,6 +10,7 @@ from app.forms import flash_validation_errors, form_payload from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote from app.routes.users.blueprint import users_bp from app.services.notifications import send_discord_notification +from app.time_utils import utc_now_naive from app.validators import OneOnOneRejectionSchema, OneOnOneRequestSchema @@ -178,7 +177,7 @@ def accept_one_on_one(request_id): player = request_obj.player request_obj.status = 'approved' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() db.session.commit() # Notify player via Discord (same message as if approved through Discord reactions) @@ -235,7 +234,7 @@ def reject_one_on_one(request_id): player = request_obj.player request_obj.status = 'rejected' - request_obj.responded_at = datetime.utcnow() + request_obj.responded_at = utc_now_naive() if rejection_reason: request_obj.coach_rejection_message = rejection_reason db.session.commit() diff --git a/app/time_utils.py b/app/time_utils.py new file mode 100644 index 0000000..54325e5 --- /dev/null +++ b/app/time_utils.py @@ -0,0 +1,19 @@ +"""Time helpers with explicit storage semantics. + +The deployed schema currently stores timestamps in ``DateTime`` columns +without timezone information. Until the real PostgreSQL schema is restored +and migrated, application timestamps must therefore remain naive values. +They are nevertheless generated from an aware UTC clock so the convention is +explicit and does not rely on the deprecated :meth:`datetime.utcnow` API. +""" + +from datetime import UTC, datetime + + +def utc_now_naive() -> datetime: + """Return the current UTC instant without ``tzinfo`` for legacy columns. + + Replace this compatibility boundary with aware UTC values when the + corresponding columns are migrated to timezone-aware types. + """ + return datetime.now(UTC).replace(tzinfo=None) diff --git a/pyproject.toml b/pyproject.toml index 064e394..91a98b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,6 @@ filterwarnings = [ "default", # discord.py imports audioop, removed from the stdlib in 3.13. "ignore:'audioop' is deprecated:DeprecationWarning", - # Every model uses datetime.utcnow as a column default. Tracked as - # DB-009; the warning would otherwise drown the run. - "ignore:datetime.datetime.utcnow:DeprecationWarning", ] [tool.coverage.report] diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py new file mode 100644 index 0000000..6619cb6 --- /dev/null +++ b/tests/test_time_utils.py @@ -0,0 +1,29 @@ +"""Regression tests for the application's timestamp convention.""" + +import ast +from datetime import UTC, datetime +from pathlib import Path + +from app.time_utils import utc_now_naive + + +def test_utc_now_naive_is_an_explicit_utc_value(): + before = datetime.now(UTC).replace(tzinfo=None) + actual = utc_now_naive() + after = datetime.now(UTC).replace(tzinfo=None) + + assert actual.tzinfo is None + assert before <= actual <= after + + +def test_application_does_not_call_deprecated_utcnow(): + app_root = Path(__file__).parents[1] / 'app' + offenders = [] + + for path in app_root.rglob('*.py'): + tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr == 'utcnow': + offenders.append(f'{path.relative_to(app_root)}:{node.lineno}') + + assert offenders == []