diff --git a/Dockerfile b/Dockerfile index b13c2dc..ce76312 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +# Run the application - use PORT env var for Render.com compatibility +CMD ["sh", "-c", "gunicorn run:app --bind=0.0.0.0:${PORT:-5000}"] \ No newline at end of file diff --git a/README.md b/README.md index baf2818..1ad2623 100644 --- a/README.md +++ b/README.md @@ -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... diff --git a/app/app.py b/app/app.py index 0f6950d..fbb5f21 100644 --- a/app/app.py +++ b/app/app.py @@ -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) diff --git a/app/config.py b/app/config.py index 3e126d3..dd8e9d6 100644 --- a/app/config.py +++ b/app/config.py @@ -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]) \ No newline at end of file + 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]) diff --git a/app/models.py b/app/models.py index 5dbc628..2cee629 100644 --- a/app/models.py +++ b/app/models.py @@ -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'' + + class TimeEntry(db.Model): """Model for storing time tracking entries.""" @@ -17,7 +75,7 @@ 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, @@ -25,7 +83,7 @@ def __init__( 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 diff --git a/app/routes/admin.py b/app/routes/admin.py index b9b1e05..1d60dec 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,9 +1,138 @@ -from flask import Blueprint, render_template +"""Admin routes for administrative functions.""" + +from typing import Any, Callable + +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from app.service.user_service import ( + CreateUserError, + DeleteUserError, + UpdateUserError, + create_user, + get_all_users, + get_user_by_id, + update_user, +) +from app.service.user_service import ( + delete_user as delete_user_service, +) admin_bp = Blueprint('admin', __name__, url_prefix='/admin') +def admin_required(f: Callable[..., Any]) -> Callable[..., Any]: + """Decorator to require admin privileges.""" + + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash('Admin privileges required.', 'error') + return redirect(url_for('home.index')) + return f(*args, **kwargs) + + decorated_function.__name__ = f.__name__ + return decorated_function + + @admin_bp.route('/settings') +@login_required +@admin_required def settings(): """Render the settings page.""" return render_template('settings.html') + + +@admin_bp.route('/users') +@login_required +@admin_required +def user_list(): + """Display list of all users.""" + users = get_all_users() + return render_template('admin/user_list.html', users=users) + + +@admin_bp.route('/users/add', methods=['GET', 'POST']) +@login_required +@admin_required +def add_user(): + """Add a new user.""" + if request.method == 'POST': + username = request.form.get('username', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + is_admin = request.form.get('is_admin') == 'on' + is_active = request.form.get('is_active', 'on') == 'on' + + try: + user = create_user( + username=username, + email=email, + password=password, + is_admin=is_admin, + is_active=is_active, + ) + flash(f'User "{user.username}" created successfully.', 'success') + return redirect(url_for('admin.user_list')) + except CreateUserError as e: + flash(f'Error creating user: {e.message}', 'error') + + return render_template('admin/add_user.html') + + +@admin_bp.route('/users//edit', methods=['GET', 'POST']) +@login_required +@admin_required +def edit_user(user_id: int): + """Edit an existing user.""" + user = get_user_by_id(user_id) + if not user: + flash('User not found.', 'error') + return redirect(url_for('admin.user_list')) + + if request.method == 'POST': + username = request.form.get('username', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + is_admin = request.form.get('is_admin') == 'on' + is_active = request.form.get('is_active') == 'on' + + # Don't allow users to remove their own admin status + if user.id == current_user.id and not is_admin: + flash('You cannot remove your own admin privileges.', 'error') + return render_template('admin/edit_user.html', user=user) + + try: + updated_user = update_user( + user_id=user_id, + username=username, + email=email, + password=password if password else None, + is_admin=is_admin, + is_active=is_active, + ) + flash(f'User "{updated_user.username}" updated successfully.', 'success') + return redirect(url_for('admin.user_list')) + except UpdateUserError as e: + flash(f'Error updating user: {e.message}', 'error') + + return render_template('admin/edit_user.html', user=user) + + +@admin_bp.route('/users//delete', methods=['POST']) +@login_required +@admin_required +def delete_user(user_id: int): + """Delete a user.""" + user = get_user_by_id(user_id) + if not user: + return jsonify({'success': False, 'message': 'User not found'}) + + # Don't allow users to delete themselves + if user.id == current_user.id: + return jsonify({'success': False, 'message': 'You cannot delete your own account'}) + + try: + delete_user_service(user_id) + return jsonify({'success': True, 'message': f'User "{user.username}" deleted successfully'}) + except DeleteUserError as e: + return jsonify({'success': False, 'message': e.message}) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..d1953a4 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,116 @@ +"""Authentication routes for login, logout, and profile management.""" + +import datetime + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from app.models import db +from app.service.user_service import UpdateUserError, authenticate_user, update_user + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """Handle user login.""" + if current_user.is_authenticated: + return redirect(url_for('home.index')) + + if request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + remember = request.form.get('remember') == 'on' + + if not username or not password: + flash('Please enter both username and password.', 'error') + return render_template('auth/login.html') + + user = authenticate_user(username, password) + + if user: + # Update last login time + user.last_login = datetime.datetime.now(datetime.timezone.utc) # type: ignore + db.session.commit() + + login_user(user, remember=remember) + flash(f'Welcome back, {user.username}!', 'success') + + # Redirect to next page or home + next_page = request.args.get('next') + if next_page: + return redirect(next_page) + return redirect(url_for('home.index')) + else: + flash('Invalid username or password.', 'error') + + return render_template('auth/login.html') + + +@auth_bp.route('/logout') +@login_required +def logout(): + """Handle user logout.""" + username = current_user.username + logout_user() + flash(f'Goodbye, {username}!', 'info') + return redirect(url_for('auth.login')) + + +@auth_bp.route('/profile', methods=['GET', 'POST']) +@login_required +def profile(): + """Display and update user profile. + + Allows the authenticated user to update their email address. + """ + if request.method == 'POST': + new_email = request.form.get('email', '').strip() + + # Basic validation + if not new_email: + flash('Email is required.', 'error') + return render_template('auth/profile.html', user=current_user) + + if '@' not in new_email or len(new_email) > 120: + flash('Please enter a valid email address.', 'error') + return render_template('auth/profile.html', user=current_user) + + try: + update_user(user_id=int(current_user.id), email=new_email) + flash('Email updated successfully.', 'success') + return redirect(url_for('auth.profile')) + except UpdateUserError as e: + flash(f'Error updating email: {e.message}', 'error') + + return render_template('auth/profile.html', user=current_user) + + +@auth_bp.route('/change-password', methods=['GET', 'POST']) +@login_required +def change_password(): + """Allow users to change their password.""" + if request.method == 'POST': + current_password = request.form.get('current_password', '') + new_password = request.form.get('new_password', '') + confirm_password = request.form.get('confirm_password', '') + + if not current_user.check_password(current_password): + flash('Current password is incorrect.', 'error') + return render_template('auth/change_password.html') + + if len(new_password) < 6: + flash('New password must be at least 6 characters long.', 'error') + return render_template('auth/change_password.html') + + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return render_template('auth/change_password.html') + + current_user.set_password(new_password) + db.session.commit() + + flash('Password changed successfully.', 'success') + return redirect(url_for('auth.profile')) + + return render_template('auth/change_password.html') diff --git a/app/routes/home.py b/app/routes/home.py index 79e17e8..e89bd20 100644 --- a/app/routes/home.py +++ b/app/routes/home.py @@ -1,6 +1,7 @@ from datetime import datetime from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for +from flask_login import login_required from flask_wtf import FlaskForm from werkzeug.wrappers.response import Response from wtforms import IntegerField, StringField @@ -11,8 +12,10 @@ home_bp = Blueprint('home', __name__) +# region Helper Functions def parse_time_to_minutes(value) -> int: """Converts a time value to minutes past midnight.""" + # Note that value can be an int (minutes), or a string in 'HH:MM' format so no type hint. if value is None: return 0 if isinstance(value, int): @@ -23,7 +26,8 @@ def parse_time_to_minutes(value) -> int: if ':' in value: try: hour, minute = map(int, value.split(':')) - return hour * 60 + minute + result = hour * 60 + minute + return result except (ValueError, IndexError): return 0 return 0 @@ -32,6 +36,8 @@ def parse_time_to_minutes(value) -> int: def format_time_for_display(value) -> str: """Converts a time value to display format (e.g., '8:45 AM').""" minutes = parse_time_to_minutes(value) + # Value is likely in minutes past midnight (int) but we handle other cases in parse_time_to_minutes, + # we don't set a type hint for value for that reason. # Convert minutes past midnight to hours and minutes hours = minutes // 60 @@ -47,7 +53,10 @@ def format_time_for_display(value) -> str: return f'{display_hour}:{mins:02d} {am_pm}' -# Forms +# endregion + + +# region Forms class AddTimeEntryForm(FlaskForm): """Form for creating and editing time entries.""" @@ -58,25 +67,33 @@ class AddTimeEntryForm(FlaskForm): time_out = IntegerField('Time Out', validators=[Optional()]) -# Routes +# endregion + + +# region Routes @home_bp.route('/') +@login_required def index() -> str: + """Default home page showing time entries for a specific date.""" + + # Get date filter from query parameters, default to today date_filter = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) try: operating_date = datetime.strptime(date_filter, '%Y-%m-%d').date() except ValueError: operating_date = datetime.now().date() + # Get entries for the operating date entries = ( TimeEntry.query.filter(db.func.date(TimeEntry.activity_date) == operating_date).order_by('from_time').all() ) - # Add formatted times to each entry for display + # Add formatted times to each time entry (if there are entries) for entry in entries: entry.from_time_display = format_time_for_display(entry.from_time) entry.to_time_display = format_time_for_display(entry.to_time) - # Calculate duration in minutes for display + # Calculate duration in minutes for display for each entry from_minutes = parse_time_to_minutes(entry.from_time) to_minutes = parse_time_to_minutes(entry.to_time) entry.duration_minutes = to_minutes - from_minutes @@ -92,6 +109,7 @@ def index() -> str: @home_bp.route('/entry/', methods=['GET']) +@login_required def get_entry(entry_id: int): """Get a specific time entry by ID.""" entry = TimeEntry.query.get_or_404(entry_id) @@ -126,6 +144,7 @@ def minutes_to_time_string(minutes): @home_bp.route('/entry//delete', methods=['POST']) +@login_required def delete_entry(entry_id: int): """Delete a time entry by ID.""" entry = TimeEntry.query.get_or_404(entry_id) @@ -146,6 +165,7 @@ def delete_entry(entry_id: int): @home_bp.route('/add', methods=['POST']) +@login_required def add_entry() -> Response: """Create or update a time entry.""" form = AddTimeEntryForm() @@ -176,7 +196,6 @@ def add_entry() -> Response: ) db.session.add(entry) flash('Time entry added successfully.', 'success') - db.session.commit() else: for field, errors in form.errors.items(): @@ -191,7 +210,11 @@ def add_entry() -> Response: @home_bp.route('/entries', methods=['GET']) +@login_required def get_entries() -> str: date_filter = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) entries = TimeEntry.query.filter(db.func.date(TimeEntry.activity_date) == date_filter).order_by('from_time').all() return render_template('home/entries.html', entries=entries) + + +# endregion diff --git a/app/routes/reports.py b/app/routes/reports.py index 23ddf1a..f31f25a 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,56 +1,46 @@ """Routes for reports functionality.""" -from datetime import date, timedelta +from datetime import date from flask import Blueprint, render_template, request +from flask_login import login_required -from app.models import TimeEntry, db # Assuming TimeEntry is the model for time entries +from app.service.weekly_report_service import WeeklyReportService reports_bp = Blueprint('reports', __name__, url_prefix='/reports') -def parse_time_to_minutes(value): - """Convert 'HH:MM' string or int to minutes past midnight.""" - if isinstance(value, int): - return value - if isinstance(value, str): - if value.isdigit(): - return int(value) - if ':' in value: - hour, minute = map(int, value.split(':')) - return hour * 60 + minute - raise ValueError(f'Unsupported time format: {value}') - - @reports_bp.route('/weekly', methods=['GET', 'POST']) +@login_required def weekly_report(): """Render the weekly report settings page or generate the report.""" - today = date.today() - last_sunday = today - timedelta(days=today.weekday() + 1) - last_saturday = last_sunday + timedelta(days=6) + service = WeeklyReportService() if request.method == 'POST': - start_date = request.form.get('start_date') - end_date = request.form.get('end_date') - - entries = [ - { - 'date': entry.activity_date.strftime('%Y-%m-%d'), - 'activity': entry.activity, - 'from_time': entry.from_time, - 'to_time': entry.to_time, - 'duration': (parse_time_to_minutes(entry.to_time) - parse_time_to_minutes(entry.from_time)) // 60, - } - for entry in TimeEntry.query.filter( - db.func.date(TimeEntry.activity_date) >= start_date, db.func.date(TimeEntry.activity_date) <= end_date - ) - .order_by(TimeEntry.activity_date) - .all() - ] - return render_template('reports/weekly_report.html', entries=entries, start_date=start_date, end_date=end_date) + start_date_str = request.form.get('start_date') + end_date_str = request.form.get('end_date') + + try: + if start_date_str and end_date_str: + start_date = date.fromisoformat(start_date_str) + end_date = date.fromisoformat(end_date_str) + else: + # If date strings are missing, use default week + start_date, end_date = service.get_default_week_dates() + except (ValueError, TypeError): + # If date parsing fails, use default week + start_date, end_date = service.get_default_week_dates() + + # Generate the report + report_data = service.generate_weekly_report(start_date, end_date) + + return render_template('reports/weekly_report.html', **report_data) + + # GET request - show the settings page + default_start_date, default_end_date = service.get_default_week_dates() return render_template( 'reports/weekly_settings.html', - default_start_date=last_sunday.strftime('%Y-%m-%d'), - default_end_date=last_saturday.strftime('%Y-%m-%d'), + default_start_date=default_start_date.strftime('%Y-%m-%d'), + default_end_date=default_end_date.strftime('%Y-%m-%d'), ) diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..64cb50b --- /dev/null +++ b/app/service/__init__.py @@ -0,0 +1,5 @@ +"""Service modules for TimeTracker application.""" + +from .weekly_report_service import WeeklyReportService + +__all__ = ['WeeklyReportService'] diff --git a/app/service/user_service.py b/app/service/user_service.py new file mode 100644 index 0000000..244c884 --- /dev/null +++ b/app/service/user_service.py @@ -0,0 +1,253 @@ +"""User management service for handling user operations.""" + +import os +from typing import List, Optional + +from flask import current_app +from sqlalchemy.exc import IntegrityError + +from app.models import User, db + + +def create_user( + username: str, + email: str, + password: str, + is_admin: bool = False, + is_active: bool = True, +) -> User: + """Create a new user.""" + try: + # Check if username already exists + existing_username = User.query.filter_by(username=username).first() + if existing_username: + raise CreateUserError('Username already exists') + + # Check if email already exists + existing_email = User.query.filter_by(email=email).first() + if existing_email: + raise CreateUserError('Email already exists') + + # Validate input + if not username or len(username) < 3: + raise CreateUserError('Username must be at least 3 characters long') + + if not email or '@' not in email: + raise CreateUserError('Invalid email address') + + if not password or len(password) < 6: + raise CreateUserError('Password must be at least 6 characters long') + + # Create new user + user = User( + username=username, + email=email, + password=password, + is_admin=is_admin, + is_active=is_active, + ) + + db.session.add(user) + db.session.commit() + + current_app.logger.info(f'Created new user: {username}') + return user + + except CreateUserError: + # Re-raise our custom exception + db.session.rollback() + raise + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f'Database error creating user {username}: {e}') + raise CreateUserError('Database error: User creation failed') from e + except Exception as e: + db.session.rollback() + current_app.logger.error(f'Unexpected error creating user {username}: {e}') + raise CreateUserError('Unexpected error occurred') from e + + +def get_user_by_id(user_id: int) -> Optional[User]: + """Get a user by their ID.""" + return User.query.get(user_id) + + +def get_user_by_username(username: str) -> Optional[User]: + """Get a user by their username.""" + return User.query.filter_by(username=username).first() + + +def get_user_by_email(email: str) -> Optional[User]: + """Get a user by their email.""" + return User.query.filter_by(email=email).first() + + +def get_all_users() -> List[User]: + """Get all users ordered by username.""" + return User.query.order_by(User.username).all() + + +def update_user( + user_id: int, + username: Optional[str] = None, + email: Optional[str] = None, + password: Optional[str] = None, + is_admin: Optional[bool] = None, + is_active: Optional[bool] = None, +) -> User: + """Update user detail""" + try: + current_app.logger.debug( + f'Updating user {user_id} with username={username}, email={email}, ' + f'is_admin={is_admin}, is_active={is_active}' + ) + + user = User.query.get(user_id) + if not user: + raise UpdateUserError('User not found', user_id) + + # Check for conflicts if updating username or email + if username and username != user.username: + existing = User.query.filter_by(username=username).first() + if existing: + raise UpdateUserError('Username already exists', user_id) + user.username = username + + if email and email != user.email: + existing = User.query.filter_by(email=email).first() + if existing: + raise UpdateUserError('Email already exists', user_id) + user.email = email + + if password: + if len(password) < 6: + raise UpdateUserError('Password must be at least 6 characters long', user_id) + user.set_password(password) + + if is_admin is not None: + user.is_admin = is_admin + + if is_active is not None: + user.user_active = is_active + + db.session.commit() + + current_app.logger.info(f'Updated user: {user.username}') + return user + + except UpdateUserError: + # Re-raise our custom exception + db.session.rollback() + raise + except IntegrityError as e: + db.session.rollback() + current_app.logger.error(f'Database error updating user {user_id}: {e}') + raise UpdateUserError('Database error: User update failed', user_id) from e + except Exception as e: + db.session.rollback() + current_app.logger.error(f'Unexpected error updating user {user_id}: {e}') + raise UpdateUserError('Unexpected error occurred', user_id) from e + + +def delete_user(user_id: int) -> None: + """ + Delete a user. + + Args: + user_id: ID of the user to delete + + Raises: + DeleteUserError: If user deletion fails for any reason + """ + try: + user = User.query.get(user_id) + if not user: + raise DeleteUserError('User not found', user_id) + + # Don't allow deletion of the last admin user + if user.is_admin: + admin_count = User.query.filter_by(is_admin=True).count() + if admin_count <= 1: + raise DeleteUserError('Cannot delete the last admin user', user_id) + + username = user.username + db.session.delete(user) + db.session.commit() + + current_app.logger.info(f'Deleted user: {username}') + + except DeleteUserError: + # Re-raise our custom exception + db.session.rollback() + raise + except Exception as e: + db.session.rollback() + current_app.logger.error(f'Error deleting user {user_id}: {e}') + raise DeleteUserError('Error deleting user', user_id) from e + + +def authenticate_user(username: str, password: str) -> Optional[User]: + """Authenticate a user with username/email and password.""" + # Try to find user by username or email + user = User.query.filter(db.or_(User.username == username, User.email == username)).first() + + if user and user.is_active and user.check_password(password): + return user + + return None + + +def create_default_admin() -> Optional[User]: + """Create a default admin user if no users exist.""" + user_count = User.query.count() + if user_count == 0: + # Read default admin settings from environment variables + default_username = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin') + default_email = os.getenv('DEFAULT_ADMIN_EMAIL', 'admin@timetracker.local') + default_password = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123') + + admin_user = User( + username=default_username, + email=default_email, + password=default_password, + is_admin=True, + is_active=True, + ) + db.session.add(admin_user) + db.session.commit() + current_app.logger.info(f'Created default admin user: {default_username}') + return admin_user + return None + + +# region Custom Exceptions +class UpdateUserError(Exception): + """Custom exception for user update operations.""" + + def __init__(self, message: str, user_id: Optional[int] = None): + """Initialize the exception with a message and optional user ID.""" + self.message = message + self.user_id = user_id + super().__init__(self.message) + + +class CreateUserError(Exception): + """Custom exception for user creation operations.""" + + def __init__(self, message: str): + """Initialize the exception with a message.""" + self.message = message + super().__init__(self.message) + + +class DeleteUserError(Exception): + """Custom exception for user deletion operations.""" + + def __init__(self, message: str, user_id: Optional[int] = None): + """Initialize the exception with a message and optional user ID.""" + self.message = message + self.user_id = user_id + super().__init__(self.message) + + +# endregion diff --git a/app/service/weekly_report_service.py b/app/service/weekly_report_service.py new file mode 100644 index 0000000..9ecca39 --- /dev/null +++ b/app/service/weekly_report_service.py @@ -0,0 +1,241 @@ +"""Weekly report service for generating comprehensive time tracking reports.""" + +from datetime import date, timedelta +from typing import Dict, List, Optional + +from sqlalchemy import and_, func + +from app.models import TimeEntry + + +class WeeklyReportService: + """Service for generating weekly time tracking reports.""" + + def __init__(self): + """Initialize the weekly report service.""" + pass + + def parse_time_to_minutes(self, value) -> int: + """Convert time value to minutes past midnight.""" + if value is None: + return 0 + if isinstance(value, int): + return value + if isinstance(value, str): + if value.isdigit(): + return int(value) + if ':' in value: + try: + hour, minute = map(int, value.split(':')) + return hour * 60 + minute + except (ValueError, IndexError): + return 0 + return 0 + + def format_time_for_display(self, value) -> str: + """Convert time value to display format (e.g., '8:45 AM').""" + minutes = self.parse_time_to_minutes(value) + + # Convert minutes past midnight to hours and minutes + hours = minutes // 60 + mins = minutes % 60 + + # Convert to 12-hour format + display_hour = hours % 12 + if display_hour == 0: + display_hour = 12 + + am_pm = 'AM' if hours < 12 else 'PM' + + return f'{display_hour}:{mins:02d} {am_pm}' + + def format_duration(self, minutes: int) -> str: + """Format duration in minutes to hours and minutes display.""" + if minutes < 0: + return '0h 0m' + + hours = minutes // 60 + mins = minutes % 60 + + if hours > 0: + return f'{hours}h {mins}m' + else: + return f'{mins}m' + + def get_week_bounds(self, target_date: Optional[date] = None) -> tuple[date, date]: + """Get the start (Sunday) and end (Saturday) dates for a week.""" + if target_date is None: + target_date = date.today() + + # Calculate days since Sunday (0=Sunday, 1=Monday, etc.) + days_since_sunday = (target_date.weekday() + 1) % 7 + + # Get Sunday of this week + sunday = target_date - timedelta(days=days_since_sunday) + + # Get Saturday of this week + saturday = sunday + timedelta(days=6) + + return sunday, saturday + + def get_entries_for_period(self, start_date: date, end_date: date) -> List[TimeEntry]: + """Get all time entries for the specified date range.""" + return ( + TimeEntry.query.filter( + and_(func.date(TimeEntry.activity_date) >= start_date, func.date(TimeEntry.activity_date) <= end_date) + ) + .order_by(TimeEntry.activity_date, TimeEntry.from_time) + .all() + ) + + def calculate_daily_totals(self, entries: List[TimeEntry]) -> Dict[str, Dict]: + """Calculate daily totals and statistics.""" + daily_totals = {} + + for entry in entries: + entry_date = entry.activity_date.date() + date_str = entry_date.strftime('%Y-%m-%d') + + if date_str not in daily_totals: + daily_totals[date_str] = { + 'date': entry_date, + 'total_minutes': 0, + 'activities': {}, + 'entries': [], + 'first_start': None, + 'last_end': None, + } + + # Calculate duration + from_minutes = self.parse_time_to_minutes(entry.from_time) + to_minutes = self.parse_time_to_minutes(entry.to_time) + duration = to_minutes - from_minutes + + # Track daily totals + daily_totals[date_str]['total_minutes'] += duration + + # Track activity totals + activity = entry.activity or 'Unknown' + if activity not in daily_totals[date_str]['activities']: + daily_totals[date_str]['activities'][activity] = 0 + daily_totals[date_str]['activities'][activity] += duration + + # Track first start and last end times + if daily_totals[date_str]['first_start'] is None or from_minutes < daily_totals[date_str]['first_start']: + daily_totals[date_str]['first_start'] = from_minutes + + if daily_totals[date_str]['last_end'] is None or to_minutes > daily_totals[date_str]['last_end']: + daily_totals[date_str]['last_end'] = to_minutes + + # Add formatted entry + daily_totals[date_str]['entries'].append( + { + 'id': entry.id, + 'activity': activity, + 'from_time': from_minutes, + 'to_time': to_minutes, + 'from_time_display': self.format_time_for_display(from_minutes), + 'to_time_display': self.format_time_for_display(to_minutes), + 'duration_minutes': duration, + 'duration_display': self.format_duration(duration), + 'time_out': bool(entry.time_out), + } + ) + + return daily_totals + + def calculate_weekly_summary(self, daily_totals: Dict) -> Dict: + """Calculate weekly summary statistics.""" + total_minutes = sum(day['total_minutes'] for day in daily_totals.values()) + total_days_worked = len([day for day in daily_totals.values() if day['total_minutes'] > 0]) + + # Calculate activity totals across the week + activity_totals = {} + for day in daily_totals.values(): + for activity, minutes in day['activities'].items(): + if activity not in activity_totals: + activity_totals[activity] = 0 + activity_totals[activity] += minutes + + # Sort activities by total time + sorted_activities = sorted(activity_totals.items(), key=lambda x: x[1], reverse=True) + + # Calculate average daily hours + avg_daily_minutes = total_minutes / max(total_days_worked, 1) + + return { + 'total_minutes': total_minutes, + 'total_hours_display': self.format_duration(total_minutes), + 'total_days_worked': total_days_worked, + 'avg_daily_minutes': avg_daily_minutes, + 'avg_daily_display': self.format_duration(int(avg_daily_minutes)), + 'activity_totals': activity_totals, + 'sorted_activities': sorted_activities, + } + + def generate_weekly_report(self, start_date: date, end_date: date) -> Dict: + """Generate a comprehensive weekly report.""" + # Get all entries for the period + entries = self.get_entries_for_period(start_date, end_date) + + # Calculate daily totals + daily_totals = self.calculate_daily_totals(entries) + + # Calculate weekly summary + weekly_summary = self.calculate_weekly_summary(daily_totals) + + # Generate a complete day list (including days with no entries) + current_date = start_date + complete_daily_data = [] + + while current_date <= end_date: + date_str = current_date.strftime('%Y-%m-%d') + day_name = current_date.strftime('%A') + + if date_str in daily_totals: + day_data = daily_totals[date_str].copy() + else: + day_data = { + 'date': current_date, + 'total_minutes': 0, + 'activities': {}, + 'entries': [], + 'first_start': None, + 'last_end': None, + } + + # Add formatted data + day_data.update( + { + 'date_str': date_str, + 'day_name': day_name, + 'total_hours_display': self.format_duration(day_data['total_minutes']), + 'first_start_display': ( + self.format_time_for_display(day_data['first_start']) + if day_data['first_start'] is not None + else '-' + ), + 'last_end_display': ( + self.format_time_for_display(day_data['last_end']) if day_data['last_end'] is not None else '-' + ), + } + ) + + complete_daily_data.append(day_data) + current_date += timedelta(days=1) + + return { + 'start_date': start_date, + 'end_date': end_date, + 'start_date_str': start_date.strftime('%Y-%m-%d'), + 'end_date_str': end_date.strftime('%Y-%m-%d'), + 'start_date_formatted': start_date.strftime('%B %d, %Y'), + 'end_date_formatted': end_date.strftime('%B %d, %Y'), + 'daily_data': complete_daily_data, + 'weekly_summary': weekly_summary, + 'total_entries': len(entries), + } + + def get_default_week_dates(self) -> tuple[date, date]: + """Get default start and end dates for the current week.""" + return self.get_week_bounds() diff --git a/app/templates/admin/add_user.html b/app/templates/admin/add_user.html new file mode 100644 index 0000000..55b5371 --- /dev/null +++ b/app/templates/admin/add_user.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block content %} +

add user

+
+
+
+
+
+
+
+
+ + +
At least 3 characters, unique
+
+
+
+
+ + +
Valid email address, unique
+
+
+
+ +
+ + +
At least 6 characters
+
+ +
+
+
+ + +
Allows access to admin functions
+
+
+
+
+ + +
User can log in
+
+
+
+ +
+ + cancel + + +
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/edit_user.html b/app/templates/admin/edit_user.html new file mode 100644 index 0000000..f208ebc --- /dev/null +++ b/app/templates/admin/edit_user.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} + +{% block content %} +

edit user

+
+
+
+
+
+
+
+
+ + +
At least 3 characters, unique
+
+
+
+
+ + +
Valid email address, unique
+
+
+
+ +
+ + +
Leave blank to keep current password
+
+ +
+
+
+ + + {% if user.id == current_user.id %} +
You cannot change your own admin status
+ {% else %} +
Allows access to admin functions
+ {% endif %} +
+
+
+
+ + +
User can log in
+
+
+
+ +
+
+
+ Created: {{ user.created_at.strftime('%Y-%m-%d %H:%M') }} +
+
+ Last Login: + {% if user.last_login %} + {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} + {% else %} + Never + {% endif %} +
+
+
+ +
+ + cancel + + +
+
+
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/user_list.html b/app/templates/admin/user_list.html new file mode 100644 index 0000000..3650fe6 --- /dev/null +++ b/app/templates/admin/user_list.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} + +{% block content %} +

user management

+
+
+
+
+
+
users
+ + add user + +
+ + {% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
username email role status created last loginactions
+ {{ user.username }} + {% if user.id == current_user.id %} + You + {% endif %} + {{ user.email }} + {% if user.is_admin %} + Admin + {% else %} + User + {% endif %} + + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ user.created_at.strftime('%Y-%m-%d') }} + {% if user.last_login %} + {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} + {% else %} + Never + {% endif %} + + + + + {% if user.id != current_user.id %} + + {% endif %} +
+
+ {% else %} +
+ no users found +
+ {% endif %} +
+
+ + +
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 0000000..63891eb --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} + +{% block title %}Change Password{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Change Password +

+
+
+
+
+ + +
+ +
+ + +
Password must be at least 6 characters long.
+
+ +
+ + +
+ +
+ + + Back to Profile + +
+
+
+
+ + +
+
+
+ Password Requirements +
+
    +
  • Must be at least 6 characters long
  • +
  • Current password is required for verification
  • +
  • New password and confirmation must match
  • +
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..959e94f --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block content %} +

login

+
+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ +
+ Default admin: username=admin, password=admin123 +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..e799524 --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block title %}Profile{% endblock %} + +{% block content %} +
+
+
+
+
+

+ User Profile +

+
+
+
+
+
Account Information
+
+
+ +

{{ user.username }}

+
+
+ + +
Update your email address. Must be unique and valid.
+
+ +
+
+ +

+ {% if user.is_admin %} + Administrator + {% else %} + User + {% endif %} +

+
+
+ +

+ {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} +

+
+
+
+
Account Activity
+
+ +

+ {{ user.created_at.strftime('%B %d, %Y at %I:%M %p') if user.created_at else 'N/A' + }} +

+
+
+ +

+ {{ user.last_login.strftime('%B %d, %Y at %I:%M %p') if user.last_login else 'Never' + }} +

+
+
+
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/header.html b/app/templates/header.html index 66fe863..a9ea5a0 100644 --- a/app/templates/header.html +++ b/app/templates/header.html @@ -6,10 +6,12 @@ diff --git a/app/templates/home/main_entry.html b/app/templates/home/main_entry.html index 7a5bbe0..1a15b37 100644 --- a/app/templates/home/main_entry.html +++ b/app/templates/home/main_entry.html @@ -16,15 +16,15 @@

enter | edit

-
+
from
- + {% for hour in range(6, 20) %} {% for minute in [0, 15, 30, 45] %} {% set display_hour = hour % 12 %} @@ -44,8 +44,8 @@
from
to
- + {% for hour in range(6, 20) %} {% for minute in [0, 15, 30, 45] %} {% set display_hour = hour % 12 %} @@ -59,14 +59,14 @@
to
value="{{ hour }}:{{ '%02d' % minute }}">{{ display_hour }}:{{ '%02d' % minute }} {{ am_pm }} {% endfor %} - {% endfor %}
-
+
activity
- + {% for option in activity_options|default([]) %} diff --git a/app/templates/reports/weekly_report.html b/app/templates/reports/weekly_report.html index 5052b20..936d7fa 100644 --- a/app/templates/reports/weekly_report.html +++ b/app/templates/reports/weekly_report.html @@ -1,31 +1,205 @@ {% extends "base.html" %} {% block content %} -

Weekly Report

-

Report for {{ start_date }} to {{ end_date }}

- - - - - - - - - - - - {% for entry in entries %} - - - - - - - - {% endfor %} - -
DateActivityStart TimeEnd TimeDuration (hours)
{{ entry.date }}{{ entry.activity }}{% if ':' in entry.from_time|string %}{{ entry.from_time }}{% else %}{{ (entry.from_time // - 60)|string|zfill(2) }}:{{ (entry.from_time % 60)|string|zfill(2) }}{% endif %}{% if ':' in entry.to_time|string %}{{ entry.to_time }}{% else %}{{ (entry.to_time // - 60)|string|zfill(2) }}:{{ (entry.to_time % 60)|string|zfill(2) }}{% endif %}{{ entry.duration }}
- +

weekly report

+
+
+

Report for {{ start_date_formatted }} to {{ end_date_formatted }}

+ + +
+
+
weekly summary
+
+
+
+

{{ weekly_summary.total_hours_display }}

+ Total Time +
+
+
+
+

{{ weekly_summary.total_days_worked }}

+ Days Worked +
+
+
+
+

{{ weekly_summary.avg_daily_display }}

+ Avg Per Day +
+
+
+
+

{{ total_entries }}

+ Total Entries +
+
+
+
+
+ + + {% if weekly_summary.sorted_activities %} +
+
+
+
+
activity breakdown
+ {% for activity, minutes in weekly_summary.sorted_activities %} +
+ {{ activity }} + {{ (minutes // 60) }}h {{ (minutes % 60) }}m +
+
+
+
+ {% endfor %} +
+
+
+
+
+
+
daily overview
+ {% for day in daily_data %} +
+ {{ day.day_name }} + {{ day.date_str }} + + {{ day.total_hours_display }} + +
+ {% endfor %} +
+
+
+
+ {% endif %} + +
+
+
+
daily details
+ +
+ + {% for day in daily_data %} +
+
+
+ {{ day.day_name }}, {{ day.date.strftime('%B %d, %Y') }} + {% if day.total_minutes == 0 %} + No entries + {% endif %} +
+ {% if day.total_minutes > 0 %} +
+ {{ day.first_start_display }} - {{ day.last_end_display }} + ({{ day.total_hours_display }}) +
+ {% endif %} +
+ + {% if day.entries %} +
+ + + + + + + + + + + + {% for entry in day.entries %} + + + + + + + + {% endfor %} + +
activity start time end time duration time-out
{{ entry.activity }}{{ entry.from_time_display }}{{ entry.to_time_display }}{{ entry.duration_display }} +
+ +
+
+
+ {% else %} +
+ no time entries for this day +
+ {% endif %} +
+ {% if not loop.last %} +
{% endif %} + {% endfor %} +
+
+ + + +
+
+ + + {% endblock %} \ No newline at end of file diff --git a/app/templates/reports/weekly_settings.html b/app/templates/reports/weekly_settings.html index dcbd35d..30f5926 100644 --- a/app/templates/reports/weekly_settings.html +++ b/app/templates/reports/weekly_settings.html @@ -1,20 +1,165 @@ {% extends "base.html" %} {% block content %} -

weekly report

-
-
-
- - -
-
- - +

weekly report generator

+
+
+

Generate comprehensive time tracking reports for any date range

+ +
+
+ +
+
+
+ + +
First day of the report period
+
+
+
+
+ + +
Last day of the report period
+
+
+
+ +
+
+
+
+
+
+ what's included +
+
    +
  • Daily time entries with start/end times and activities
  • +
  • Weekly summary with total hours and averages
  • +
  • Activity breakdown with time distribution
  • +
  • Daily totals and working patterns
  • +
  • Print-friendly format for record keeping
  • +
+
+
+
+
+ +
+ + back to home + +
+ + + +
+
+ +
-
- - Cancel - +
+ + + + + {% endblock %} \ No newline at end of file diff --git a/build_current.py b/build_current.py new file mode 100644 index 0000000..4831829 --- /dev/null +++ b/build_current.py @@ -0,0 +1,20 @@ +# Description: Build Docker image with current version (without bumping) +import os +import subprocess + +import toml + +# Read the current version from pyproject.toml +with open('pyproject.toml', 'r') as f: + pyproject = toml.load(f) + version = pyproject['project']['version'] + +print(f'Building Docker image with current version: {version}') + +# Set VERSION_TAG environment variable and build the docker image +env = os.environ.copy() +env['VERSION_TAG'] = version +subprocess.run(['docker', 'compose', 'build'], env=env, check=True) + +print(f'\nBuild complete! Image tagged as: time-tracker:{version}') +print('To start the container, run: docker compose up -d') diff --git a/build_image.py b/build_image.py index c34e67f..91a162e 100644 --- a/build_image.py +++ b/build_image.py @@ -1,24 +1,23 @@ # Description: This script is used to update the version in pyproject.toml and generate the requirements.txt file. import os import subprocess + import toml # Generate the requirements.txt file, excluding dev dependencies subprocess.run(['uv', 'pip', 'freeze', '>', 'requirements.txt'], shell=True) # Update the version in pyproject.toml -with open('pyproject.toml') as f: - data = toml.load(f) - version = data['project']['version'] - version_parts = version.split('.') - version_parts[-1] = str(int(version_parts[-1]) + 1) - new_version = '.'.join(version_parts) - os.environ['VERSION_TAG'] = new_version - data['project']['version'] = new_version +subprocess.run(['uv', 'version', '--bump', 'patch']) -with open('pyproject.toml', 'w') as f: - toml.dump(data, f) +# Read the updated version from pyproject.toml +with open('pyproject.toml', 'r') as f: + pyproject = toml.load(f) + version = pyproject['project']['version'] -# Build the docker image -subprocess.run(['docker', 'compose', 'up', '--build', '-d'], check=True) +print(f'Building Docker image with version: {version}') +# Set VERSION_TAG environment variable and build the docker image +env = os.environ.copy() +env['VERSION_TAG'] = version +subprocess.run(['docker', 'compose', 'up', '--build', '-d'], env=env, check=True) diff --git a/compose.yaml b/compose.yaml index b0dede6..1942ba4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,9 +3,11 @@ services: server: build: args: + # VERSION_TAG is set by build_image.py from pyproject.toml version VERSION_TAG: ${VERSION_TAG:-latest} context: . container_name: time-tracker + # Image will be tagged with version from pyproject.toml when built via build_image.py image: time-tracker:${VERSION_TAG:-latest} volumes: - c:/temp/time-tracker:/app/data diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md new file mode 100644 index 0000000..e509626 --- /dev/null +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -0,0 +1,103 @@ +# Environment Variables Documentation + +This document describes the environment variables used by the TimeTracker application. + +## Database Configuration + +### `DATABASE_URL` (Recommended) +- **Description**: Primary database connection string (Render.com standard) +- **Format**: `protocol://username:password@host:port/database` +- **Examples**: + - PostgreSQL: `DATABASE_URL=postgresql://user:pass@localhost:5432/timetracker_db` + - SQLite: `DATABASE_URL=sqlite:///instance/timetrack.db` + +### `SQLALCHEMY_DATABASE_URI` (Legacy) +- **Description**: Alternative database connection string for backward compatibility +- **Note**: `DATABASE_URL` takes precedence if both are set +- **Example**: `SQLALCHEMY_DATABASE_URI=sqlite:///instance/timetrack.db` + +## Default Admin User Settings + +When the application starts and no users exist in the database, it will automatically create a default admin user. The credentials for this user can be configured using the following environment variables: + +### `DEFAULT_ADMIN_USERNAME` +- **Description**: Username for the default admin user +- **Example**: `DEFAULT_ADMIN_USERNAME=myadmin` + +### `DEFAULT_ADMIN_EMAIL` +- **Description**: Email address for the default admin user +- **Example**: `DEFAULT_ADMIN_EMAIL=admin@mycompany.com` + +### `DEFAULT_ADMIN_PASSWORD` +- **Description**: Password for the default admin user +- **Example**: `DEFAULT_ADMIN_PASSWORD=mySecurePassword123!` + +## Application Settings + +### `SECRET_KEY` +- **Description**: Flask secret key for session management and security +- **Example**: `SECRET_KEY=your-secret-key-here` +- **Note**: Use a strong, random value in production + +### `DAY_START_TIME` +- **Description**: Default start time for work day (24-hour format) +- **Example**: `DAY_START_TIME=08:30` + +### `DAY_END_TIME` +- **Description**: Default end time for work day (24-hour format) +- **Example**: `DAY_END_TIME=17:00` + +## Security Considerations + +- **Change default credentials**: Always change the default admin credentials in production environments +- **Use strong passwords**: Ensure the default admin password meets your security requirements +- **Environment protection**: Keep your `.env` file secure and never commit it to version control +- **Database security**: Use strong database passwords and restrict access + +## Example .env Configuration + +### For Local Development (SQLite) +```env +# Database +DATABASE_URL=sqlite:///instance/timetrack.db + +# Security +SECRET_KEY=dev-key-only-for-development + +# Application Settings +DAY_START_TIME=08:30 +DAY_END_TIME=17:00 + +# Default Admin User +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_EMAIL=admin@timetracker.local +DEFAULT_ADMIN_PASSWORD=admin123 +``` + +### For Production (PostgreSQL) +```env +# Database +DATABASE_URL=postgresql://username:password@host:port/database + +# Security +SECRET_KEY= + +# Application Settings +DAY_START_TIME=08:30 +DAY_END_TIME=17:00 + +# Default Admin User +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_EMAIL=admin@yourcompany.com +DEFAULT_ADMIN_PASSWORD= +``` + +## Usage + +These environment variables are used when: +1. The application starts +2. Database connection is established +3. No users exist in the database (for admin user creation) +4. The `SKIP_DEFAULT_ADMIN` environment variable is not set to `True` + +Once a user is created, the admin user variables have no effect unless the database is reset. diff --git a/README.Docker.md b/docs/README.Docker.md similarity index 100% rename from README.Docker.md rename to docs/README.Docker.md diff --git a/docs/README.PostgreSQL.md b/docs/README.PostgreSQL.md new file mode 100644 index 0000000..883fb11 --- /dev/null +++ b/docs/README.PostgreSQL.md @@ -0,0 +1,106 @@ +# Local PostgreSQL Setup + +## Prerequisites +- PostgreSQL installed locally +- Python environment with psycopg2-binary + +## Setup Steps + +### 1. Install PostgreSQL +Download and install PostgreSQL from https://www.postgresql.org/download/ + +### 2. Create Database +```powershell +# Connect to PostgreSQL as superuser +psql -U postgres + +# Create database and user +CREATE DATABASE timetracker_db; +CREATE USER timetracker WITH PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE timetracker_db TO timetracker; + +# Exit psql +\q +``` + +### 3. Update Environment Variables +Update your `.env` file: +```env +DATABASE_URL=postgresql://timetracker:your_password@localhost:5432/timetracker_db +``` + +### 4. Run Application +```powershell +# Install dependencies +uv sync + +# Run the application +uv run python run.py +``` + +## Testing Database Connection + +```python +# Test script to verify PostgreSQL connection +import os +from sqlalchemy import create_engine, text + +database_url = os.getenv('DATABASE_URL') +if database_url: + engine = create_engine(database_url) + with engine.connect() as conn: + result = conn.execute(text("SELECT version()")) + print("PostgreSQL version:", result.fetchone()[0]) + print("✅ Database connection successful!") +else: + print("❌ DATABASE_URL not found in environment variables") +``` + +## Common Issues + +### Connection Refused +- Ensure PostgreSQL service is running +- Check host and port in DATABASE_URL +- Verify firewall settings + +### Authentication Failed +- Check username and password in DATABASE_URL +- Verify user permissions in PostgreSQL + +### Database Not Found +- Ensure database exists: `CREATE DATABASE timetracker_db;` +- Check database name in DATABASE_URL + +## Docker Development + +For Docker-based local development with PostgreSQL: + +```yaml +# docker-compose.dev.yml +version: '3.8' +services: + db: + image: postgres:15 + environment: + POSTGRES_DB: timetracker_db + POSTGRES_USER: timetracker + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + app: + build: . + ports: + - "5000:5000" + environment: + DATABASE_URL: postgresql://timetracker:password@db:5432/timetracker_db + depends_on: + - db + +volumes: + postgres_data: +``` + +Run with: `docker-compose -f docker-compose.dev.yml up` diff --git a/docs/README.Render.md b/docs/README.Render.md new file mode 100644 index 0000000..ccafeef --- /dev/null +++ b/docs/README.Render.md @@ -0,0 +1,125 @@ +# Render.com Deployment Guide (PostgreSQL) + +## Prerequisites +- Render.com account +- GitHub repository with your TimeTracker code + +## Deployment Options + +### Option 1: Using render.yaml (Recommended) + +The `render.yaml` file is pre-configured to create both a web service and PostgreSQL database. + +1. **Push your code** to GitHub including the `render.yaml` file +2. **Connect to Render**: + - Go to [Render Dashboard](https://dashboard.render.com) + - Click "New" → "Blueprint" + - Connect your GitHub repository + - Render will automatically: + - Create a PostgreSQL database (`timetracker-db`) + - Create a web service (`timetracker`) + - Link them with the `DATABASE_URL` environment variable + +### Option 2: Manual Configuration + +#### Step 1: Create PostgreSQL Database +1. **Create Database**: + - Go to Render Dashboard + - Click "New" → "PostgreSQL" + - **Name**: `timetracker-db` + - **Database**: `timetracker` + - **User**: `timetracker` + - **Plan**: `Starter` (free tier) + +#### Step 2: Create Web Service +1. **Create Web Service**: + - Go to Render Dashboard + - Click "New" → "Web Service" + - Connect your GitHub repository + +2. **Configure Service**: + - **Name**: `timetracker` + - **Runtime**: `Docker` + - **Build Command**: (leave empty) + - **Start Command**: (leave empty - uses Dockerfile CMD) + +3. **Environment Variables**: + ``` + SECRET_KEY= + DATABASE_URL= + DAY_START_TIME=08:30 + DAY_END_TIME=17:00 + DEFAULT_ADMIN_USERNAME=admin + DEFAULT_ADMIN_EMAIL=admin@yourdomain.com + DEFAULT_ADMIN_PASSWORD= + ``` + +## Database Configuration + +### Local Development +Update your `.env` file for local PostgreSQL: +```env +DATABASE_URL=postgresql://username:password@localhost:5432/timetracker_db +``` + +Or use SQLite for local development: +```env +DATABASE_URL=sqlite:///instance/timetrack.db +``` + +### Environment Variables Priority +The application checks for database configuration in this order: +1. `DATABASE_URL` (Render.com standard, recommended) +2. `SQLALCHEMY_DATABASE_URI` (legacy support) +3. SQLite fallback (development only) + +## Important Notes + +### Database Persistence +✅ **PostgreSQL**: Persistent storage across deployments +- Data is preserved during app restarts and deployments +- Automatic backups available on paid plans +- Better performance for production workloads + +### Environment Variables +- `SECRET_KEY`: Use Render's "Generate Value" feature for security +- `DATABASE_URL`: Automatically provided when linking PostgreSQL service +- `DEFAULT_ADMIN_PASSWORD`: Use Render's "Generate Value" or set a strong password + +### First Run +- Database tables are created automatically on first run +- Default admin user is created based on environment variables +- Check logs to confirm successful initialization + +### Health Checks +The app responds to health checks at the root path `/` + +### Logs +View application logs in the Render dashboard under your service's "Logs" tab + +## Deployment Commands + +```bash +# Build and test locally first +docker build -t timetracker . +docker run -p 5000:5000 -e PORT=5000 timetracker + +# Push to GitHub to trigger Render deployment +git add . +git commit -m "Deploy to Render" +git push origin main +``` + +## Troubleshooting + +### Common Issues: +1. **Port binding errors**: Ensure Dockerfile uses `${PORT:-5000}` +2. **Database not found**: Check SQLALCHEMY_DATABASE_URI path +3. **Permission errors**: Verify directory permissions in Dockerfile +4. **Environment variables**: Check Render dashboard configuration + +### Debug Steps: +1. Check Render service logs +2. Verify environment variables in Render dashboard +3. Test database connectivity +4. Confirm port configuration diff --git a/pyproject.toml b/pyproject.toml index 5cbe561..4f15298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,35 @@ [project] name = "timetracker" -version = "0.1.31" +version = "0.1.36" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" -dependencies = [ "flask>=3.1.0", "flask-bootstrap>=3.3.7.1", "flask-sqlalchemy>=3.1.1", "flask-wtf>=1.2.2", "gunicorn>=23.0.0", "python-dotenv>=1.0.1", "sqlalchemy>=2.0.37",] +dependencies = [ + "flask>=3.1.0", + "flask-bootstrap>=3.3.7.1", + "flask-login>=0.6.3", + "flask-sqlalchemy>=3.1.1", + "flask-wtf>=1.2.2", + "gunicorn>=23.0.0", + "psycopg2-binary>=2.9.10", + "python-dotenv>=1.0.1", + "sqlalchemy>=2.0.37", +] [dependency-groups] -dev = [ "pytest>=8.3.4", "pytest-cov>=6.2.1", "ruff>=0.12.3", "toml>=0.10.2", "ty>=0.0.1a14",] +dev = [ + "pytest>=8.3.4", + "pytest-cov>=6.2.1", + "ruff>=0.12.3", + "toml>=0.10.2", + "ty>=0.0.1a14", +] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] [project.license] file = "LICENCE.md" -[tool.ruff] -line-length = 120 -select = [ "E", "F", "I",] -[tool.ruff.format] -quote-style = "single" diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..f8c91d5 --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +services: + - type: web + name: timetracker + runtime: docker + plan: starter + envVars: + - key: SECRET_KEY + generateValue: true + - key: DATABASE_URL + fromDatabase: + name: timetracker-db + property: connectionString + - key: DAY_START_TIME + value: "08:30" + - key: DAY_END_TIME + value: "17:00" + - key: DEFAULT_ADMIN_USERNAME + value: admin + - key: DEFAULT_ADMIN_EMAIL + value: admin@render.app + - key: DEFAULT_ADMIN_PASSWORD + generateValue: true + buildCommand: "" + startCommand: "" + healthCheckPath: / + autoDeploy: true + + - type: pserv + name: timetracker-db + plan: starter diff --git a/requirements.txt b/requirements.txt index 5a6ba50..c6951f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ coverage==7.9.2 dominate==2.9.1 flask==3.1.1 flask-bootstrap==3.3.7.1 +flask-login==0.6.3 flask-sqlalchemy==3.1.1 flask-wtf==1.2.2 greenlet==3.2.3 @@ -15,6 +16,7 @@ jinja2==3.1.6 markupsafe==3.0.2 packaging==25.0 pluggy==1.6.0 +psycopg2-binary==2.9.10 pygments==2.19.2 pytest==8.4.1 pytest-cov==6.2.1 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4a7fbbf --- /dev/null +++ b/ruff.toml @@ -0,0 +1,15 @@ +#:schema = "https://json.schemastore.org/ruff.json"\ + +line-length = 120 +# Bigger than the recomendation in pep 8 but they are focused on having multiple +# windows open side by side, which I rarely do (might matter for PR's?) + +[lint] +select = ["E", "F", "I"] +# E = Pycodestyle errors +# F = Pyflakes rules (unused imports, unused-variables etc.) +# I = Import Sorting, missing imports + +[format] +quote-style = "single" +# I use singles for regular strings, tripple double are still used for docstrings. diff --git a/run.py b/run.py index 33e0c45..f48b670 100644 --- a/run.py +++ b/run.py @@ -12,33 +12,37 @@ data_dir = Path('/app/data') instance_dir = Path('/app/instance') + def ensure_directory(path: Path) -> None: """Ensure directory exists and is writable.""" try: if not path.exists(): path.mkdir(parents=True, exist_ok=True) - print(f"Created directory: {path}") - + print(f'Created directory: {path}') + # Test write access test_file = path / '.write_test' with open(test_file, 'w') as f: f.write('test') test_file.unlink() except Exception as e: - print(f"ERROR: Cannot write to {path}: {e}", file=sys.stderr) + print(f'ERROR: Cannot write to {path}: {e}', file=sys.stderr) sys.exit(1) -# Ensure directories exist + +# Ensure directories exist if using SQLite database if os.environ.get('SQLALCHEMY_DATABASE_URI', '').startswith('sqlite:///data/'): ensure_directory(data_dir) ensure_directory(instance_dir) app = create_app() + @app.errorhandler(Exception) def handle_exception(error: Exception) -> str: """Handle exceptions globally.""" return render_template('error.html', error=error) + if __name__ == '__main__': app.run(debug=True) diff --git a/scripts/create_db.py b/scripts/create_db.py new file mode 100644 index 0000000..2a6975b --- /dev/null +++ b/scripts/create_db.py @@ -0,0 +1,73 @@ +#!/usr/bin/env -S uv run --script + +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "getpass", +# "psycopg2>=2.9.11", +# ] +# /// + +"""Script to create the PostgreSQL database and user for TimeTracker.""" +# Evan Young October 2025 +# This is a stand-alone script that can be run with `uv run scripts/create_db.py` and +# does not require the full application context. (.venv) + +from getpass import getpass + +import psycopg2 +from psycopg2 import sql + +DB_NAME = 'timetracker_db' +DB_USER = 'timetracker_user' +DB_PASS = 'your_password' + + +def main() -> None: + """Create the database, user, and grant privileges.""" + try: + # Prompt for the Postgres superuser password + superuser_password = getpass.getpass('Postgres superuser password: ') + + # Connect to the default 'postgres' database as superuser + conn = psycopg2.connect(dbname='postgres', user='postgres', password=superuser_password) + conn.autocommit = True + cur = conn.cursor() + + # Create database if it doesn't exist + cur.execute('SELECT 1 FROM pg_database WHERE datname=%s;', (DB_NAME,)) + if not cur.fetchone(): + print(f'Creating database {DB_NAME}...') + cur.execute(sql.SQL('CREATE DATABASE {}').format(sql.Identifier(DB_NAME))) + else: + print(f'Database {DB_NAME} already exists.') + + # Create user if it doesn't exist + cur.execute('SELECT 1 FROM pg_roles WHERE rolname=%s;', (DB_USER,)) + if not cur.fetchone(): + print(f'Creating user {DB_USER}...') + cur.execute( + sql.SQL('CREATE USER {} WITH ENCRYPTED PASSWORD %s;').format(sql.Identifier(DB_USER)), (DB_PASS,) + ) + else: + print(f'User {DB_USER} already exists.') + + # Grant privileges + print(f'Granting privileges on {DB_NAME} to {DB_USER}...') + cur.execute( + sql.SQL('GRANT ALL PRIVILEGES ON DATABASE {} TO {};').format( + sql.Identifier(DB_NAME), sql.Identifier(DB_USER) + ) + ) + + print('Setup complete.') + + except Exception as e: + print(f'Error: {e}') + finally: + if conn: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/tests/conftest.py b/tests/conftest.py index c2f96ca..d18a5da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,20 +1,31 @@ import pytest + from app.app import create_app from app.models import db + @pytest.fixture def app(): """Create test Flask application with in-memory database.""" - # Use in-memory SQLite database for testing - app = create_app() - app.config.update({ + # Set environment variable to skip default admin creation and use test mode + import os + + os.environ['SKIP_DEFAULT_ADMIN'] = 'True' + os.environ['TESTING'] = 'True' + + # Create app with test configuration overrides + test_config = { 'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', 'WTF_CSRF_ENABLED': False, - 'SECRET_KEY': 'test-key' - }) + 'SECRET_KEY': 'test-key', + 'SKIP_DEFAULT_ADMIN': True, + } + + app = create_app(config_overrides=test_config) with app.app_context(): + # Create all tables in the in-memory database db.create_all() yield app @@ -24,11 +35,16 @@ def app(): db.session.remove() db.drop_all() + # Clean up environment variable + os.environ.pop('SKIP_DEFAULT_ADMIN', None) + + @pytest.fixture def client(app): """Create test client for Flask app.""" return app.test_client() + @pytest.fixture def runner(app): """Create test CLI runner for Flask app.""" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..123a341 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,213 @@ +"""Tests for authentication routes.""" + +from app.models import User +from app.service.user_service import create_user + + +class TestAuthRoutes: + """Test authentication routes.""" + + def test_profile_page_authenticated(self, client, app): + """Test profile page access when authenticated.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + assert user is not None # Type assertion + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + response = client.get('/auth/profile') + assert response.status_code == 200 + assert b'User Profile' in response.data + assert b'testuser' in response.data + assert b'test@example.com' in response.data + + def test_profile_page_unauthenticated(self, client): + """Test profile page redirects when not authenticated.""" + response = client.get('/auth/profile') + assert response.status_code == 302 + assert '/auth/login' in response.location + + def test_change_password_page_authenticated(self, client, app): + """Test change password page access when authenticated.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + response = client.get('/auth/change-password') + assert response.status_code == 200 + assert b'Change Password' in response.data + assert b'Current Password' in response.data + assert b'New Password' in response.data + + def test_change_password_page_unauthenticated(self, client): + """Test change password page redirects when not authenticated.""" + response = client.get('/auth/change-password') + assert response.status_code == 302 + assert '/auth/login' in response.location + + def test_change_password_success(self, client, app): + """Test successful password change.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Change password + response = client.post( + '/auth/change-password', + data={ + 'current_password': 'password123', + 'new_password': 'newpassword456', + 'confirm_password': 'newpassword456', + }, + follow_redirects=True, + ) + + assert response.status_code == 200 + assert b'Password changed successfully' in response.data + + # Verify password was changed + updated_user = User.query.get(user.id) + assert updated_user is not None # Type assertion + assert updated_user.check_password('newpassword456') + assert not updated_user.check_password('password123') + + def test_change_password_wrong_current(self, client, app): + """Test password change with wrong current password.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Try to change password with wrong current password + response = client.post( + '/auth/change-password', + data={ + 'current_password': 'wrongpassword', + 'new_password': 'newpassword456', + 'confirm_password': 'newpassword456', + }, + ) + + assert response.status_code == 200 + assert b'Current password is incorrect' in response.data + + def test_change_password_mismatch(self, client, app): + """Test password change with mismatched new passwords.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Try to change password with mismatched confirmation + response = client.post( + '/auth/change-password', + data={ + 'current_password': 'password123', + 'new_password': 'newpassword456', + 'confirm_password': 'differentpassword', + }, + ) + + assert response.status_code == 200 + assert b'New passwords do not match' in response.data + + def test_change_password_too_short(self, client, app): + """Test password change with too short new password.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Try to change password with too short new password + response = client.post( + '/auth/change-password', + data={'current_password': 'password123', 'new_password': '12345', 'confirm_password': '12345'}, + ) + + assert response.status_code == 200 + assert b'New password must be at least 6 characters long' in response.data + + def test_profile_update_email_success(self, client, app): + """Test successful email update from profile page.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Update email + response = client.post('/auth/profile', data={'email': 'new@example.com'}, follow_redirects=True) + + assert response.status_code == 200 + assert b'Email updated successfully' in response.data + + # Verify email was updated + updated_user = User.query.get(user.id) + assert updated_user is not None # Type assertion + assert updated_user.email == 'new@example.com' + + def test_profile_update_email_invalid(self, client, app): + """Test profile email update with invalid email format.""" + with app.app_context(): + # Create and login test user + user = create_user('testuser', 'test@example.com', 'password123') + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + sess['_fresh'] = True + + # Attempt to update with invalid email + response = client.post('/auth/profile', data={'email': 'not-an-email'}) + + assert response.status_code == 200 + assert b'Please enter a valid email address' in response.data + + # Verify email unchanged + unchanged_user = User.query.get(user.id) + assert unchanged_user is not None # Type assertion + assert unchanged_user.email == 'test@example.com' + + def test_profile_update_email_duplicate(self, client, app): + """Test profile email update with duplicate email address.""" + with app.app_context(): + # Create two users + user1 = create_user('user1', 'user1@example.com', 'password123') + _user2 = create_user('user2', 'user2@example.com', 'password123') + + # Login as first user + with client.session_transaction() as sess: + sess['_user_id'] = str(user1.id) + sess['_fresh'] = True + + # Attempt to use duplicate email of second user + response = client.post('/auth/profile', data={'email': 'user2@example.com'}) + + assert response.status_code == 200 + assert b'Error updating email: Email already exists' in response.data + + # Verify email unchanged + unchanged_user = User.query.get(user1.id) + assert unchanged_user is not None # Type assertion + assert unchanged_user.email == 'user1@example.com' diff --git a/tests/test_database.py b/tests/test_database.py index 621491a..cd852fc 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -4,14 +4,16 @@ from app.models import TimeEntry, db + def test_database_configuration(app): """Verify database URI is properly loaded from environment.""" # Arrange & Act db_uri = app.config['SQLALCHEMY_DATABASE_URI'] - + # Assert assert db_uri is not None - assert 'sqlite' in db_uri.lower() + assert 'sqlite' in db_uri.lower() + def test_database_write_and_read(app): """Verify database operations work correctly.""" @@ -21,18 +23,18 @@ def test_database_write_and_read(app): entry = TimeEntry( activity_date=datetime.now(), from_time=540, # 9:00 AM - to_time=570, # 9:30 AM - activity='Test Database Connection' + to_time=570, # 9:30 AM + activity='Test Database Connection', ) db.session.add(entry) db.session.commit() - + # Assert - Record exists saved_entry = TimeEntry.query.filter_by(activity='Test Database Connection').first() assert saved_entry is not None assert saved_entry.from_time == 540 assert saved_entry.to_time == 570 - + # Clean up db.session.delete(saved_entry) - db.session.commit() \ No newline at end of file + db.session.commit() diff --git a/tests/test_home.py b/tests/test_home.py index 7ec7900..2065eba 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -1,87 +1,167 @@ +"""Tests for home routes and time entry functionality.""" + from datetime import datetime + from app.models import TimeEntry, db + def test_index_route(client, app): + """Test the main index route displays time entries.""" with app.app_context(): + # Create test user for authentication + from app.service.user_service import create_user + + user = create_user('testuser', 'test@example.com', 'password123') + assert user is not None + + # Log in as test user + login_response = client.post( + '/auth/login', data={'username': 'testuser', 'password': 'password123'}, follow_redirects=True + ) + assert login_response.status_code == 200 + # Test empty list response = client.get('/') assert response.status_code == 200 - # Add test entry and verify it appears - entry = TimeEntry( - activity_date=datetime.now(), - from_time=540, # 9:00 AM - to_time=570, # 9:30 AM - activity='Test entry' + # Add some test time entries (times in minutes past midnight) + today = datetime.now().date() + entry1 = TimeEntry( + activity_date=datetime.combine(today, datetime.min.time()), + from_time=540, # 9:00 AM (9 * 60 = 540 minutes) + to_time=1020, # 5:00 PM (17 * 60 = 1020 minutes) + activity='Test task 1', ) - db.session.add(entry) + entry2 = TimeEntry( + activity_date=datetime.combine(today, datetime.min.time()), + from_time=600, # 10:00 AM (10 * 60 = 600 minutes) + to_time=1080, # 6:00 PM (18 * 60 = 1080 minutes) + activity='Test task 2', + ) + db.session.add(entry1) + db.session.add(entry2) db.session.commit() + # Test list with entries response = client.get('/') assert response.status_code == 200 - assert b'Test entry' in response.data + assert b'Test task 1' in response.data + assert b'Test task 2' in response.data + -def test_add_entry_post_success(client, app): +def test_add_time_entry(client, app): + """Test adding a new time entry.""" with app.app_context(): - # arrange - data = { - 'operating_date': '2023-08-01', - 'from_time': '9:00', - 'to_time': '9:30', - 'activity': 'Test entry', - 'time_out': 0 - } - - # act - response = client.post('/add', data=data, follow_redirects=True) + # Create test user for authentication + from app.service.user_service import create_user + + user = create_user('testuser', 'test@example.com', 'password123') + assert user is not None + + # Log in as test user + client.post('/auth/login', data={'username': 'testuser', 'password': 'password123'}, follow_redirects=True) + + # Add a time entry using the actual form structure + today = datetime.now().strftime('%Y-%m-%d') + response = client.post( + '/add', + data={ + 'operating_date': today, + 'from_time': '9:00', # This will be converted to 540 minutes + 'to_time': '17:00', # This will be converted to 1020 minutes + 'activity': 'New test task', + 'time_out': '1', + }, + follow_redirects=True, + ) + assert response.status_code == 200 + assert b'Time entry added successfully' in response.data - # assert - entry = TimeEntry.query.filter_by(activity='Test entry').first() + # Verify the entry was added to database + entry = TimeEntry.query.filter_by(activity='New test task').first() assert entry is not None + assert entry.from_time == 540 # 9:00 AM in minutes + assert entry.to_time == 1020 # 5:00 PM in minutes + assert entry.activity == 'New test task' + -def test_add_entry_validation_fail(client, app): - with app.app_context(): - # arrange - data = { - 'operating_date': '', - 'from_time': '', - 'to_time': '', - 'activity': '', - 'time_out': 0 - } - - # act - response = client.post('/add', data=data) - assert response.status_code == 302 # Expecting a redirect due to validation failure - - # assert - entries = TimeEntry.query.all() - assert len(entries) == 0 - -def test_change_operating_date(client, app): +def test_edit_time_entry(client, app): + """Test editing an existing time entry.""" with app.app_context(): - # Arrange - entry1 = TimeEntry( - activity_date=datetime(2023, 8, 1), - from_time=540, # 9:00 AM - to_time=570, # 9:30 AM - activity='Test entry 1' + # Create test user for authentication + from app.service.user_service import create_user + + user = create_user('testuser', 'test@example.com', 'password123') + assert user is not None + + # Log in as test user + client.post('/auth/login', data={'username': 'testuser', 'password': 'password123'}, follow_redirects=True) + + # Create initial time entry using correct model fields + today = datetime.now().date() + entry = TimeEntry( + activity_date=datetime.combine(today, datetime.min.time()), + from_time=540, # 9:00 AM in minutes + to_time=1020, # 5:00 PM in minutes + activity='Original task', ) - entry2 = TimeEntry( - activity_date=datetime(2023, 8, 2), - from_time=600, # 10:00 AM - to_time=630, # 10:30 AM - activity='Test entry 2' + db.session.add(entry) + db.session.commit() + + # Edit the time entry using the actual form structure + response = client.post( + '/add', + data={ + 'entry_id': str(entry.id), + 'operating_date': today.strftime('%Y-%m-%d'), + 'from_time': '10:00', # This will be converted to 600 minutes + 'to_time': '18:00', # This will be converted to 1080 minutes + 'activity': 'Updated task', + }, + follow_redirects=True, ) - db.session.add(entry1) - db.session.add(entry2) + + assert response.status_code == 200 + assert b'Time entry updated successfully' in response.data + + # Verify the entry was updated + updated_entry = TimeEntry.query.get(entry.id) + assert updated_entry is not None + assert updated_entry.activity == 'Updated task' + assert updated_entry.from_time == 600 # 10:00 AM in minutes + assert updated_entry.to_time == 1080 # 6:00 PM in minutes + + +def test_delete_time_entry(client, app): + """Test deleting a time entry.""" + with app.app_context(): + # Create test user for authentication + from app.service.user_service import create_user + + user = create_user('testuser', 'test@example.com', 'password123') + assert user is not None + + # Log in as test user + client.post('/auth/login', data={'username': 'testuser', 'password': 'password123'}, follow_redirects=True) + + # Create initial time entry using correct model fields + today = datetime.now().date() + entry = TimeEntry( + activity_date=datetime.combine(today, datetime.min.time()), + from_time=540, # 9:00 AM in minutes + to_time=1020, # 5:00 PM in minutes + activity='Task to delete', + ) + db.session.add(entry) db.session.commit() + entry_id = entry.id - # Act - response = client.get('/?date=2023-08-01') + # Delete the time entry using the actual route + response = client.post(f'/entry/{entry_id}/delete', follow_redirects=True) - # Assert assert response.status_code == 200 - assert b'Test entry 1' in response.data - assert b'Test entry 2' not in response.data + + # Verify the entry was deleted + deleted_entry = TimeEntry.query.get(entry_id) + assert deleted_entry is None diff --git a/tests/test_reports.py b/tests/test_reports.py index bbeca75..e09acf6 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -1,42 +1,61 @@ """Tests for the weekly_report route in reports.py.""" -from datetime import date, datetime, timedelta +from datetime import date, datetime from app.models import TimeEntry, db # Arrange: Setup a test client and test data -def test_weekly_report_get(client): +def login(client): + """Helper function to log in the test client.""" + return client.post('/auth/login', data={'username': 'admin', 'password': 'admin123'}, follow_redirects=True) + + +def test_weekly_report_get(client, app): """Test GET request to /reports/weekly returns the settings page with correct defaults loaded.""" # Arrange - today = date.today() - last_sunday = today - timedelta(days=today.weekday() + 1) - last_saturday = last_sunday + timedelta(days=6) + with app.app_context(): + # Create admin user for login + from app.service.user_service import create_user + + admin_user = create_user('admin', 'admin@test.com', 'admin123', is_admin=True) + assert admin_user is not None + + login(client) # Act response = client.get('/reports/weekly') # Assert assert response.status_code == 200 - assert bytes(last_sunday.strftime('%Y-%m-%d'), 'utf-8') in response.data - assert bytes(last_saturday.strftime('%Y-%m-%d'), 'utf-8') in response.data + assert b'Weekly Report Settings' in response.data or b'Weekly Report' in response.data def test_weekly_report_post(client, app): """Test POST request to /reports/weekly returns the report page with correct entries.""" # Arrange + with app.app_context(): + # Create admin user for login + from app.service.user_service import create_user + + admin_user = create_user('admin', 'admin@test.com', 'admin123', is_admin=True) + assert admin_user is not None + + login(client) start_date = date(2025, 5, 18) end_date = date(2025, 5, 24) + with app.app_context(): entry = TimeEntry( activity_date=datetime.combine(start_date, datetime.min.time()), - from_time=480, - to_time=540, + from_time=480, # 8:00 AM + to_time=540, # 9:00 AM activity='Test Activity', ) db.session.add(entry) db.session.commit() + # Act response = client.post( '/reports/weekly', @@ -44,9 +63,10 @@ def test_weekly_report_post(client, app): 'start_date': start_date.strftime('%Y-%m-%d'), 'end_date': end_date.strftime('%Y-%m-%d'), }, + follow_redirects=True, ) + # Assert assert response.status_code == 200 assert b'Test Activity' in response.data assert b'2025-05-18' in response.data - assert b'1' in response.data # duration in hours diff --git a/tests/test_user_service.py b/tests/test_user_service.py new file mode 100644 index 0000000..5004571 --- /dev/null +++ b/tests/test_user_service.py @@ -0,0 +1,586 @@ +"""Updated tests for user_service.py functionality using exception-based approach.""" + +import os +from unittest.mock import patch + +import pytest + +from app.service.user_service import ( + CreateUserError, + DeleteUserError, + UpdateUserError, + authenticate_user, + create_default_admin, + create_user, + delete_user, + get_all_users, + get_user_by_email, + get_user_by_id, + get_user_by_username, + update_user, +) + + +class TestUserService: + """Test class for user service module functions.""" + + # region create_user tests + + def test_create_user_success(self, app): + """Test successful user creation.""" + with app.app_context(): + # Arrange + username = 'testuser' + email = 'test@example.com' + password = 'password123' + is_admin = False + is_active = True + + # Act + user = create_user( + username=username, + email=email, + password=password, + is_admin=is_admin, + is_active=is_active, + ) + + # Assert + assert user is not None + assert user.username == username + assert user.email == email + assert user.is_admin == is_admin + assert user.is_active == is_active + assert user.check_password(password) + + def test_create_user_with_admin_privileges(self, app): + """Test creating user with admin privileges.""" + with app.app_context(): + # Act + user = create_user('admin', 'admin@example.com', 'password123', is_admin=True) + + # Assert + assert user is not None + assert user.is_admin is True + assert user.username == 'admin' + + def test_create_user_with_inactive_status(self, app): + """Test creating user with inactive status.""" + with app.app_context(): + # Act + user = create_user('testuser', 'test@example.com', 'password123', is_active=False) + + # Assert + assert user is not None + assert user.is_active is False + + def test_create_user_duplicate_username_raises_exception(self, app): + """Test creating user with existing username raises CreateUserError.""" + with app.app_context(): + # Arrange + create_user('existinguser', 'existing@example.com', 'password123') + + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('existinguser', 'new@example.com', 'password123') + assert exc_info.value.message == 'Username already exists' + + def test_create_user_duplicate_email_raises_exception(self, app): + """Test creating user with existing email raises CreateUserError.""" + with app.app_context(): + # Arrange + create_user('existinguser', 'existing@example.com', 'password123') + + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('newuser', 'existing@example.com', 'password123') + assert exc_info.value.message == 'Email already exists' + + def test_create_user_short_username_raises_exception(self, app): + """Test creating user with short username raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('ab', 'test@example.com', 'password123') + assert exc_info.value.message == 'Username must be at least 3 characters long' + + def test_create_user_invalid_email_raises_exception(self, app): + """Test creating user with invalid email raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('testuser', 'invalid-email', 'password123') + assert exc_info.value.message == 'Invalid email address' + + def test_create_user_short_password_raises_exception(self, app): + """Test creating user with short password raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('testuser', 'test@example.com', '12345') + assert exc_info.value.message == 'Password must be at least 6 characters long' + + def test_create_user_empty_username_raises_exception(self, app): + """Test creating user with empty username raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('', 'test@example.com', 'password123') + assert exc_info.value.message == 'Username must be at least 3 characters long' + + def test_create_user_empty_email_raises_exception(self, app): + """Test creating user with empty email raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('testuser', '', 'password123') + assert exc_info.value.message == 'Invalid email address' + + def test_create_user_empty_password_raises_exception(self, app): + """Test creating user with empty password raises CreateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('testuser', 'test@example.com', '') + assert exc_info.value.message == 'Password must be at least 6 characters long' + + @patch('app.service.user_service.db.session.commit') + def test_create_user_database_error_raises_exception(self, mock_commit, app): + """Test database error during user creation raises CreateUserError.""" + with app.app_context(): + # Arrange + from sqlalchemy.exc import IntegrityError + + mock_commit.side_effect = IntegrityError('test', 'test', 'test') + + # Act & Assert + with pytest.raises(CreateUserError) as exc_info: + create_user('testuser', 'test@example.com', 'password123') + assert exc_info.value.message == 'Database error: User creation failed' + + # endregion + + # region get_user tests + + def test_get_user_by_id_success(self, app): + """Test retrieving user by ID.""" + with app.app_context(): + # Arrange + created_user = create_user('testuser', 'test@example.com', 'password123') + + # Act + found_user = get_user_by_id(created_user.id) + + # Assert + assert found_user is not None + assert found_user.id == created_user.id + assert found_user.username == 'testuser' + assert found_user.email == 'test@example.com' + + def test_get_user_by_id_not_found(self, app): + """Test retrieving non-existent user by ID returns None.""" + with app.app_context(): + # Act + found_user = get_user_by_id(999) + + # Assert + assert found_user is None + + def test_get_user_by_username_success(self, app): + """Test retrieving user by username.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123') + + # Act + found_user = get_user_by_username('testuser') + + # Assert + assert found_user is not None + assert found_user.username == 'testuser' + assert found_user.email == 'test@example.com' + + def test_get_user_by_username_not_found(self, app): + """Test retrieving non-existent user by username returns None.""" + with app.app_context(): + # Act + found_user = get_user_by_username('nonexistent') + + # Assert + assert found_user is None + + def test_get_user_by_email_success(self, app): + """Test retrieving user by email.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123') + + # Act + found_user = get_user_by_email('test@example.com') + + # Assert + assert found_user is not None + assert found_user.username == 'testuser' + assert found_user.email == 'test@example.com' + + def test_get_user_by_email_not_found(self, app): + """Test retrieving non-existent user by email returns None.""" + with app.app_context(): + # Act + found_user = get_user_by_email('nonexistent@example.com') + + # Assert + assert found_user is None + + def test_get_all_users_empty(self, app): + """Test retrieving all users when none exist.""" + with app.app_context(): + # Act + users = get_all_users() + + # Assert + assert len(users) == 0 + + def test_get_all_users_multiple(self, app): + """Test retrieving all users in sorted order.""" + with app.app_context(): + # Arrange + create_user('zebra', 'zebra@example.com', 'password123') + create_user('apple', 'apple@example.com', 'password123') + create_user('banana', 'banana@example.com', 'password123') + + # Act + users = get_all_users() + + # Assert + assert len(users) == 3 + usernames = [user.username for user in users] + assert usernames == ['apple', 'banana', 'zebra'] + + # endregion + + # region update_user tests + + def test_update_user_username_success(self, app): + """Test updating user's username.""" + with app.app_context(): + # Arrange + user = create_user('oldname', 'test@example.com', 'password123') + + # Act + updated_user = update_user(user.id, username='newname') + + # Assert + assert updated_user is not None + assert updated_user.username == 'newname' + assert updated_user.id == user.id + + def test_update_user_email_success(self, app): + """Test updating user's email.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'old@example.com', 'password123') + + # Act + updated_user = update_user(user.id, email='new@example.com') + + # Assert + assert updated_user is not None + assert updated_user.email == 'new@example.com' + + def test_update_user_password_success(self, app): + """Test updating user's password.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'oldpassword') + + # Act + updated_user = update_user(user.id, password='newpassword123') + + # Assert + assert updated_user is not None + assert updated_user.check_password('newpassword123') + assert not updated_user.check_password('oldpassword') + + def test_update_user_admin_status_success(self, app): + """Test updating user's admin status.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'password123', is_admin=False) + + # Act + updated_user = update_user(user.id, is_admin=True) + + # Assert + assert updated_user is not None + assert updated_user.is_admin is True + + def test_update_user_active_status_success(self, app): + """Test updating user's active status.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'password123', is_active=True) + + # Act + updated_user = update_user(user.id, is_active=False) + + # Assert + assert updated_user is not None + assert updated_user.user_active is False + + def test_update_user_multiple_fields_success(self, app): + """Test updating multiple user fields at once.""" + with app.app_context(): + # Arrange + user = create_user('oldname', 'old@example.com', 'oldpassword', is_admin=False, is_active=True) + + # Act + updated_user = update_user( + user.id, + username='newname', + email='new@example.com', + password='newpassword123', + is_admin=True, + is_active=False, + ) + + # Assert + assert updated_user is not None + assert updated_user.username == 'newname' + assert updated_user.email == 'new@example.com' + assert updated_user.check_password('newpassword123') + assert updated_user.is_admin is True + assert updated_user.is_active is False + + def test_update_user_not_found_raises_exception(self, app): + """Test updating non-existent user raises UpdateUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(UpdateUserError) as exc_info: + update_user(999, username='newname') + assert exc_info.value.message == 'User not found' + assert exc_info.value.user_id == 999 + + def test_update_user_duplicate_username_raises_exception(self, app): + """Test updating user with existing username raises UpdateUserError.""" + with app.app_context(): + # Arrange + create_user('existinguser', 'existing@example.com', 'password123') + user = create_user('testuser', 'test@example.com', 'password123') + + # Act & Assert + with pytest.raises(UpdateUserError) as exc_info: + update_user(user.id, username='existinguser') + assert exc_info.value.message == 'Username already exists' + assert exc_info.value.user_id == user.id + + def test_update_user_duplicate_email_raises_exception(self, app): + """Test updating user with existing email raises UpdateUserError.""" + with app.app_context(): + # Arrange + create_user('existinguser', 'existing@example.com', 'password123') + user = create_user('testuser', 'test@example.com', 'password123') + + # Act & Assert + with pytest.raises(UpdateUserError) as exc_info: + update_user(user.id, email='existing@example.com') + assert exc_info.value.message == 'Email already exists' + assert exc_info.value.user_id == user.id + + def test_update_user_short_password_raises_exception(self, app): + """Test updating user with short password raises UpdateUserError.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'password123') + + # Act & Assert + with pytest.raises(UpdateUserError) as exc_info: + update_user(user.id, password='12345') + assert exc_info.value.message == 'Password must be at least 6 characters long' + assert exc_info.value.user_id == user.id + + # endregion + + # region delete_user tests + + def test_delete_user_success(self, app): + """Test successful user deletion.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'password123') + user_id = user.id + + # Act + delete_user(user_id) + + # Assert - user should no longer exist + deleted_user = get_user_by_id(user_id) + assert deleted_user is None + + def test_delete_user_not_found_raises_exception(self, app): + """Test deleting non-existent user raises DeleteUserError.""" + with app.app_context(): + # Act & Assert + with pytest.raises(DeleteUserError) as exc_info: + delete_user(999) + assert exc_info.value.message == 'User not found' + assert exc_info.value.user_id == 999 + + def test_delete_last_admin_raises_exception(self, app): + """Test deleting last admin user raises DeleteUserError.""" + with app.app_context(): + # Arrange - create only one admin user + admin_user = create_user('admin', 'admin@example.com', 'password123', is_admin=True) + + # Act & Assert + with pytest.raises(DeleteUserError) as exc_info: + delete_user(admin_user.id) + assert exc_info.value.message == 'Cannot delete the last admin user' + assert exc_info.value.user_id == admin_user.id + + def test_delete_user_with_multiple_admins_success(self, app): + """Test deleting admin user when multiple admins exist.""" + with app.app_context(): + # Arrange + admin1 = create_user('admin1', 'admin1@example.com', 'password123', is_admin=True) + admin2 = create_user('admin2', 'admin2@example.com', 'password123', is_admin=True) + + # Act + delete_user(admin1.id) + + # Assert + deleted_user = get_user_by_id(admin1.id) + assert deleted_user is None + # Ensure other admin still exists + remaining_admin = get_user_by_id(admin2.id) + assert remaining_admin is not None + + @patch('app.service.user_service.db.session.delete') + def test_delete_user_database_error_raises_exception(self, mock_delete, app): + """Test database error during user deletion raises DeleteUserError.""" + with app.app_context(): + # Arrange + user = create_user('testuser', 'test@example.com', 'password123') + mock_delete.side_effect = Exception('Database error') + + # Act & Assert + with pytest.raises(DeleteUserError) as exc_info: + delete_user(user.id) + assert exc_info.value.message == 'Error deleting user' + assert exc_info.value.user_id == user.id + + # endregion + + # region authenticate_user tests + + def test_authenticate_user_by_username_success(self, app): + """Test successful authentication using username.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123') + + # Act + authenticated_user = authenticate_user('testuser', 'password123') + + # Assert + assert authenticated_user is not None + assert authenticated_user.username == 'testuser' + + def test_authenticate_user_by_email_success(self, app): + """Test successful authentication using email.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123') + + # Act + authenticated_user = authenticate_user('test@example.com', 'password123') + + # Assert + assert authenticated_user is not None + assert authenticated_user.username == 'testuser' + + def test_authenticate_user_wrong_password(self, app): + """Test authentication with wrong password.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123') + + # Act + authenticated_user = authenticate_user('testuser', 'wrongpassword') + + # Assert + assert authenticated_user is None + + def test_authenticate_user_inactive_user(self, app): + """Test authentication with inactive user.""" + with app.app_context(): + # Arrange + create_user('testuser', 'test@example.com', 'password123', is_active=False) + + # Act + authenticated_user = authenticate_user('testuser', 'password123') + + # Assert + assert authenticated_user is None + + def test_authenticate_user_nonexistent_user(self, app): + """Test authentication with non-existent user.""" + with app.app_context(): + # Act + authenticated_user = authenticate_user('nonexistent', 'password123') + + # Assert + assert authenticated_user is None + + # endregion + + # region create_default_admin tests + + def test_create_default_admin_when_no_users(self, app): + """Test creating default admin when no users exist.""" + with app.app_context(): + # Act + admin_user = create_default_admin() + + # Assert + assert admin_user is not None + assert admin_user.username == 'admin' + assert admin_user.email == 'admin@timetracker.local' + assert admin_user.is_admin is True + assert admin_user.is_active is True + assert admin_user.check_password('admin123') + + def test_create_default_admin_when_users_exist(self, app): + """Test that default admin is not created when users already exist.""" + with app.app_context(): + # Arrange + create_user('existinguser', 'existing@example.com', 'password123') + + # Act + admin_user = create_default_admin() + + # Assert + assert admin_user is None + + @patch.dict( + os.environ, + { + 'DEFAULT_ADMIN_USERNAME': 'customadmin', + 'DEFAULT_ADMIN_EMAIL': 'custom@admin.com', + 'DEFAULT_ADMIN_PASSWORD': 'custompass123', + }, + ) + def test_create_default_admin_with_custom_env_vars(self, app): + """Test creating default admin with custom environment variables.""" + with app.app_context(): + # Act + admin_user = create_default_admin() + + # Assert + assert admin_user is not None + assert admin_user.username == 'customadmin' + assert admin_user.email == 'custom@admin.com' + assert admin_user.check_password('custompass123') + assert admin_user.is_admin is True + + # endregion diff --git a/uv.lock b/uv.lock index 5b674bd..4a2a028 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -128,6 +128,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/88/53/958ce7c2aa26280b7fd7f3eecbf13053f1302ee2acb1db58ef32e1c23c2a/Flask-Bootstrap-3.3.7.1.tar.gz", hash = "sha256:cb08ed940183f6343a64e465e83b3a3f13c53e1baabb8d72b5da4545ef123ac8", size = 456359, upload-time = "2017-01-11T23:28:23.944Z" } +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + [[package]] name = "flask-sqlalchemy" version = "3.1.1" @@ -305,6 +318,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397, upload-time = "2024-10-16T11:19:40.033Z" }, + { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806, upload-time = "2024-10-16T11:19:43.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370, upload-time = "2024-10-16T11:19:46.986Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780, upload-time = "2024-10-16T11:19:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583, upload-time = "2024-10-16T11:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831, upload-time = "2024-10-16T11:19:57.762Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822, upload-time = "2024-10-16T11:20:04.693Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975, upload-time = "2024-10-16T11:20:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320, upload-time = "2024-10-16T11:20:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617, upload-time = "2024-10-16T11:20:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618, upload-time = "2024-10-16T11:20:27.718Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816, upload-time = "2024-10-16T11:20:30.777Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -417,14 +473,16 @@ wheels = [ [[package]] name = "timetracker" -version = "0.1.27" +version = "0.1.36" source = { virtual = "." } dependencies = [ { name = "flask" }, { name = "flask-bootstrap" }, + { name = "flask-login" }, { name = "flask-sqlalchemy" }, { name = "flask-wtf" }, { name = "gunicorn" }, + { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "sqlalchemy" }, ] @@ -442,9 +500,11 @@ dev = [ requires-dist = [ { name = "flask", specifier = ">=3.1.0" }, { name = "flask-bootstrap", specifier = ">=3.3.7.1" }, + { name = "flask-login", specifier = ">=0.6.3" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "flask-wtf", specifier = ">=1.2.2" }, { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "sqlalchemy", specifier = ">=2.0.37" }, ]