Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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...

35 changes: 32 additions & 3 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,63 @@

from dotenv import load_dotenv
from flask import Flask
from flask_login import LoginManager

from app.config import Config
from app.models import db
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)

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

# 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()
# 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)
Expand Down
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])
64 changes: 61 additions & 3 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,70 @@
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, Integer, String
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."""

Expand All @@ -17,15 +75,15 @@ class TimeEntry(db.Model):
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)

def __init__(
self,
activity_date: datetime.datetime,
from_time: int,
to_time: 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
Expand Down
Loading
Loading