Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ec0860d
Implement weekly report generation: refactor report logic, enhance HT…
cantis Aug 2, 2025
4489a77
Refactor weekly report templates: standardize headings, improve layou…
cantis Aug 2, 2025
f98ce99
Remove unused test files: delete test_time_fix.py, test_time_fix_stan…
cantis Aug 3, 2025
985d55b
Merge pull request #1 from cantis:feature/add-weekly-report
cantis Aug 3, 2025
0a02a83
feat: Implement user authentication and management features
cantis Aug 3, 2025
4a3e52e
feat: Enhance user management and testing capabilities
cantis Aug 3, 2025
ce0512e
feat: Add user creation and login in tests for improved test reliability
cantis Aug 3, 2025
0f84a17
feat: Implement configurable default admin user creation using enviro…
cantis Aug 7, 2025
9e87848
feat: Add change password and profile pages with validation and tests
cantis Aug 7, 2025
43fd322
feat: Refactor tests for home and reports routes to improve user auth…
cantis Aug 7, 2025
adc51dc
feat: Update Dockerfile and configuration for Render.com compatibilit…
cantis Aug 7, 2025
50e0c79
feat: Update dependencies for PostgreSQL support; add psycopg2-binary…
cantis Aug 7, 2025
37fe087
Refactoring user service to remove tuple retgurn code
cantis Sep 2, 2025
aaa87ed
Add comprehensive tests for authentication, home routes, and user ser…
cantis Sep 6, 2025
f5cc35b
feat: Update version in pyproject.toml and uv.lock; streamline versio…
cantis Oct 7, 2025
6f6acb2
chore: Update README.md to reflect project status and build instructions
cantis Oct 15, 2025
6a8b097
chore: Remove outdated documentation files for Docker, PostgreSQL, an…
cantis Oct 15, 2025
917db5f
refactor: Enhance time entry form layout and improve time selection o…
cantis Oct 19, 2025
2f6bd6f
feat: Update TimeEntry model to use boolean for time_out field; add d…
cantis Oct 20, 2025
8807d99
feat: Refactor user model and service to use 'user_active' instead of…
cantis Oct 21, 2025
e57fa48
feat: Update user model and service to use 'is_active' instead of 'us…
cantis Nov 10, 2025
2aaf8af
feat: Update README and scripts to enhance Docker image build process…
cantis Nov 10, 2025
060c72a
feat: Bump version to 0.1.37 in pyproject.toml and uv.lock
cantis Feb 20, 2026
0f0aff7
setup for hub reverse-proxy
cantis Feb 21, 2026
0c254c9
feat: Update header links to use url_for for improved routing
cantis Feb 21, 2026
9e25135
feat: Update to Sqlite db
cantis Feb 23, 2026
7f8fc81
feat: Implement user authentication and authorization with CSRF prote…
cantis Mar 11, 2026
902ed14
feat: Add automatic schema migrations for user_id in time_entries
cantis Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ USER appuser
# Expose the port that the application listens on.
EXPOSE 5000

# Run the application with debugging.
CMD ["sh", "-c", "gunicorn run:app --bind=0.0.0.0:5000"]
# Run the application - use PORT env var for Render.com compatibility
CMD ["sh", "-c", "gunicorn run:app --bind=0.0.0.0:${PORT:-5000}"]
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
April 2025

My attempt to create a time logging app using Python and Flask, oh and Docker.
Evan's time tracking application - a Python learning project.

See Docs folder for more documentation and how to set up the .env file

To build the docker image:
```powershell
# Bump patch version and build
PS> py build_image.py

# Or build with current version (no version bump)
PS> py build_current.py
```

Note: `build_image.py` will:
1. Update requirements.txt from current environment
2. Bump the patch version in `pyproject.toml`
3. Build and tag the Docker image with the new version (e.g., `time-tracker:0.1.36`)
4. Start the container with the new image

The `build_current.py` script builds without bumping the version.

To run the docker image:
```powershell
PS> docker compose up -d
```


Just an experiment right now...

63 changes: 59 additions & 4 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,92 @@
import os

from dotenv import load_dotenv
from flask import Flask
from flask import Flask, render_template
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from werkzeug.middleware.proxy_fix import ProxyFix

from app.config import Config
from app.models import db
from app.migrations import run_migrations
from app.models import User, db
from app.routes.admin import admin_bp
from app.routes.auth import auth_bp
from app.routes.home import home_bp
from app.routes.reports import reports_bp
from app.service.user_service import create_default_admin

# Load environment variables from .env file
load_dotenv()


def create_app() -> Flask:
"""Create and configure Flask application."""
def create_app(config_overrides: dict | None = None) -> Flask:
"""Create and configure Flask application. (application factory pattern)"""

app = Flask(__name__)

# Load configuration
app.config.from_object(Config)
app.config['APPLICATION_ROOT'] = os.getenv('APPLICATION_ROOT', '/')

# Apply any configuration overrides (useful for testing)
if config_overrides:
app.config.update(config_overrides)

# Configure ProxyFix middleware for reverse proxy support
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)

# Initialize CSRF protection
CSRFProtect(app)

# Ensure instance folder exists
os.makedirs(app.instance_path, exist_ok=True)

# Initialize database
db.init_app(app)

# Initialize Flask-Login
# Note: type ignore is used to suppress type checking issues with Flask-Login because of
# an incompatibility between Flask-Login and Flask's type hints.
login_manager: LoginManager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login' # type: ignore[attr-defined]
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'

@login_manager.user_loader
def load_user(user_id: str) -> User | None:
"""Load user by ID for Flask-Login."""
try:
return User.query.get(int(user_id))
except (ValueError, TypeError):
return None

with app.app_context():
db.create_all()
run_migrations(db)
# Create default admin user if no users exist (skip in tests)
if not (app.config.get('SKIP_DEFAULT_ADMIN', False) or os.getenv('SKIP_DEFAULT_ADMIN')):
create_default_admin()

# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(home_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(reports_bp)

# Register error handlers
@app.errorhandler(403)
def forbidden(e):
return render_template(
'error.html', error='403 Forbidden – You do not have permission to access this page.'
), 403

@app.errorhandler(404)
def not_found(e):
return render_template('error.html', error='404 Not Found – The page you requested does not exist.'), 404

@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', error='500 Internal Server Error – Something went wrong on our end.'), 500

return app
52 changes: 37 additions & 15 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,46 @@
# Base directory of the application
BASE_DIR = Path(__file__).resolve().parent.parent


class Config:
"""Configuration settings for the application."""

# Database config with Docker-aware path handling
db_uri = os.getenv('SQLALCHEMY_DATABASE_URI')
if db_uri and db_uri.startswith('sqlite:///'):
# Make relative paths absolute for Docker environment
db_path = db_uri.replace('sqlite:///', '')
if not db_path.startswith('/'):
# If path is not absolute, make it relative to app root
db_uri = f'sqlite:///{BASE_DIR / db_path}'

SQLALCHEMY_DATABASE_URI = db_uri or 'sqlite:///instance/timetrack.db'

# Use in-memory database for testing, otherwise use configured database
if os.getenv('TESTING') == 'True':
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
else:
# Priority order for database configuration:
# 1. DATABASE_URL (Render.com standard)
# 2. SQLALCHEMY_DATABASE_URI (legacy support)
# 3. Default SQLite fallback

database_url = os.getenv('DATABASE_URL')
if database_url:
# Render.com provides DATABASE_URL, use it directly
SQLALCHEMY_DATABASE_URI = database_url
else:
# Fallback to SQLALCHEMY_DATABASE_URI or SQLite default
db_uri = os.getenv('SQLALCHEMY_DATABASE_URI')
if db_uri and db_uri.startswith('sqlite:///'):
# For SQLite, ensure database is in a writable location
db_path = db_uri.replace('sqlite:///', '')
if not db_path.startswith('/'):
# If path is not absolute, make it relative to app root
db_uri = f'sqlite:///{BASE_DIR / db_path}'
else:
# For Docker paths like /app/instance/*, keep as-is
db_uri = f'sqlite:///{db_path}'

SQLALCHEMY_DATABASE_URI = db_uri or f'sqlite:///{BASE_DIR / "instance" / "timetrack.db"}'

SQLALCHEMY_TRACK_MODIFICATIONS = False

# Security settings
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key-only-for-development')

# Application settings
DAY_START_TIME = int(os.getenv('DAY_START_TIME', '08:30').split(':')[0]) * 60 + int(os.getenv('DAY_START_TIME', '08:30').split(':')[1])
DAY_END_TIME = int(os.getenv('DAY_END_TIME', '17:00').split(':')[0]) * 60 + int(os.getenv('DAY_END_TIME', '17:00').split(':')[1])
start_time_env = os.getenv('DAY_START_TIME', '08:30')
end_time_env = os.getenv('DAY_END_TIME', '17:00')

DAY_START_TIME = int(start_time_env.split(':')[0]) * 60 + int(start_time_env.split(':')[1])
DAY_END_TIME = int(end_time_env.split(':')[0]) * 60 + int(end_time_env.split(':')[1])
79 changes: 79 additions & 0 deletions app/migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Inline schema migrations that run automatically on startup.

Each migration is idempotent — it checks whether the change is needed before
applying it, so it is safe to run on every startup (including Docker).
"""

import logging

from sqlalchemy import inspect, text

logger = logging.getLogger(__name__)


def run_migrations(db) -> None:
"""Apply any pending schema migrations.

Called from create_app() after db.create_all(), inside an active
app context.
"""
_migrate_add_user_id_to_time_entries(db)


# ---------------------------------------------------------------------------
# Individual migrations
# ---------------------------------------------------------------------------


def _migrate_add_user_id_to_time_entries(db) -> None:
"""Add user_id column to time_entries and assign existing rows to the
default admin user.

Safe for SQLite and PostgreSQL.
"""
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('time_entries')]

if 'user_id' in columns:
return # Already migrated

logger.info('Migration: adding user_id column to time_entries')

db_url = str(db.engine.url)
is_sqlite = db_url.startswith('sqlite')

with db.engine.begin() as conn:
if is_sqlite:
# SQLite does not support NOT NULL ADD COLUMN without a DEFAULT,
# so we add it as nullable first, back-fill, then note the
# constraint lives in SQLAlchemy only (SQLite doesn't enforce it).
conn.execute(text('ALTER TABLE time_entries ADD COLUMN user_id INTEGER'))
else:
# PostgreSQL / other RDBMS — add as nullable, we'll fill it next
conn.execute(text('ALTER TABLE time_entries ADD COLUMN user_id INTEGER REFERENCES users(id)'))

# Assign all orphaned rows to the first admin user, falling back to
# the first user of any kind if no admin exists yet.
row = conn.execute(text('SELECT id FROM users WHERE is_admin = 1 ORDER BY id LIMIT 1')).fetchone()

if row is None:
row = conn.execute(text('SELECT id FROM users ORDER BY id LIMIT 1')).fetchone()

if row is not None:
admin_id = row[0]
conn.execute(
text('UPDATE time_entries SET user_id = :uid WHERE user_id IS NULL'),
{'uid': admin_id},
)
logger.info('Migration: assigned %d rows to user id=%d', _count_updated(conn), admin_id)
else:
# No users exist yet — rows will be assigned when the default
# admin is created and the user logs in for the first time.
# Leave them NULL for now; they are invisible until claimed.
logger.warning('Migration: no users found — existing time_entries left with NULL user_id')


def _count_updated(conn) -> int:
"""Return the number of time_entries that still have a non-NULL user_id."""
result = conn.execute(text('SELECT COUNT(*) FROM time_entries WHERE user_id IS NOT NULL')).fetchone()
return result[0] if result else 0
70 changes: 67 additions & 3 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,99 @@
import datetime
from typing import Optional

from flask_login import UserMixin
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash

db = SQLAlchemy()


class User(UserMixin, db.Model):
"""Model for storing user accounts."""

__tablename__ = 'users'

id = Column(Integer, primary_key=True)
username = Column(String(80), unique=True, nullable=False)
email = Column(String(120), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
user_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.datetime.now(datetime.timezone.utc))
last_login = Column(DateTime, nullable=True)

def __init__(
self,
username: str,
email: str,
password: str,
is_admin: bool = False,
is_active: bool = True,
):
"""Initialize User with proper type hints and password hashing."""
self.username = username
self.email = email
self.set_password(password)
self.is_admin = is_admin
self.user_active = is_active

def set_password(self, password: str) -> None:
"""Hash and set the user's password."""
self.password_hash = generate_password_hash(password)

def check_password(self, password: str) -> bool:
"""Check if the provided password matches the stored hash."""
return check_password_hash(str(self.password_hash), password)

def get_id(self) -> str:
"""Return the user ID as a string for Flask-Login."""
return str(self.id)

@property
def is_active(self) -> bool:
"""Return the user's active status for Flask-Login compatibility."""
return bool(self.user_active)

@property
def role(self) -> str:
"""Return the user's role as a string."""
return 'admin' if bool(self.is_admin) else 'user'

def __repr__(self) -> str:
"""String representation of the user."""
return f'<User {self.username}>'


class TimeEntry(db.Model):
"""Model for storing time tracking entries."""

__tablename__ = 'time_entries'

id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
activity_date = Column(DateTime, nullable=False, default=datetime.datetime.now(datetime.timezone.utc))
from_time = Column(Integer, nullable=False) # Stored in minutes past midnight
to_time = Column(Integer, nullable=False) # Stored in minutes past midnight
activity = Column(String, nullable=True)
time_out = Column(Integer, nullable=True) # Stored in minutes past midnight
time_out = Column(Boolean, nullable=False) # Indicates if the entry is a time-out entry (untracked time)

user = relationship('User', backref='time_entries')

def __init__(
self,
activity_date: datetime.datetime,
from_time: int,
to_time: int,
user_id: int,
activity: Optional[str] = None,
time_out: Optional[int] = None,
time_out: bool = False,
):
"""Initialize TimeEntry with proper type hints for linters."""
self.activity_date = activity_date
self.from_time = from_time
self.to_time = to_time
self.user_id = user_id
self.activity = activity
self.time_out = time_out
Loading