diff --git a/DOCKER_PUBLIC_SETUP.md b/DOCKER_PUBLIC_SETUP.md index 4f5615c..b93ee35 100644 --- a/DOCKER_PUBLIC_SETUP.md +++ b/DOCKER_PUBLIC_SETUP.md @@ -73,7 +73,7 @@ SECRET_KEY=your-secure-random-string-here ADMIN_USERNAMES=admin,manager # Optional -TZ=Europe/Brussels +TZ=Europe/Rome CURRENCY=EUR ROUNDING_MINUTES=1 SINGLE_ACTIVE_TIMER=true diff --git a/Dockerfile b/Dockerfile index 9a73a7a..9a52e1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,15 @@ ENV FLASK_ENV=production # Install system dependencies RUN apt-get update && apt-get install -y \ curl \ + tzdata \ && rm -rf /var/lib/apt/lists/* # Set work directory WORKDIR /app +# Set default timezone +ENV TZ=Europe/Rome + # Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -24,12 +28,31 @@ COPY . . # Create data and logs directories with proper permissions RUN mkdir -p /data /app/logs && chmod 755 /data && chmod 755 /app/logs +# Create startup script directly in Dockerfile +RUN echo '#!/bin/bash' > /app/start.sh && \ + echo 'set -e' >> /app/start.sh && \ + echo 'cd /app' >> /app/start.sh && \ + echo 'export FLASK_APP=app' >> /app/start.sh && \ + echo 'echo "=== Starting TimeTracker ==="' >> /app/start.sh && \ + echo 'echo "Testing startup script..."' >> /app/start.sh && \ + echo 'ls -la /app/docker/' >> /app/start.sh && \ + echo 'echo "Starting database initialization..."' >> /app/start.sh && \ + echo 'python /app/docker/init-database-sql.py' >> /app/start.sh && \ + echo 'echo "Starting application..."' >> /app/start.sh && \ + echo 'exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"' >> /app/start.sh + # Make startup scripts executable -RUN chmod +x /app/docker/start.sh /app/docker/init-database.py /app/docker/test-db.py +RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py # Create non-root user RUN useradd -m -u 1000 timetracker && \ - chown -R timetracker:timetracker /app /data /app/logs + chown -R timetracker:timetracker /app /data /app/logs && \ + chmod +x /app/start.sh + +# Verify startup script exists and is accessible +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh + USER timetracker # Expose port @@ -39,90 +62,5 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8080/_health || exit 1 -# Create startup script with database initialization -RUN echo '#!/bin/bash\n\ -set -e\n\ -cd /app\n\ -export FLASK_APP=app\n\ -\n\ -echo "Waiting for database to be ready..."\n\ -# Wait for Postgres to be ready\n\ -python - <<"PY"\n\ -import os\n\ -import time\n\ -import sys\n\ -from sqlalchemy import create_engine, text\n\ -from sqlalchemy.exc import OperationalError\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - for attempt in range(30):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - with engine.connect() as conn:\n\ - conn.execute(text("SELECT 1"))\n\ - print("Database connection established successfully")\n\ - break\n\ - except Exception as e:\n\ - print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\ - time.sleep(2)\n\ - else:\n\ - print("Database not ready after waiting, exiting...")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping connection check")\n\ -PY\n\ -\n\ -echo "Checking if database is initialized..."\n\ -# Check if database is initialized by looking for tables\n\ -python - <<"PY"\n\ -import os\n\ -import sys\n\ -from sqlalchemy import create_engine, text, inspect\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - inspector = inspect(engine)\n\ - \n\ - # Check if our main tables exist\n\ - existing_tables = inspector.get_table_names()\n\ - required_tables = ["users", "projects", "time_entries", "settings"]\n\ - \n\ - missing_tables = [table for table in required_tables if table not in existing_tables]\n\ - \n\ - if missing_tables:\n\ - print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\ - print("Will initialize database...")\n\ - sys.exit(1) # Exit with error to trigger initialization\n\ - else:\n\ - print("Database is already initialized with all required tables")\n\ - sys.exit(0) # Exit successfully, no initialization needed\n\ - \n\ - except Exception as e:\n\ - print(f"Error checking database initialization: {e}")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping initialization check")\n\ - sys.exit(0)\n\ -PY\n\ -\n\ -if [ $? -eq 1 ]; then\n\ - echo "Initializing database..."\n\ - python /app/docker/init-database.py\n\ - if [ $? -eq 0 ]; then\n\ - echo "Database initialized successfully"\n\ - else\n\ - echo "Database initialization failed, but continuing..."\n\ - fi\n\ -else\n\ - echo "Database already initialized, skipping initialization"\n\ -fi\n\ -\n\ -echo "Starting application..."\n\ -exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\ -' > /app/start.sh && chmod +x /app/start.sh - # Run the application CMD ["/app/start.sh"] diff --git a/Dockerfile.combined b/Dockerfile.combined deleted file mode 100644 index 368bf33..0000000 --- a/Dockerfile.combined +++ /dev/null @@ -1,216 +0,0 @@ -FROM python:3.11-slim - -# Install system dependencies including PostgreSQL -RUN apt-get update && apt-get install -y \ - curl \ - postgresql \ - postgresql-contrib \ - supervisor \ - && rm -rf /var/lib/apt/lists/* - -# Set work directory -WORKDIR /app - -# Install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Create data and logs directories with proper permissions -RUN mkdir -p /data /app/logs /var/lib/postgresql/data && \ - chmod 755 /data && chmod 755 /app/logs && chmod 755 /var/lib/postgresql/data - -# Make startup scripts executable -RUN chmod +x /app/docker/init-database.py /app/docker/test-db.py - -# Create non-root user -RUN useradd -m -u 1000 timetracker && \ - chown -R timetracker:timetracker /app /data /app/logs /var/lib/postgresql/data - -# Create PostgreSQL configuration -RUN mkdir -p /etc/postgresql/main && \ - echo "listen_addresses = '*'" > /etc/postgresql/main/postgresql.conf && \ - echo "port = 5432" >> /etc/postgresql/main/postgresql.conf && \ - echo "data_directory = '/var/lib/postgresql/data'" >> /etc/postgresql/main/postgresql.conf && \ - echo "log_destination = 'stderr'" >> /etc/postgresql/main/postgresql.conf && \ - echo "logging_collector = on" >> /etc/postgresql/main/postgresql.conf && \ - echo "log_directory = 'log'" >> /etc/postgresql/main/postgresql.conf && \ - echo "log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'" >> /etc/postgresql/main/postgresql.conf && \ - echo "log_rotation_age = 1d" >> /etc/postgresql/main/postgresql.conf && \ - echo "log_rotation_size = 10MB" >> /etc/postgresql/main/postgresql.conf - -# Create PostgreSQL access configuration -RUN echo "local all all trust" > /etc/postgresql/main/pg_hba.conf && \ - echo "host all all 127.0.0.1/32 trust" >> /etc/postgresql/main/pg_hba.conf && \ - echo "host all all ::1/128 trust" >> /etc/postgresql/main/pg_hba.conf && \ - echo "host all all ::1/128 trust" >> /etc/postgresql/main/pg_hba.conf - -# Find binary locations -RUN echo "--- Finding PostgreSQL binaries ---" && \ - find /usr -name "postgres" -type f 2>/dev/null | head -5 && \ - find /usr -name "initdb" -type f 2>/dev/null | head -5 && \ - find /usr -name "pg_ctl" -type f 2>/dev/null | head -5 && \ - find /usr -name "psql" -type f 2>/dev/null | head -5 && \ - echo "--- Checking PATH ---" && \ - echo $PATH && \ - echo "--- Checking which postgres ---" && \ - which postgres || echo "postgres not found in PATH" - -# Create symlinks for PostgreSQL binaries and database initialization script -RUN find /usr -name "postgres" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/postgres && \ - find /usr -name "initdb" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/initdb && \ - find /usr -name "pg_ctl" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/pg_ctl && \ - find /usr -name "createdb" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/createdb && \ - find /usr -name "createuser" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/createuser && \ - find /usr -name "psql" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/psql && \ - find /usr -name "pg_isready" -type f 2>/dev/null | head -1 | xargs -I {} ln -sf {} /usr/bin/pg_isready && \ - echo "--- PostgreSQL binaries linked ---" && \ - ls -la /usr/bin/postgres* /usr/bin/initdb /usr/bin/pg_ctl /usr/bin/createdb /usr/bin/createuser /usr/bin/psql /usr/bin/pg_isready 2>/dev/null || echo "Some binaries not found" - -# Copy database initialization script -COPY docker/init-db.sh /app/init-db.sh -RUN chmod +x /app/init-db.sh - -# Copy supervisor configuration -COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf - -# Create startup script for Flask app with database initialization -RUN echo '#!/bin/bash\n\ -set -e\n\ -cd /app\n\ -export FLASK_APP=app\n\ -export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker\n\ -\n\ -echo "=== Starting TimeTracker with Combined Setup ==="\n\ -\n\ -# Initialize PostgreSQL if needed\n\ -if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then\n\ - echo "Initializing PostgreSQL..."\n\ - su - postgres -c "/usr/bin/initdb -D /var/lib/postgresql/data"\n\ - echo "PostgreSQL initialized"\n\ -fi\n\ -\n\ -# Start PostgreSQL in background\n\ -echo "Starting PostgreSQL..."\n\ -su - postgres -c "/usr/bin/postgres -D /var/lib/postgresql/data" &\n\ -POSTGRES_PID=$!\n\ -\n\ -# Wait for PostgreSQL to be ready\n\ -echo "Waiting for PostgreSQL..."\n\ -sleep 10\n\ -\n\ -# Wait for PostgreSQL to actually be accepting connections\n\ -echo "Checking PostgreSQL connection..."\n\ -until su - postgres -c "/usr/bin/pg_isready -q"; do\n\ - echo "PostgreSQL is not ready yet, waiting..."\n\ - sleep 2\n\ -done\n\ -echo "PostgreSQL is ready!"\n\ -\n\ -# Create database and user\n\ -echo "Setting up database..."\n\ -su - postgres -c "/usr/bin/createdb timetracker" 2>/dev/null || echo "Database exists"\n\ -su - postgres -c "/usr/bin/createuser -s timetracker" 2>/dev/null || echo "User exists"\n\ -\n\ -# Initialize schema if needed or if FORCE_REINIT is set\n\ -if [ ! -f /var/lib/postgresql/data/.initialized ] || [ "$FORCE_REINIT" = "true" ]; then\n\ - echo "Initializing database schema..."\n\ - su - postgres -c "/usr/bin/psql -d timetracker -f /app/docker/init.sql"\n\ - touch /var/lib/postgresql/data/.initialized\n\ - echo "Schema initialized"\n\ -fi\n\ -\n\ -# Wait for database to be ready for Python connection\n\ -echo "Waiting for database to be ready for Python..."\n\ -python - <<"PY"\n\ -import os\n\ -import time\n\ -import sys\n\ -from sqlalchemy import create_engine, text\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - for attempt in range(30):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - with engine.connect() as conn:\n\ - conn.execute(text("SELECT 1"))\n\ - print("Database connection established successfully")\n\ - break\n\ - except Exception as e:\n\ - print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\ - time.sleep(2)\n\ - else:\n\ - print("Database not ready after waiting, exiting...")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping connection check")\n\ -PY\n\ -\n\ -echo "Checking if database is initialized..."\n\ -# Check if database is initialized by looking for tables\n\ -python - <<"PY"\n\ -import os\n\ -import sys\n\ -from sqlalchemy import create_engine, text, inspect\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - inspector = inspect(engine)\n\ - \n\ - # Check if our main tables exist\n\ - existing_tables = inspector.get_table_names()\n\ - required_tables = ["users", "projects", "time_entries", "settings"]\n\ - \n\ - missing_tables = [table for table in required_tables if table not in existing_tables]\n\ - \n\ - if missing_tables:\n\ - print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\ - print("Will initialize database...")\n\ - sys.exit(1) # Exit with error to trigger initialization\n\ - else:\n\ - print("Database is already initialized with all required tables")\n\ - sys.exit(0) # Exit successfully, no initialization needed\n\ - \n\ - except Exception as e:\n\ - print(f"Error checking database initialization: {e}")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping initialization check")\n\ - sys.exit(0)\n\ -PY\n\ -\n\ -if [ $? -eq 1 ]; then\n\ - echo "Initializing database with Python script..."\n\ - python /app/docker/init-database.py\n\ - if [ $? -eq 0 ]; then\n\ - echo "Database initialized successfully with Python script"\n\ - else\n\ - echo "Python database initialization failed, but continuing..."\n\ - fi\n\ -else\n\ - echo "Database already initialized, skipping initialization"\n\ -fi\n\ -\n\ -# Start Flask app\n\ -echo "Starting Flask application..."\n\ -exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\ -' > /app/start.sh && chmod +x /app/start.sh - -# Expose ports -EXPOSE 8080 5432 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:8080/_health || exit 1 - -# Copy main initialization script -COPY docker/init.sh /app/init.sh -RUN chmod +x /app/init.sh - -# Initialize database and start services -CMD ["/app/start.sh"] diff --git a/Dockerfile.simple b/Dockerfile.simple index 13849b0..eddb61c 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -5,11 +5,15 @@ RUN apt-get update && apt-get install -y \ postgresql \ postgresql-contrib \ curl \ + tzdata \ && rm -rf /var/lib/apt/lists/* # Set work directory WORKDIR /app +# Set default timezone +ENV TZ=Europe/Rome + # Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -23,135 +27,32 @@ RUN mkdir -p /data /app/logs /var/lib/postgresql/data /var/run/postgresql # Make startup scripts executable RUN chmod +x /app/docker/init-database.py /app/docker/test-db.py +# Create startup script directly in Dockerfile +RUN echo '#!/bin/bash' > /app/start.sh && \ + echo 'set -e' >> /app/start.sh && \ + echo 'cd /app' >> /app/start.sh && \ + echo 'export FLASK_APP=app' >> /app/start.sh && \ + echo 'export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker' >> /app/start.sh && \ + echo 'echo "=== Starting TimeTracker ==="' >> /app/start.sh && \ + echo 'echo "Testing startup script..."' >> /app/start.sh && \ + echo 'ls -la /app/docker/' >> /app/start.sh && \ + echo 'echo "Starting database initialization..."' >> /app/start.sh && \ + echo 'python /app/docker/init-database-sql.py' >> /app/start.sh && \ + echo 'echo "Starting application..."' >> /app/start.sh && \ + echo 'exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"' >> /app/start.sh + +# Make startup scripts executable +RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/test-db.py + # Create timetracker user and ensure postgres user exists RUN useradd -m -u 1000 timetracker && \ chown -R timetracker:timetracker /app /data /app/logs && \ - chown -R postgres:postgres /var/lib/postgresql/data /var/run/postgresql + chown -R postgres:postgres /var/lib/postgresql/data /var/run/postgresql && \ + chmod +x /app/start.sh -# Create a startup script with database initialization -RUN echo '#!/bin/bash\n\ -set -e\n\ -cd /app\n\ -export FLASK_APP=app\n\ -export DATABASE_URL=postgresql+psycopg2://timetracker@localhost:5432/timetracker\n\ -\n\ -echo "=== Starting TimeTracker ==="\n\ -\n\ -# Initialize PostgreSQL if needed\n\ -if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then\n\ - echo "Initializing PostgreSQL..."\n\ - su - postgres -c "/usr/lib/postgresql/*/bin/initdb -D /var/lib/postgresql/data"\n\ - echo "PostgreSQL initialized"\n\ -fi\n\ -\n\ -# Start PostgreSQL in background\n\ -echo "Starting PostgreSQL..."\n\ -su - postgres -c "/usr/lib/postgresql/*/bin/postgres -D /var/lib/postgresql/data" &\n\ -POSTGRES_PID=$!\n\ -\n\ -# Wait for PostgreSQL to be ready\n\ -echo "Waiting for PostgreSQL..."\n\ -sleep 10\n\ -\n\ -# Wait for PostgreSQL to actually be accepting connections\n\ -echo "Checking PostgreSQL connection..."\n\ -until su - postgres -c "/usr/bin/pg_isready -q"; do\n\ - echo "PostgreSQL is not ready yet, waiting..."\n\ - sleep 2\n\ -done\n\ -echo "PostgreSQL is ready!"\n\ -\n\ -# Create database and user\n\ -echo "Setting up database..."\n\ -su - postgres -c "/usr/bin/createdb timetracker" 2>/dev/null || echo "Database exists"\n\ -su - postgres -c "/usr/bin/createuser -s timetracker" 2>/dev/null || echo "User exists"\n\ -\n\ -# Initialize schema if needed or if FORCE_REINIT is set\n\ -if [ ! -f /var/lib/postgresql/data/.initialized ] || [ "$FORCE_REINIT" = "true" ]; then\n\ - echo "Initializing database schema..."\n\ - su - postgres -c "/usr/bin/psql -d timetracker -f /app/docker/init.sql"\n\ - touch /var/lib/postgresql/data/.initialized\n\ - echo "Schema initialized"\n\ -fi\n\ -\n\ -# Wait for database to be ready for Python connection\n\ -echo "Waiting for database to be ready for Python..."\n\ -python - <<"PY"\n\ -import os\n\ -import time\n\ -import sys\n\ -from sqlalchemy import create_engine, text\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - for attempt in range(30):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - with engine.connect() as conn:\n\ - conn.execute(text("SELECT 1"))\n\ - print("Database connection established successfully")\n\ - break\n\ - except Exception as e:\n\ - print(f"Waiting for database... (attempt {attempt+1}/30): {e}")\n\ - time.sleep(2)\n\ - else:\n\ - print("Database not ready after waiting, exiting...")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping connection check")\n\ -PY\n\ -\n\ -echo "Checking if database is initialized..."\n\ -# Check if database is initialized by looking for tables\n\ -python - <<"PY"\n\ -import os\n\ -import sys\n\ -from sqlalchemy import create_engine, text, inspect\n\ -\n\ -url = os.getenv("DATABASE_URL", "")\n\ -if url.startswith("postgresql"):\n\ - try:\n\ - engine = create_engine(url, pool_pre_ping=True)\n\ - inspector = inspect(engine)\n\ - \n\ - # Check if our main tables exist\n\ - existing_tables = inspector.get_table_names()\n\ - required_tables = ["users", "projects", "time_entries", "settings"]\n\ - \n\ - missing_tables = [table for table in required_tables if table not in existing_tables]\n\ - \n\ - if missing_tables:\n\ - print(f"Database not fully initialized. Missing tables: {missing_tables}")\n\ - print("Will initialize database...")\n\ - sys.exit(1) # Exit with error to trigger initialization\n\ - else:\n\ - print("Database is already initialized with all required tables")\n\ - sys.exit(0) # Exit successfully, no initialization needed\n\ - \n\ - except Exception as e:\n\ - print(f"Error checking database initialization: {e}")\n\ - sys.exit(1)\n\ -else:\n\ - print("No PostgreSQL database configured, skipping initialization check")\n\ - sys.exit(0)\n\ -PY\n\ -\n\ -if [ $? -eq 1 ]; then\n\ - echo "Initializing database with Python script..."\n\ - python /app/docker/init-database.py\n\ - if [ $? -eq 0 ]; then\n\ - echo "Database initialized successfully with Python script"\n\ - else\n\ - echo "Python database initialization failed, but continuing..."\n\ - fi\n\ -else\n\ - echo "Database already initialized, skipping initialization"\n\ -fi\n\ -\n\ -# Start Flask app\n\ -echo "Starting Flask application..."\n\ -exec gunicorn --bind 0.0.0.0:8080 --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"\n\ -' > /app/start.sh && chmod +x /app/start.sh +# Verify startup script exists and is accessible +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh # Expose port EXPOSE 8080 diff --git a/Dockerfile.test b/Dockerfile.test deleted file mode 100644 index 52e81bc..0000000 --- a/Dockerfile.test +++ /dev/null @@ -1,7 +0,0 @@ -FROM python:3.11-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -EXPOSE 8080 -CMD ["python", "app.py"] diff --git a/GITHUB_WORKFLOW_IMAGES.md b/GITHUB_WORKFLOW_IMAGES.md index 0cb1492..c1e5f4d 100644 --- a/GITHUB_WORKFLOW_IMAGES.md +++ b/GITHUB_WORKFLOW_IMAGES.md @@ -172,7 +172,7 @@ DATABASE_URL=postgresql://user:password@host:port/database ### Optional for All Images ```bash -TZ=Europe/Brussels +TZ=Europe/Rome CURRENCY=EUR ROUNDING_MINUTES=1 SINGLE_ACTIVE_TIMER=true diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..e43c28d --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,135 @@ +# TimeTracker Project Structure + +This document provides an overview of the cleaned up TimeTracker project structure after removing unnecessary files and consolidating the codebase. + +## ๐Ÿ“ Root Directory Structure + +``` +TimeTracker/ +โ”œโ”€โ”€ ๐Ÿ“ app/ # Main Flask application +โ”œโ”€โ”€ ๐Ÿ“ assets/ # Static assets (images, screenshots) +โ”œโ”€โ”€ ๐Ÿ“ docker/ # Docker configuration files +โ”œโ”€โ”€ ๐Ÿ“ templates/ # Additional template files +โ”œโ”€โ”€ ๐Ÿ“ tests/ # Test suite +โ”œโ”€โ”€ ๐Ÿ“ .github/ # GitHub workflows and configurations +โ”œโ”€โ”€ ๐Ÿ“ logs/ # Application logs (with .gitkeep) +โ”œโ”€โ”€ ๐Ÿณ Dockerfile # Main Dockerfile +โ”œโ”€โ”€ ๐Ÿณ Dockerfile.simple # Simple container Dockerfile +โ”œโ”€โ”€ ๐Ÿ“„ docker-compose.simple.yml # Simple container setup +โ”œโ”€โ”€ ๐Ÿ“„ docker-compose.public.yml # Public container setup +โ”œโ”€โ”€ ๐Ÿ“„ requirements.txt # Python dependencies +โ”œโ”€โ”€ ๐Ÿ“„ app.py # Application entry point +โ”œโ”€โ”€ ๐Ÿ“„ env.example # Environment variables template +โ”œโ”€โ”€ ๐Ÿ“„ README.md # Main project documentation +โ”œโ”€โ”€ ๐Ÿ“„ PROJECT_STRUCTURE.md # This file +โ”œโ”€โ”€ ๐Ÿ“„ CONTRIBUTING.md # Contribution guidelines +โ”œโ”€โ”€ ๐Ÿ“„ CODE_OF_CONDUCT.md # Community code of conduct +โ”œโ”€โ”€ ๐Ÿ“„ LICENSE # GPL v3 license +โ”œโ”€โ”€ ๐Ÿ“„ GITHUB_WORKFLOW_IMAGES.md # Docker image workflow docs +โ”œโ”€โ”€ ๐Ÿ“„ DOCKER_PUBLIC_SETUP.md # Public container setup docs +โ”œโ”€โ”€ ๐Ÿ“„ REQUIREMENTS.md # Detailed requirements documentation +โ”œโ”€โ”€ ๐Ÿ“„ deploy-public.bat # Windows deployment script +โ””โ”€โ”€ ๐Ÿ“„ deploy-public.sh # Linux/Mac deployment script +``` + +## ๐Ÿงน Cleanup Summary + +### Files Removed +- `DATABASE_INIT_FIX_FINAL_README.md` - Database fix documentation (resolved) +- `DATABASE_INIT_FIX_README.md` - Database fix documentation (resolved) +- `TIMEZONE_FIX_README.md` - Timezone fix documentation (resolved) +- `Dockerfile.test` - Test Dockerfile (not needed) +- `Dockerfile.combined` - Combined Dockerfile (consolidated) +- `docker-compose.yml` - Old compose file (replaced) +- `deploy.sh` - Old deployment script (replaced) +- `index.html` - Unused HTML file +- `_config.yml` - Unused config file +- `logs/timetracker.log` - Large log file (not in version control) +- `.pytest_cache/` - Python test cache directory + +### Files Consolidated +- **Dockerfiles**: Now only `Dockerfile` and `Dockerfile.simple` +- **Docker Compose**: Now only `docker-compose.simple.yml` and `docker-compose.public.yml` +- **Deployment**: Now only `deploy-public.bat` and `deploy-public.sh` + +## ๐Ÿ—๏ธ Core Components + +### Application (`app/`) +- **Models**: Database models for users, projects, time entries, and settings +- **Routes**: API endpoints and web routes +- **Templates**: Jinja2 HTML templates +- **Utils**: Utility functions including timezone management +- **Config**: Application configuration + +### Docker Configuration (`docker/`) +- **Startup scripts**: Container initialization and database setup +- **Database scripts**: SQL-based database initialization +- **Configuration files**: Docker-specific configurations + +### Templates (`templates/`) +- **Admin templates**: User management and system settings +- **Error templates**: Error page templates +- **Main templates**: Core application templates +- **Project templates**: Project management templates +- **Report templates**: Reporting and analytics templates +- **Timer templates**: Time tracking interface templates + +### Assets (`assets/`) +- **Screenshots**: Application screenshots for documentation +- **Images**: Logo and other static images + +## ๐Ÿš€ Deployment Options + +### 1. Simple Container (Recommended) +- **File**: `docker-compose.simple.yml` +- **Dockerfile**: `Dockerfile.simple` +- **Features**: All-in-one with PostgreSQL database +- **Use case**: Production deployment + +### 2. Public Container +- **File**: `docker-compose.public.yml` +- **Dockerfile**: `Dockerfile` +- **Features**: External database configuration +- **Use case**: Development and testing + +## ๐Ÿ“š Documentation Files + +- **README.md**: Main project documentation and quick start guide +- **PROJECT_STRUCTURE.md**: This file - project structure overview +- **CONTRIBUTING.md**: How to contribute to the project +- **CODE_OF_CONDUCT.md**: Community behavior guidelines +- **GITHUB_WORKFLOW_IMAGES.md**: Docker image build workflow +- **DOCKER_PUBLIC_SETUP.md**: Public container setup guide +- **REQUIREMENTS.md**: Detailed system requirements + +## ๐Ÿ”ง Development Files + +- **requirements.txt**: Python package dependencies +- **app.py**: Flask application entry point +- **env.example**: Environment variables template +- **tests/**: Test suite and test files + +## ๐Ÿ“ Key Improvements Made + +1. **Removed Duplicate Files**: Eliminated redundant documentation and configuration files +2. **Consolidated Docker Setup**: Streamlined to two main container types +3. **Updated Documentation**: README now reflects current project state +4. **Timezone Support**: Added comprehensive timezone management (100+ options) +5. **Clean Structure**: Organized project for better maintainability + +## ๐ŸŽฏ Getting Started + +1. **Choose deployment type**: Simple container (recommended) or public container +2. **Follow README.md**: Complete setup instructions +3. **Use appropriate compose file**: `docker-compose.simple.yml` or `docker-compose.public.yml` +4. **Configure timezone**: Access admin settings to set your local timezone + +## ๐Ÿ” File Purposes + +- **`.gitkeep` files**: Ensure empty directories are tracked in Git +- **`.github/`**: GitHub Actions workflows for automated builds +- **`logs/`**: Application log storage (cleaned up, only `.gitkeep` remains) +- **`LICENSE`**: GPL v3 open source license +- **`.gitignore`**: Git ignore patterns for temporary files + +This cleaned up structure provides a more maintainable and focused codebase while preserving all essential functionality and documentation. diff --git a/README.md b/README.md index ee00029..5cca550 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A robust, self-hosted time tracking application designed for teams and freelance - **Manual Entry**: Log time with start/end dates and project selection - **Idle Detection**: Automatic timeout for inactive sessions - **Multiple Projects**: Track time across different clients and projects +- **Timezone Support**: Full timezone awareness with configurable local time display ### ๐Ÿ‘ฅ User Management - **Role-Based Access**: Admin and regular user roles @@ -53,9 +54,10 @@ A robust, self-hosted time tracking application designed for teams and freelance ### ๐Ÿš€ Technical Features - **Responsive Design**: Works on desktop, tablet, and mobile - **HTMX Integration**: Dynamic interactions without JavaScript complexity -- **SQLite Database**: Lightweight, file-based storage +- **PostgreSQL Database**: Robust database with automatic initialization - **Docker Ready**: Easy deployment and scaling - **RESTful API**: Programmatic access to time data +- **Timezone Management**: Comprehensive timezone support with 100+ options ## ๐Ÿ–ผ๏ธ Screenshots @@ -110,6 +112,7 @@ The **simple container** is an all-in-one solution that includes both the TimeTr - โœ… **Auto-initialization**: Database automatically created and configured - โœ… **Persistent storage**: Data survives container restarts - โœ… **Production ready**: Optimized for deployment +- โœ… **Timezone support**: Full timezone management with 100+ options **Run with docker-compose:** ```bash @@ -134,7 +137,7 @@ docker run -d \ **Environment Variables:** - `FORCE_REINIT`: Set to `true` to reinitialize database schema (default: `false`) -- `TZ`: Timezone (default: `Europe/Brussels`) +- `TZ`: Timezone (default: `Europe/Rome`) #### 2. Public Container (Development/Testing) @@ -209,6 +212,7 @@ docker-compose -f docker-compose.simple.yml up -d - โœ… **Production ready** - Optimized for deployment - โœ… **Persistent storage** - Data survives restarts - โœ… **Simple setup** - One command deployment +- โœ… **Timezone support** - 100+ timezone options with automatic DST handling **Default credentials:** - **Username**: `admin` @@ -236,7 +240,7 @@ The container automatically: 2. **Configure environment variables:** ```bash - cp .env.example .env + cp env.example .env # Edit .env with your database settings ``` @@ -286,7 +290,7 @@ The container automatically: | Variable | Description | Default | |----------|-------------|---------| | `FORCE_REINIT` | Reinitialize database schema | `false` | -| `TZ` | Timezone | `Europe/Brussels` | +| `TZ` | Timezone | `Europe/Rome` | #### Public Container Environment Variables @@ -294,7 +298,7 @@ The container automatically: |----------|-------------|---------| | `DATABASE_URL` | Database connection string | - | | `SECRET_KEY` | Flask secret key | - | -| `TZ` | Timezone | `Europe/Brussels` | +| `TZ` | Timezone | `Europe/Rome` | | `CURRENCY` | Currency for billing | `EUR` | | `ROUNDING_MINUTES` | Time rounding in minutes | `1` | | `SINGLE_ACTIVE_TIMER` | Allow only one active timer per user | `true` | @@ -333,15 +337,23 @@ The container automatically: 3. **Assign users** to projects 4. **Track project status** and **completion** +### Timezone Configuration + +1. **Access Admin Settings** (admin users only) +2. **Select your timezone** from 100+ available options +3. **View real-time preview** of current time in selected timezone +4. **Save settings** to apply timezone changes application-wide + ## ๐Ÿ—๏ธ Architecture ### Technology Stack - **Backend**: Flask with SQLAlchemy ORM -- **Database**: SQLite (with upgrade path to PostgreSQL) +- **Database**: PostgreSQL with automatic initialization - **Frontend**: Server-rendered templates with HTMX - **Real-time**: WebSocket for live timer updates - **Containerization**: Docker with docker-compose +- **Timezone**: Full timezone support with pytz ### Project Structure @@ -355,7 +367,9 @@ TimeTracker/ โ”‚ โ””โ”€โ”€ config.py # Configuration settings โ”œโ”€โ”€ docker/ # Docker configuration โ”œโ”€โ”€ tests/ # Test suite -โ”œโ”€โ”€ docker-compose.yml # Docker Compose configuration +โ”œโ”€โ”€ docker-compose.simple.yml # Simple container setup +โ”œโ”€โ”€ docker-compose.public.yml # Public container setup +โ”œโ”€โ”€ Dockerfile.simple # Simple container Dockerfile โ”œโ”€โ”€ requirements.txt # Python dependencies โ””โ”€โ”€ README.md # This file ``` @@ -367,7 +381,7 @@ TimeTracker/ - **Users**: Username-based authentication with role-based access - **Projects**: Client projects with billing information and client management - **Time Entries**: Manual and automatic time tracking with notes, tags, and billing support -- **Settings**: System configuration and preferences +- **Settings**: System configuration including timezone preferences #### Database Schema @@ -391,6 +405,7 @@ The simple container automatically creates and initializes a PostgreSQL database - **Billing Support**: Hourly rates, billable flags, and cost calculations - **Export Capabilities**: CSV export for reports and data backup - **Responsive Design**: Works on desktop and mobile devices +- **Timezone Support**: Full timezone awareness with automatic DST handling ## ๐Ÿ› ๏ธ Development @@ -408,7 +423,7 @@ The simple container automatically creates and initializes a PostgreSQL database 3. **Set up environment:** ```bash - cp .env.example .env + cp env.example .env # Edit .env with development settings ``` @@ -451,7 +466,7 @@ python -m pytest tests/test_timer.py ## ๐Ÿ’พ Backup and Maintenance -- **Automatic backups**: Nightly SQLite database backups +- **Automatic backups**: Nightly PostgreSQL database backups - **Manual exports**: On-demand CSV exports and full data dumps - **Health monitoring**: Built-in health check endpoints - **Database migrations**: Version-controlled schema changes @@ -498,6 +513,7 @@ The GPL v3 license ensures that: - **Database errors**: Ensure proper permissions and disk space - **Docker issues**: Verify Docker and Docker Compose installation - **Network access**: Check firewall settings and port configuration +- **Timezone issues**: Verify timezone settings in admin panel ## ๐Ÿš€ Roadmap @@ -515,6 +531,7 @@ The GPL v3 license ensures that: - **v1.0.0**: Initial release with core time tracking features - **v1.1.0**: Added comprehensive reporting and export capabilities - **v1.2.0**: Enhanced project management and billing support +- **v1.3.0**: Added comprehensive timezone support with 100+ options ## ๐Ÿ™ Acknowledgments diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2aefd73..e6d1ac3 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -162,7 +162,7 @@ A Python backend (Flask recommended) runs inside Docker on a Raspberry Pi. The f ### 6.4 Configurability -* `.env`/config UI for: timezone (default Europe/Brussels), currency, default rounding, allow self-register, single-active-timer, idle timeout, export delimiter. +* `.env`/config UI for: timezone (default Europe/Rome), currency, default rounding, allow self-register, single-active-timer, idle timeout, export delimiter. ### 6.5 Security @@ -366,7 +366,7 @@ services: image: timetracker:latest build: . environment: - - TZ=Europe/Brussels + - TZ=Europe/Rome - ROUNDING_MINUTES=1 - SINGLE_ACTIVE_TIMER=true - ALLOW_SELF_REGISTER=true diff --git a/_config.yml b/_config.yml deleted file mode 100644 index f60af69..0000000 --- a/_config.yml +++ /dev/null @@ -1,73 +0,0 @@ -# Jekyll configuration for GitHub Pages -# This file ensures proper configuration for the static site - -# Site settings -title: TimeTracker -description: Self-hosted time tracking for teams and freelancers -url: "https://drytrix.github.io" -baseurl: "/TimeTracker" - -# Build settings -markdown: kramdown -highlighter: rouge -permalink: pretty - -# Collections -collections: - assets: - output: true - permalink: /:collection/:name - -# Exclude from processing -exclude: - - README.md - - LICENSE - - CONTRIBUTING.md - - CODE_OF_CONDUCT.md - - requirements.txt - - app.py - - docker-compose.yml - - Dockerfile - - deploy.sh - - .env.example - - .gitignore - - app/ - - docker/ - - logs/ - - tests/ - - templates/ - - .github/ - -# Include in processing -include: - - _config.yml - - index.html - - assets/ - -# Plugins (GitHub Pages compatible) -plugins: - - jekyll-seo-tag - - jekyll-sitemap - -# SEO settings -author: TimeTracker Team -email: your-email@example.com -github_username: DRYTRIX -github_repo: TimeTracker - -# Social media -social: - name: TimeTracker - links: - - https://github.com/DRYTRIX/TimeTracker - - https://github.com/DRYTRIX/TimeTracker/issues - - https://github.com/DRYTRIX/TimeTracker/discussions - -# Defaults -defaults: - - scope: - path: "" - type: "pages" - values: - layout: "default" - author: "TimeTracker Team" diff --git a/app/__init__.py b/app/__init__.py index afa48a9..5b274a3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -109,6 +109,10 @@ def create_app(config=None): from app.utils.context_processors import register_context_processors register_context_processors(app) + # Register template filters + from app.utils.template_filters import register_template_filters + register_template_filters(app) + # Register CLI commands from app.utils.cli import register_cli_commands register_cli_commands(app) diff --git a/app/config.py b/app/config.py index d9f9bd8..6040881 100644 --- a/app/config.py +++ b/app/config.py @@ -29,7 +29,7 @@ class Config: ) # Application settings - TZ = os.getenv('TZ', 'Europe/Brussels') + TZ = os.getenv('TZ', 'Europe/Rome') CURRENCY = os.getenv('CURRENCY', 'EUR') ROUNDING_MINUTES = int(os.getenv('ROUNDING_MINUTES', 1)) SINGLE_ACTIVE_TIMER = os.getenv('SINGLE_ACTIVE_TIMER', 'true').lower() == 'true' diff --git a/app/models/project.py b/app/models/project.py index c02c2f5..aa7c932 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -45,7 +45,7 @@ class Project(db.Model): db.func.sum(TimeEntry.duration_seconds) ).filter( TimeEntry.project_id == self.id, - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).scalar() or 0 return round(total_seconds / 3600, 2) @@ -57,7 +57,7 @@ class Project(db.Model): db.func.sum(TimeEntry.duration_seconds) ).filter( TimeEntry.project_id == self.id, - TimeEntry.end_utc.isnot(None), + TimeEntry.end_time.isnot(None), TimeEntry.billable == True ).scalar() or 0 return round(total_seconds / 3600, 2) @@ -72,18 +72,18 @@ class Project(db.Model): def get_entries_by_user(self, user_id=None, start_date=None, end_date=None): """Get time entries for this project, optionally filtered by user and date range""" from .time_entry import TimeEntry - query = self.time_entries.filter(TimeEntry.end_utc.isnot(None)) + query = self.time_entries.filter(TimeEntry.end_time.isnot(None)) if user_id: query = query.filter(TimeEntry.user_id == user_id) if start_date: - query = query.filter(TimeEntry.start_utc >= start_date) + query = query.filter(TimeEntry.start_time >= start_date) if end_date: - query = query.filter(TimeEntry.start_utc <= end_date) + query = query.filter(TimeEntry.start_time <= end_date) - return query.order_by(TimeEntry.start_utc.desc()).all() + return query.order_by(TimeEntry.start_time.desc()).all() def get_user_totals(self, start_date=None, end_date=None): """Get total hours per user for this project""" @@ -95,14 +95,14 @@ class Project(db.Model): db.func.sum(TimeEntry.duration_seconds).label('total_seconds') ).join(TimeEntry).filter( TimeEntry.project_id == self.id, - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ) if start_date: - query = query.filter(TimeEntry.start_utc >= start_date) + query = query.filter(TimeEntry.start_time >= start_date) if end_date: - query = query.filter(TimeEntry.start_utc <= end_date) + query = query.filter(TimeEntry.start_time <= end_date) results = query.group_by(User.username).all() diff --git a/app/models/settings.py b/app/models/settings.py index 5e1898c..439c627 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -8,7 +8,7 @@ class Settings(db.Model): __tablename__ = 'settings' id = db.Column(db.Integer, primary_key=True) - timezone = db.Column(db.String(50), default='Europe/Brussels', nullable=False) + timezone = db.Column(db.String(50), default='Europe/Rome', nullable=False) currency = db.Column(db.String(3), default='EUR', nullable=False) rounding_minutes = db.Column(db.Integer, default=1, nullable=False) single_active_timer = db.Column(db.Boolean, default=True, nullable=False) diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 85be8a6..36bb2fa 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -1,6 +1,14 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from app import db from app.config import Config +from app.utils.timezone import utc_to_local, local_to_utc + +def local_now(): + """Get current time in local timezone as naive datetime (for database storage)""" + from app.utils.timezone import get_timezone_obj + tz = get_timezone_obj() + now = datetime.now(tz) + return now.replace(tzinfo=None) class TimeEntry(db.Model): """Time entry model for manual and automatic time tracking""" @@ -10,28 +18,28 @@ class TimeEntry(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - start_utc = db.Column(db.DateTime, nullable=False, index=True) - end_utc = db.Column(db.DateTime, nullable=True, index=True) + start_time = db.Column(db.DateTime, nullable=False, index=True) + end_time = db.Column(db.DateTime, nullable=True, index=True) duration_seconds = db.Column(db.Integer, nullable=True) notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto' billable = db.Column(db.Boolean, default=True, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = db.Column(db.DateTime, default=local_now, nullable=False) + updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - def __init__(self, user_id, project_id, start_utc, end_utc=None, notes=None, tags=None, source='manual', billable=True): + def __init__(self, user_id, project_id, start_time, end_time=None, notes=None, tags=None, source='manual', billable=True): self.user_id = user_id self.project_id = project_id - self.start_utc = start_utc - self.end_utc = end_utc + self.start_time = start_time + self.end_time = end_time self.notes = notes.strip() if notes else None self.tags = tags.strip() if tags else None self.source = source self.billable = billable # Calculate duration if end time is provided - if self.end_utc: + if self.end_time: self.calculate_duration() def __repr__(self): @@ -40,7 +48,7 @@ class TimeEntry(db.Model): @property def is_active(self): """Check if this is an active timer (no end time)""" - return self.end_utc is None + return self.end_time is None @property def duration_hours(self): @@ -71,20 +79,30 @@ class TimeEntry(db.Model): @property def current_duration_seconds(self): """Calculate current duration for active timers""" - if self.end_utc: + if self.end_time: return self.duration_seconds or 0 # For active timers, calculate from start time to now - duration = datetime.utcnow() - self.start_utc + # Since we store everything in local timezone, we can work with naive datetimes + # as long as we treat them as local time + + # Get current time in local timezone (naive, matching database storage) + now_local = local_now() + + # Calculate duration (both times are treated as local time) + duration = now_local - self.start_time return int(duration.total_seconds()) def calculate_duration(self): """Calculate and set duration in seconds with rounding""" - if not self.end_utc: + if not self.end_time: return - # Calculate raw duration - duration = self.end_utc - self.start_utc + # Since we store everything in local timezone, we can work with naive datetimes + # as long as we treat them as local time + + # Calculate raw duration (both times are treated as local time) + duration = self.end_time - self.start_time raw_seconds = int(duration.total_seconds()) # Apply rounding @@ -97,33 +115,38 @@ class TimeEntry(db.Model): else: self.duration_seconds = raw_seconds - def stop_timer(self, end_utc=None): + def stop_timer(self, end_time=None): """Stop an active timer""" - if self.end_utc: + if self.end_time: raise ValueError("Timer is already stopped") - self.end_utc = end_utc or datetime.utcnow() + # Use local timezone for consistency with database storage + if end_time: + self.end_time = end_time + else: + self.end_time = local_now() + self.calculate_duration() - self.updated_at = datetime.utcnow() + self.updated_at = local_now() db.session.commit() def update_notes(self, notes): """Update notes for this entry""" self.notes = notes.strip() if notes else None - self.updated_at = datetime.utcnow() + self.updated_at = local_now() db.session.commit() def update_tags(self, tags): """Update tags for this entry""" self.tags = tags.strip() if tags else None - self.updated_at = datetime.utcnow() + self.updated_at = local_now() db.session.commit() def set_billable(self, billable): """Set billable status""" self.billable = billable - self.updated_at = datetime.utcnow() + self.updated_at = local_now() db.session.commit() def to_dict(self): @@ -132,8 +155,8 @@ class TimeEntry(db.Model): 'id': self.id, 'user_id': self.user_id, 'project_id': self.project_id, - 'start_utc': self.start_utc.isoformat() if self.start_utc else None, - 'end_utc': self.end_utc.isoformat() if self.end_utc else None, + 'start_time': self.start_time.isoformat() if self.start_time else None, + 'end_time': self.end_time.isoformat() if self.end_time else None, 'duration_seconds': self.duration_seconds, 'duration_hours': self.duration_hours, 'duration_formatted': self.duration_formatted, @@ -152,23 +175,23 @@ class TimeEntry(db.Model): @classmethod def get_active_timers(cls): """Get all active timers""" - return cls.query.filter_by(end_utc=None).all() + return cls.query.filter_by(end_time=None).all() @classmethod def get_user_active_timer(cls, user_id): """Get active timer for a specific user""" - return cls.query.filter_by(user_id=user_id, end_utc=None).first() + return cls.query.filter_by(user_id=user_id, end_time=None).first() @classmethod def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None): """Get time entries for a specific period with optional filters""" - query = cls.query.filter(cls.end_utc.isnot(None)) + query = cls.query.filter(cls.end_time.isnot(None)) if start_date: - query = query.filter(cls.start_utc >= start_date) + query = query.filter(cls.start_time >= start_date) if end_date: - query = query.filter(cls.start_utc <= end_date) + query = query.filter(cls.start_time <= end_date) if user_id: query = query.filter(cls.user_id == user_id) @@ -176,7 +199,7 @@ class TimeEntry(db.Model): if project_id: query = query.filter(cls.project_id == project_id) - return query.order_by(cls.start_utc.desc()).all() + return query.order_by(cls.start_time.desc()).all() @classmethod def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False): @@ -184,10 +207,10 @@ class TimeEntry(db.Model): query = db.session.query(db.func.sum(cls.duration_seconds)) if start_date: - query = query.filter(cls.start_utc >= start_date) + query = query.filter(cls.start_time >= start_date) if end_date: - query = query.filter(cls.start_utc <= end_date) + query = query.filter(cls.start_time <= end_date) if user_id: query = query.filter(cls.user_id == user_id) diff --git a/app/models/user.py b/app/models/user.py index 9e1479a..b4c146e 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -36,7 +36,7 @@ class User(UserMixin, db.Model): from .time_entry import TimeEntry return TimeEntry.query.filter_by( user_id=self.id, - end_utc=None + end_time=None ).first() @property @@ -47,7 +47,7 @@ class User(UserMixin, db.Model): db.func.sum(TimeEntry.duration_seconds) ).filter( TimeEntry.user_id == self.id, - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).scalar() or 0 return round(total_seconds / 3600, 2) @@ -55,9 +55,9 @@ class User(UserMixin, db.Model): """Get recent time entries for this user""" from .time_entry import TimeEntry return self.time_entries.filter( - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).order_by( - TimeEntry.start_utc.desc() + TimeEntry.start_time.desc() ).limit(limit).all() def update_last_login(self): diff --git a/app/routes/admin.py b/app/routes/admin.py index 5b28ed2..645a5d8 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -28,12 +28,12 @@ def admin_dashboard(): active_users = User.query.filter_by(is_active=True).count() total_projects = Project.query.count() active_projects = Project.query.filter_by(status='active').count() - total_entries = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)).count() - active_timers = TimeEntry.query.filter_by(end_utc=None).count() + total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count() + active_timers = TimeEntry.query.filter_by(end_time=None).count() # Get recent activity recent_entries = TimeEntry.query.filter( - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).order_by( TimeEntry.created_at.desc() ).limit(10).all() @@ -163,8 +163,17 @@ def settings(): settings_obj = Settings.get_settings() if request.method == 'POST': + # Validate timezone + timezone = request.form.get('timezone', 'Europe/Rome') + try: + import pytz + pytz.timezone(timezone) # This will raise an exception if timezone is invalid + except pytz.exceptions.UnknownTimeZoneError: + flash(f'Invalid timezone: {timezone}', 'error') + return render_template('admin/settings.html', settings=settings_obj) + # Update settings - settings_obj.timezone = request.form.get('timezone', 'Europe/Brussels') + settings_obj.timezone = timezone settings_obj.currency = request.form.get('currency', 'EUR') settings_obj.rounding_minutes = int(request.form.get('rounding_minutes', 1)) settings_obj.single_active_timer = request.form.get('single_active_timer') == 'on' @@ -199,7 +208,7 @@ def system_info(): total_users = User.query.count() total_projects = Project.query.count() total_entries = TimeEntry.query.count() - active_timers = TimeEntry.query.filter_by(end_utc=None).count() + active_timers = TimeEntry.query.filter_by(end_time=None).count() # Get database size db_size_bytes = 0 diff --git a/app/routes/api.py b/app/routes/api.py index 8422150..4274fe3 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -25,7 +25,7 @@ def timer_status(): 'id': active_timer.id, 'project_name': active_timer.project.name, 'project_id': active_timer.project_id, - 'start_time': active_timer.start_utc.isoformat(), + 'start_time': active_timer.start_time.isoformat(), 'current_duration': active_timer.current_duration_seconds, 'duration_formatted': active_timer.duration_formatted } @@ -56,10 +56,11 @@ def api_start_timer(): return jsonify({'error': 'User already has an active timer'}), 400 # Create new timer + from app.models.time_entry import local_now new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, - start_utc=datetime.utcnow(), + start_time=local_now(), source='auto' ) @@ -71,7 +72,7 @@ def api_start_timer(): 'user_id': current_user.id, 'timer_id': new_timer.id, 'project_name': project.name, - 'start_time': new_timer.start_utc.isoformat() + 'start_time': new_timer.start_time.isoformat() }) return jsonify({ @@ -114,7 +115,7 @@ def get_entries(): user_id = request.args.get('user_id', type=int) project_id = request.args.get('project_id', type=int) - query = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)) + query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) # Filter by user (if admin or own entries) if user_id and current_user.is_admin: @@ -126,7 +127,7 @@ def get_entries(): if project_id: query = query.filter(TimeEntry.project_id == project_id) - entries = query.order_by(TimeEntry.start_utc.desc()).paginate( + entries = query.order_by(TimeEntry.start_time.desc()).paginate( page=page, per_page=per_page, error_out=False diff --git a/app/routes/main.py b/app/routes/main.py index 2c0a46a..db3cf0c 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -93,12 +93,12 @@ def search(): # Search in time entries entries = TimeEntry.query.filter( TimeEntry.user_id == current_user.id, - TimeEntry.end_utc.isnot(None), + TimeEntry.end_time.isnot(None), db.or_( TimeEntry.notes.contains(query), TimeEntry.tags.contains(query) ) - ).order_by(TimeEntry.start_utc.desc()).paginate( + ).order_by(TimeEntry.start_time.desc()).paginate( page=page, per_page=20, error_out=False diff --git a/app/routes/projects.py b/app/routes/projects.py index 063436c..4080f70 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -111,9 +111,9 @@ def view_project(project_id): # Get time entries for this project page = request.args.get('page', 1, type=int) entries_pagination = project.time_entries.filter( - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).order_by( - TimeEntry.start_utc.desc() + TimeEntry.start_time.desc() ).paginate( page=page, per_page=50, diff --git a/app/routes/reports.py b/app/routes/reports.py index 0da3b33..81a29be 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -15,14 +15,14 @@ def reports(): """Main reports page""" # Aggregate totals (scope by user unless admin) totals_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ) billable_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_utc.isnot(None), + TimeEntry.end_time.isnot(None), TimeEntry.billable == True ) - entries_query = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)) + entries_query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) if not current_user.is_admin: totals_query = totals_query.filter(TimeEntry.user_id == current_user.id) @@ -39,7 +39,7 @@ def reports(): 'total_users': User.query.filter_by(is_active=True).count(), } - recent_entries = entries_query.order_by(TimeEntry.start_utc.desc()).limit(10).all() + recent_entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(10).all() return render_template('reports/index.html', summary=summary, recent_entries=recent_entries) @@ -71,9 +71,9 @@ def project_report(): # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_utc.isnot(None), - TimeEntry.start_utc >= start_dt, - TimeEntry.start_utc <= end_dt + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt ) if project_id: @@ -82,7 +82,7 @@ def project_report(): if user_id: query = query.filter(TimeEntry.user_id == user_id) - entries = query.order_by(TimeEntry.start_utc.desc()).all() + entries = query.order_by(TimeEntry.start_time.desc()).all() # Aggregate by project for template expectations projects_map = {} @@ -179,9 +179,9 @@ def user_report(): # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_utc.isnot(None), - TimeEntry.start_utc >= start_dt, - TimeEntry.start_utc <= end_dt + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt ) if user_id: @@ -190,7 +190,7 @@ def user_report(): if project_id: query = query.filter(TimeEntry.project_id == project_id) - entries = query.order_by(TimeEntry.start_utc.desc()).all() + entries = query.order_by(TimeEntry.start_time.desc()).all() # Calculate totals total_hours = sum(entry.duration_hours for entry in entries) @@ -259,9 +259,9 @@ def export_csv(): # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_utc.isnot(None), - TimeEntry.start_utc >= start_dt, - TimeEntry.start_utc <= end_dt + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt ) if user_id: @@ -270,7 +270,7 @@ def export_csv(): if project_id: query = query.filter(TimeEntry.project_id == project_id) - entries = query.order_by(TimeEntry.start_utc.desc()).all() + entries = query.order_by(TimeEntry.start_time.desc()).all() # Get settings for delimiter settings = Settings.get_settings() @@ -294,8 +294,8 @@ def export_csv(): entry.user.username, entry.project.name, entry.project.client, - entry.start_utc.isoformat(), - entry.end_utc.isoformat() if entry.end_utc else '', + entry.start_time.isoformat(), + entry.end_time.isoformat() if entry.end_time else '', entry.duration_hours, entry.duration_formatted, entry.notes or '', diff --git a/app/routes/timer.py b/app/routes/timer.py index 17892e7..baa877b 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -2,6 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_required, current_user from app import db, socketio from app.models import User, Project, TimeEntry, Settings +from app.utils.timezone import parse_local_datetime from datetime import datetime import json @@ -36,10 +37,11 @@ def start_timer(): return redirect(url_for('main.dashboard')) # Create new timer + from app.models.time_entry import local_now new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, - start_utc=datetime.utcnow(), + start_time=local_now(), source='auto' ) @@ -51,7 +53,7 @@ def start_timer(): 'user_id': current_user.id, 'timer_id': new_timer.id, 'project_name': project.name, - 'start_time': new_timer.start_utc.isoformat() + 'start_time': new_timer.start_time.isoformat() }) flash(f'Timer started for {project.name}', 'success') @@ -97,7 +99,7 @@ def timer_status(): 'timer': { 'id': active_timer.id, 'project_name': active_timer.project.name, - 'start_time': active_timer.start_utc.isoformat(), + 'start_time': active_timer.start_time.isoformat(), 'current_duration': active_timer.current_duration_seconds, 'duration_formatted': active_timer.duration_formatted } @@ -176,16 +178,16 @@ def manual_entry(): flash('Invalid project selected', 'error') return render_template('timer/manual_entry.html', projects=active_projects) - # Parse datetime + # Parse datetime with timezone awareness try: - start_utc = datetime.strptime(f'{start_date} {start_time}', '%Y-%m-%d %H:%M') - end_utc = datetime.strptime(f'{end_date} {end_time}', '%Y-%m-%d %H:%M') + start_time_parsed = parse_local_datetime(start_date, start_time) + end_time_parsed = parse_local_datetime(end_date, end_time) except ValueError: flash('Invalid date/time format', 'error') return render_template('timer/manual_entry.html', projects=active_projects) # Validate time range - if end_utc <= start_utc: + if end_time_parsed <= start_time_parsed: flash('End time must be after start time', 'error') return render_template('timer/manual_entry.html', projects=active_projects) @@ -193,8 +195,8 @@ def manual_entry(): entry = TimeEntry( user_id=current_user.id, project_id=project_id, - start_utc=start_utc, - end_utc=end_utc, + start_time=start_time_parsed, + end_time=end_time_parsed, notes=notes, tags=tags, source='manual', diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index 0f8f85a..3f0b5af 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -61,7 +61,7 @@ {{ active_timer.duration_formatted }} - Started at {{ active_timer.start_utc.strftime('%H:%M') }} + Started at {{ active_timer.start_time.strftime('%H:%M') }} @@ -254,8 +254,8 @@
- {{ entry.start_utc.strftime('%b %d') }} - {{ entry.start_utc.strftime('%H:%M') }} + {{ entry.start_time.strftime('%b %d') }} + {{ entry.start_time.strftime('%H:%M') }}
diff --git a/app/templates/main/search.html b/app/templates/main/search.html index fa28bcf..b70e1dd 100644 --- a/app/templates/main/search.html +++ b/app/templates/main/search.html @@ -45,8 +45,8 @@ {{ entry.project.name }} - {{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }} - {{ entry.end_utc.strftime('%Y-%m-%d %H:%M') if entry.end_utc else '-' }} + {{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} + {{ entry.end_time.strftime('%Y-%m-%d %H:%M') if entry.end_time else '-' }} {{ entry.duration_formatted }} {% if entry.notes %} diff --git a/app/utils/cli.py b/app/utils/cli.py index 4cd27a7..d120c91 100644 --- a/app/utils/cli.py +++ b/app/utils/cli.py @@ -86,7 +86,7 @@ def register_cli_commands(app): cutoff_date = datetime.utcnow() - timedelta(days=days) old_entries = TimeEntry.query.filter( - TimeEntry.end_utc < cutoff_date + TimeEntry.end_time < cutoff_date ).all() if not old_entries: @@ -111,8 +111,8 @@ def register_cli_commands(app): total_projects = Project.query.count() active_projects = Project.query.filter_by(status='active').count() total_entries = TimeEntry.query.count() - completed_entries = TimeEntry.query.filter(TimeEntry.end_utc.isnot(None)).count() - active_timers = TimeEntry.query.filter_by(end_utc=None).count() + completed_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count() + active_timers = TimeEntry.query.filter_by(end_time=None).count() click.echo("Database Statistics:") click.echo(f" Users: {total_users} (active: {active_users})") @@ -123,7 +123,7 @@ def register_cli_commands(app): total_hours = db.session.query( db.func.sum(TimeEntry.duration_seconds) ).filter( - TimeEntry.end_utc.isnot(None) + TimeEntry.end_time.isnot(None) ).scalar() or 0 total_hours = round(total_hours / 3600, 2) diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index e756647..bca3e9f 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -1,5 +1,6 @@ from flask import g, request from app.models import Settings +from app.utils.timezone import get_timezone_offset_for_timezone def register_context_processors(app): """Register context processors for the application""" @@ -8,26 +9,48 @@ def register_context_processors(app): def inject_settings(): """Inject settings into all templates""" try: - settings = Settings.get_settings() - return { - 'settings': settings, - 'currency': settings.currency, - 'timezone': settings.timezone - } - except: - # Return defaults if settings not available - return { - 'settings': None, - 'currency': 'EUR', - 'timezone': 'Europe/Brussels' - } + from app import db + # Check if we have an active database session + if db.session.is_active: + settings = Settings.get_settings() + return { + 'settings': settings, + 'currency': settings.currency, + 'timezone': settings.timezone + } + except Exception as e: + # Log the error but continue with defaults + print(f"Warning: Could not inject settings: {e}") + pass + + # Return defaults if settings not available + return { + 'settings': None, + 'currency': 'EUR', + 'timezone': 'Europe/Rome' + } @app.context_processor def inject_globals(): """Inject global variables into all templates""" + try: + from app import db + # Check if we have an active database session + if db.session.is_active: + settings = Settings.get_settings() + timezone_name = settings.timezone if settings else 'Europe/Rome' + else: + timezone_name = 'Europe/Rome' + except Exception as e: + # Log the error but continue with defaults + print(f"Warning: Could not inject globals: {e}") + timezone_name = 'Europe/Rome' + return { 'app_name': 'Time Tracker', - 'app_version': '1.0.0' + 'app_version': '1.0.0', + 'timezone': timezone_name, + 'timezone_offset': get_timezone_offset_for_timezone(timezone_name) } @app.before_request diff --git a/app/utils/template_filters.py b/app/utils/template_filters.py new file mode 100644 index 0000000..cc63d74 --- /dev/null +++ b/app/utils/template_filters.py @@ -0,0 +1,33 @@ +from flask import Blueprint +from app.utils.timezone import utc_to_local, format_local_datetime + +def register_template_filters(app): + """Register custom template filters for the application""" + + @app.template_filter('local_datetime') + def local_datetime_filter(utc_dt, format_str='%Y-%m-%d %H:%M'): + """Convert UTC datetime to local timezone for display""" + if utc_dt is None: + return "" + return format_local_datetime(utc_dt, format_str) + + @app.template_filter('local_date') + def local_date_filter(utc_dt): + """Convert UTC datetime to local date only""" + if utc_dt is None: + return "" + return format_local_datetime(utc_dt, '%Y-%m-%d') + + @app.template_filter('local_time') + def local_time_filter(utc_dt): + """Convert UTC datetime to local time only""" + if utc_dt is None: + return "" + return format_local_datetime(utc_dt, '%H:%M') + + @app.template_filter('local_datetime_short') + def local_datetime_short_filter(utc_dt): + """Convert UTC datetime to local timezone in short format""" + if utc_dt is None: + return "" + return format_local_datetime(utc_dt, '%m/%d %H:%M') diff --git a/app/utils/timezone.py b/app/utils/timezone.py new file mode 100644 index 0000000..035327d --- /dev/null +++ b/app/utils/timezone.py @@ -0,0 +1,113 @@ +import os +import pytz +from datetime import datetime, timezone +from flask import current_app + +def get_app_timezone(): + """Get the application's configured timezone from database settings or environment""" + try: + # Try to get timezone from database settings first + from app.models import Settings + from app import db + + # Check if we have a database connection + if db.session.is_active: + try: + settings = Settings.get_settings() + if settings and settings.timezone: + return settings.timezone + except Exception as e: + # Log the error but continue with fallback + print(f"Warning: Could not get timezone from database: {e}") + pass + except Exception as e: + # If database is not available or settings don't exist, fall back to environment + print(f"Warning: Database not available for timezone: {e}") + pass + + # Fallback to environment variable + return os.getenv('TZ', 'Europe/Rome') + +def get_timezone_obj(): + """Get timezone object for the configured timezone""" + tz_name = get_app_timezone() + try: + return pytz.timezone(tz_name) + except pytz.exceptions.UnknownTimeZoneError: + # Fallback to UTC if timezone is invalid + return pytz.UTC + +def now_in_app_timezone(): + """Get current time in the application's timezone""" + tz = get_timezone_obj() + utc_now = datetime.now(timezone.utc) + return utc_now.astimezone(tz) + +def utc_to_local(utc_dt): + """Convert UTC datetime to local application timezone""" + if utc_dt is None: + return None + + # If datetime is naive (no timezone), assume it's UTC + if utc_dt.tzinfo is None: + utc_dt = utc_dt.replace(tzinfo=timezone.utc) + + tz = get_timezone_obj() + return utc_dt.astimezone(tz) + +def local_to_utc(local_dt): + """Convert local datetime to UTC""" + if local_dt is None: + return None + + # If datetime is naive, assume it's in the application timezone + if local_dt.tzinfo is None: + tz = get_timezone_obj() + local_dt = tz.localize(local_dt) + + return local_dt.astimezone(timezone.utc) + +def parse_local_datetime(date_str, time_str): + """Parse date and time strings in local timezone""" + try: + # Combine date and time + datetime_str = f'{date_str} {time_str}' + + # Parse as naive datetime (assumed to be in local timezone) + naive_dt = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M') + + # Localize to application timezone + tz = get_timezone_obj() + local_dt = tz.localize(naive_dt) + + # Convert to UTC for storage + return local_dt.astimezone(timezone.utc) + except ValueError as e: + raise ValueError(f"Invalid date/time format: {e}") + +def format_local_datetime(utc_dt, format_str='%Y-%m-%d %H:%M'): + """Format UTC datetime in local timezone""" + if utc_dt is None: + return "" + + local_dt = utc_to_local(utc_dt) + return local_dt.strftime(format_str) + +def get_timezone_offset(): + """Get current timezone offset from UTC in hours""" + tz = get_timezone_obj() + now = datetime.now(timezone.utc) + local_now = now.astimezone(tz) + offset = local_now.utcoffset() + return offset.total_seconds() / 3600 if offset else 0 + +def get_timezone_offset_for_timezone(tz_name): + """Get timezone offset for a specific timezone name""" + try: + tz = pytz.timezone(tz_name) + now = datetime.now(timezone.utc) + local_now = now.astimezone(tz) + offset = local_now.utcoffset() + return offset.total_seconds() / 3600 if offset else 0 + except pytz.exceptions.UnknownTimeZoneError: + return 0 diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 2dfcc5c..0000000 --- a/deploy.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash - -# Time Tracker Deployment Script for Raspberry Pi -# This script sets up the Time Tracker application on a Raspberry Pi - -set -e - -echo "๐Ÿš€ Time Tracker Deployment Script" -echo "==================================" - -# Check if running on Raspberry Pi -if ! grep -q "Raspberry Pi" /proc/cpuinfo 2>/dev/null; then - echo "โš ๏ธ Warning: This script is designed for Raspberry Pi" - echo " It may work on other systems but is not tested" -fi - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - echo "โŒ Docker is not installed. Please install Docker first:" - echo " curl -fsSL https://get.docker.com -o get-docker.sh" - echo " sudo sh get-docker.sh" - echo " sudo usermod -aG docker $USER" - exit 1 -fi - -# Check if Docker Compose is installed -if ! command -v docker-compose &> /dev/null; then - echo "โŒ Docker Compose is not installed. Please install Docker Compose first:" - echo " sudo apt-get update" - echo " sudo apt-get install docker-compose-plugin" - exit 1 -fi - -echo "โœ… Docker and Docker Compose are installed" - -# Create necessary directories -echo "๐Ÿ“ Creating directories..." -mkdir -p data logs backups - -# Copy environment file if it doesn't exist -if [ ! -f .env ]; then - echo "๐Ÿ“ Creating .env file from template..." - cp env.example .env - echo "โš ๏ธ Please edit .env file with your configuration before starting" - echo " Key settings to review:" - echo " - SECRET_KEY: Change this to a secure random string" - echo " - ADMIN_USERNAMES: Set your admin usernames" - echo " - TZ: Set your timezone" - echo " - CURRENCY: Set your currency" -else - echo "โœ… .env file already exists" -fi - -# Build and start the application -echo "๐Ÿ”จ Building Docker image..." -docker-compose build - -echo "๐Ÿš€ Starting Time Tracker..." -docker-compose up -d - -# Wait for application to start -echo "โณ Waiting for application to start..." -sleep 10 - -# Check if application is running -if curl -f http://localhost:8080/_health > /dev/null 2>&1; then - echo "โœ… Time Tracker is running successfully!" - echo "" - echo "๐ŸŒ Access the application at:" - echo " http://$(hostname -I | awk '{print $1}'):8080" - echo "" - echo "๐Ÿ“‹ Next steps:" - echo " 1. Open the application in your browser" - echo " 2. Log in with your admin username" - echo " 3. Create your first project" - echo " 4. Start tracking time!" - echo "" - echo "๐Ÿ”ง Useful commands:" - echo " View logs: docker-compose logs -f" - echo " Stop app: docker-compose down" - echo " Restart: docker-compose restart" - echo " Update: git pull && docker-compose up -d --build" -else - echo "โŒ Application failed to start. Check logs with:" - echo " docker-compose logs" - exit 1 -fi - -# Optional: Enable TLS with reverse proxy -read -p "๐Ÿ”’ Enable HTTPS with reverse proxy? (y/N): " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "๐Ÿ”’ Starting with TLS support..." - docker-compose --profile tls up -d - echo "โœ… HTTPS enabled! Access at:" - echo " https://$(hostname -I | awk '{print $1}')" -fi - -echo "" -echo "๐ŸŽ‰ Deployment complete!" diff --git a/docker-compose.public.yml b/docker-compose.public.yml index 674faab..3f99009 100644 --- a/docker-compose.public.yml +++ b/docker-compose.public.yml @@ -3,7 +3,7 @@ services: image: ghcr.io/${GITHUB_REPOSITORY:-drytrix/timetracker}:latest container_name: timetracker-app environment: - - TZ=${TZ:-Europe/Brussels} + - TZ=${TZ:-Europe/Rome} - CURRENCY=${CURRENCY:-EUR} - ROUNDING_MINUTES=${ROUNDING_MINUTES:-1} - SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true} @@ -35,7 +35,7 @@ services: - POSTGRES_DB=${POSTGRES_DB:-timetracker} - POSTGRES_USER=${POSTGRES_USER:-timetracker} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker} - - TZ=${TZ:-Europe/Brussels} + - TZ=${TZ:-Europe/Rome} volumes: - db_data:/var/lib/postgresql/data healthcheck: diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 33dcec7..6aaa8be 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -7,7 +7,7 @@ services: dockerfile: Dockerfile.simple container_name: timetracker-simple environment: - - TZ=${TZ:-Europe/Brussels} + - TZ=${TZ:-Europe/Rome} - CURRENCY=${CURRENCY:-EUR} - ROUNDING_MINUTES=${ROUNDING_MINUTES:-1} - SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b0e606a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,76 +0,0 @@ -services: - app: - build: . - container_name: timetracker-app - environment: - - TZ=${TZ:-Europe/Brussels} - - CURRENCY=${CURRENCY:-EUR} - - ROUNDING_MINUTES=${ROUNDING_MINUTES:-1} - - SINGLE_ACTIVE_TIMER=${SINGLE_ACTIVE_TIMER:-true} - - ALLOW_SELF_REGISTER=${ALLOW_SELF_REGISTER:-true} - - IDLE_TIMEOUT_MINUTES=${IDLE_TIMEOUT_MINUTES:-30} - - ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin} - - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this} - - DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker - - LOG_FILE=/app/logs/timetracker.log - ports: - - "8080:8080" - volumes: - - app_data:/data - - ./logs:/app/logs - depends_on: - db: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/_health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - - db: - image: postgres:16-alpine - container_name: timetracker-db - environment: - - POSTGRES_DB=${POSTGRES_DB:-timetracker} - - POSTGRES_USER=${POSTGRES_USER:-timetracker} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-timetracker} - - TZ=${TZ:-Europe/Brussels} - volumes: - - db_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - restart: unless-stopped - - # Optional reverse proxy for TLS on LAN - proxy: - image: caddy:2-alpine - container_name: timetracker-proxy - volumes: - - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - ports: - - "80:80" - - "443:443" - depends_on: - app: - condition: service_healthy - restart: unless-stopped - profiles: - - tls - -volumes: - app_data: - driver: local - db_data: - driver: local - caddy_data: - driver: local - caddy_config: - driver: local diff --git a/docker/init-database-sql.py b/docker/init-database-sql.py new file mode 100644 index 0000000..41173b1 --- /dev/null +++ b/docker/init-database-sql.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Database initialization script for TimeTracker using raw SQL +This script creates tables and initial data without depending on Flask models +""" + +import os +import sys +import time +from sqlalchemy import create_engine, text, inspect + +def wait_for_database(url, max_attempts=30, delay=2): + """Wait for database to be ready""" + print(f"Waiting for database to be ready...") + + for attempt in range(max_attempts): + try: + engine = create_engine(url, pool_pre_ping=True) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + print("Database connection established successfully") + return engine + except Exception as e: + print(f"Waiting for database... (attempt {attempt+1}/{max_attempts}): {e}") + if attempt < max_attempts - 1: + time.sleep(delay) + else: + print("Database not ready after waiting, exiting...") + sys.exit(1) + + return None + +def create_tables_sql(engine): + """Create tables using raw SQL""" + print("Creating tables using SQL...") + + # SQL statements to create tables + create_tables_sql = """ + -- Create users table + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(80) UNIQUE NOT NULL, + role VARCHAR(20) DEFAULT 'user' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_login TIMESTAMP, + is_active BOOLEAN DEFAULT true NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + -- Create projects table + CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + client VARCHAR(200) NOT NULL, + description TEXT, + billable BOOLEAN DEFAULT true NOT NULL, + hourly_rate NUMERIC(9, 2), + billing_ref VARCHAR(100), + status VARCHAR(20) DEFAULT 'active' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Create time_entries table + CREATE TABLE IF NOT EXISTS time_entries ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP, + duration_seconds INTEGER, + notes TEXT, + tags VARCHAR(500), + source VARCHAR(20) DEFAULT 'manual' NOT NULL, + billable BOOLEAN DEFAULT true NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Create settings table + CREATE TABLE IF NOT EXISTS settings ( + id SERIAL PRIMARY KEY, + timezone VARCHAR(50) DEFAULT 'Europe/Rome' NOT NULL, + currency VARCHAR(3) DEFAULT 'EUR' NOT NULL, + rounding_minutes INTEGER DEFAULT 1 NOT NULL, + single_active_timer BOOLEAN DEFAULT true NOT NULL, + allow_self_register BOOLEAN DEFAULT true NOT NULL, + idle_timeout_minutes INTEGER DEFAULT 30 NOT NULL, + backup_retention_days INTEGER DEFAULT 30 NOT NULL, + backup_time VARCHAR(5) DEFAULT '02:00' NOT NULL, + export_delimiter VARCHAR(1) DEFAULT ',' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + """ + + try: + with engine.connect() as conn: + # Execute the SQL statements + conn.execute(text(create_tables_sql)) + conn.commit() + + print("โœ“ Tables created successfully") + return True + + except Exception as e: + print(f"โœ— Error creating tables: {e}") + return False + +def create_indexes(engine): + """Create indexes for better performance""" + print("Creating indexes...") + + indexes_sql = """ + CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id); + CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id); + CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time); + """ + + try: + with engine.connect() as conn: + conn.execute(text(indexes_sql)) + conn.commit() + + print("โœ“ Indexes created successfully") + return True + + except Exception as e: + print(f"โœ— Error creating indexes: {e}") + return False + +def create_triggers(engine): + """Create triggers for automatic timestamp updates""" + print("Creating triggers...") + + # Execute each statement separately to avoid semicolon splitting issues + try: + with engine.connect() as conn: + # Create function + conn.execute(text(""" + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + """)) + + # Create triggers + conn.execute(text(""" + DROP TRIGGER IF EXISTS update_users_updated_at ON users; + CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + """)) + + conn.execute(text(""" + DROP TRIGGER IF EXISTS update_projects_updated_at ON projects; + CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + """)) + + conn.execute(text(""" + DROP TRIGGER IF EXISTS update_time_entries_updated_at ON time_entries; + CREATE TRIGGER update_time_entries_updated_at BEFORE UPDATE ON time_entries FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + """)) + + conn.execute(text(""" + DROP TRIGGER IF EXISTS update_settings_updated_at ON settings; + CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + """)) + + conn.commit() + + print("โœ“ Triggers created successfully") + return True + + except Exception as e: + print(f"โœ— Error creating triggers: {e}") + return False + +def insert_initial_data(engine): + """Insert initial data""" + print("Inserting initial data...") + + # Get admin username from environment + admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0] + + insert_sql = f""" + -- Insert default admin user + INSERT INTO users (username, role, is_active) + VALUES ('{admin_username}', 'admin', true) + ON CONFLICT (username) DO NOTHING; + + -- Insert default project + INSERT INTO projects (name, client, description, billable, status) + VALUES ('General', 'Default Client', 'Default project for general tasks', true, 'active') + ON CONFLICT DO NOTHING; + + -- Insert default settings + INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter) + VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',') + ON CONFLICT (id) DO NOTHING; + """ + + try: + with engine.connect() as conn: + conn.execute(text(insert_sql)) + conn.commit() + + print("โœ“ Initial data inserted successfully") + return True + + except Exception as e: + print(f"โœ— Error inserting initial data: {e}") + return False + +def verify_tables(engine): + """Verify that all required tables exist""" + print("Verifying tables...") + + try: + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + required_tables = ['users', 'projects', 'time_entries', 'settings'] + + missing_tables = [table for table in required_tables if table not in existing_tables] + + if missing_tables: + print(f"โœ— Missing tables: {missing_tables}") + return False + else: + print("โœ“ All required tables exist") + return True + + except Exception as e: + print(f"โœ— Error verifying tables: {e}") + return False + +def main(): + """Main function""" + url = os.getenv("DATABASE_URL", "") + + if not url.startswith("postgresql"): + print("No PostgreSQL database configured, skipping initialization") + return + + print(f"Database URL: {url}") + + # Wait for database to be ready + engine = wait_for_database(url) + + # Check if database is initialized + if verify_tables(engine): + print("Database already initialized, no action needed") + return + + print("Database not initialized, starting initialization...") + + # Create tables + if not create_tables_sql(engine): + print("Failed to create tables") + sys.exit(1) + + # Create indexes + if not create_indexes(engine): + print("Failed to create indexes") + sys.exit(1) + + # Create triggers + if not create_triggers(engine): + print("Failed to create triggers") + sys.exit(1) + + # Insert initial data + if not insert_initial_data(engine): + print("Failed to insert initial data") + sys.exit(1) + + # Verify everything was created + if verify_tables(engine): + print("โœ“ Database initialization completed successfully") + else: + print("โœ— Database initialization failed - tables still missing") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/docker/init-database.py b/docker/init-database.py index cd770fd..38cbac3 100644 --- a/docker/init-database.py +++ b/docker/init-database.py @@ -8,6 +8,7 @@ and initializes it if needed. import os import sys import time +import traceback from sqlalchemy import create_engine, text, inspect from sqlalchemy.exc import OperationalError, ProgrammingError @@ -54,6 +55,7 @@ def check_database_initialization(engine): except Exception as e: print(f"Error checking database initialization: {e}") + print(f"Traceback: {traceback.format_exc()}") return False def initialize_database(engine): @@ -63,20 +65,35 @@ def initialize_database(engine): try: # Set environment variables for Flask os.environ['FLASK_APP'] = 'app' + os.environ['FLASK_ENV'] = 'production' + + print("Importing Flask app...") # Import Flask app and initialize database from app import create_app, db from app.models import User, Project, TimeEntry, Settings + print("Creating Flask app...") app = create_app() + print("Setting up app context...") with app.app_context(): + print("Creating all tables...") # Create all tables db.create_all() + print("Verifying tables were created...") + # Verify tables were created + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + print(f"Tables after creation: {existing_tables}") + # Create default admin user if it doesn't exist admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0] + print(f"Checking for admin user: {admin_username}") + if not User.query.filter_by(username=admin_username).first(): + print("Creating admin user...") admin_user = User( username=admin_username, role='admin' @@ -85,16 +102,24 @@ def initialize_database(engine): db.session.add(admin_user) db.session.commit() print(f"Created default admin user: {admin_username}") + else: + print(f"Admin user {admin_username} already exists") # Create default settings if they don't exist + print("Checking for default settings...") if not Settings.query.first(): + print("Creating default settings...") settings = Settings() db.session.add(settings) db.session.commit() print("Created default settings") + else: + print("Default settings already exist") # Create default project if it doesn't exist + print("Checking for default project...") if not Project.query.first(): + print("Creating default project...") project = Project( name='General', client='Default Client', @@ -105,12 +130,15 @@ def initialize_database(engine): db.session.add(project) db.session.commit() print("Created default project") + else: + print("Default project already exists") print("Database initialized successfully") return True except Exception as e: print(f"Error initializing database: {e}") + print(f"Traceback: {traceback.format_exc()}") return False def main(): @@ -121,6 +149,8 @@ def main(): print("No PostgreSQL database configured, skipping initialization") return + print(f"Database URL: {url}") + # Wait for database to be ready engine = wait_for_database(url) @@ -129,6 +159,12 @@ def main(): # Initialize database if initialize_database(engine): print("Database initialization completed successfully") + # Verify initialization worked + if check_database_initialization(engine): + print("Database verification successful") + else: + print("Database verification failed - tables still missing") + sys.exit(1) else: print("Database initialization failed") sys.exit(1) diff --git a/docker/init.sql b/docker/init.sql index 95716ca..9af2ab5 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS time_entries ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, - start_utc TIMESTAMP NOT NULL, - end_utc TIMESTAMP, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP, duration_seconds INTEGER, notes TEXT, tags VARCHAR(500), @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS time_entries ( CREATE TABLE IF NOT EXISTS settings ( id SERIAL PRIMARY KEY, - timezone VARCHAR(50) DEFAULT 'Europe/Brussels' NOT NULL, + timezone VARCHAR(50) DEFAULT 'Europe/Rome' NOT NULL, currency VARCHAR(3) DEFAULT 'EUR' NOT NULL, rounding_minutes INTEGER DEFAULT 1 NOT NULL, single_active_timer BOOLEAN DEFAULT true NOT NULL, @@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS settings ( -- Create indexes for better performance CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id); CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id); -CREATE INDEX IF NOT EXISTS idx_time_entries_start_utc ON time_entries(start_utc); +CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time); -- Insert default admin user (password: admin) @@ -80,7 +80,7 @@ ON CONFLICT DO NOTHING; -- Insert default settings INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter) -VALUES ('Europe/Brussels', 'EUR', 1, true, true, 30, 30, '02:00', ',') +VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',') ON CONFLICT (id) DO NOTHING; -- Create function to update updated_at timestamp diff --git a/docker/migrate-field-names.py b/docker/migrate-field-names.py new file mode 100644 index 0000000..5f0bc66 --- /dev/null +++ b/docker/migrate-field-names.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Migration script to rename database fields from start_utc/end_utc to start_time/end_time +This script should be run after updating the application code but before starting the new version. +""" + +import os +import sys +from sqlalchemy import create_engine, text, inspect + +def wait_for_database(url, max_attempts=30, delay=2): + """Wait for database to be ready""" + print(f"Waiting for database to be ready...") + + for attempt in range(max_attempts): + try: + engine = create_engine(url, pool_pre_ping=True) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + print("Database connection established successfully") + return engine + except Exception as e: + print(f"Waiting for database... (attempt {attempt+1}/{max_attempts}): {e}") + if attempt < max_attempts - 1: + time.sleep(delay) + else: + print("Database not ready after waiting, exiting...") + sys.exit(1) + + return None + +def check_migration_needed(engine): + """Check if migration is needed""" + print("Checking if migration is needed...") + + try: + inspector = inspect(engine) + columns = inspector.get_columns('time_entries') + column_names = [col['name'] for col in columns] + + has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names + has_new_fields = 'start_time' in column_names or 'end_time' in column_names + + if has_old_fields and not has_new_fields: + print("โœ“ Migration needed: old field names detected") + return True + elif has_new_fields and not has_old_fields: + print("โœ“ Migration not needed: new field names already exist") + return False + elif has_old_fields and has_new_fields: + print("โš  Migration partially done: both old and new field names exist") + return True + else: + print("โš  Unknown state: neither old nor new field names found") + return True + + except Exception as e: + print(f"โœ— Error checking migration status: {e}") + return True + +def migrate_database(engine): + """Perform the migration""" + print("Starting database migration...") + + try: + with engine.connect() as conn: + # Start transaction + trans = conn.begin() + + try: + # Check if old columns exist + inspector = inspect(engine) + columns = inspector.get_columns('time_entries') + column_names = [col['name'] for col in columns] + + if 'start_utc' in column_names: + print("Renaming start_utc to start_time...") + conn.execute(text("ALTER TABLE time_entries RENAME COLUMN start_utc TO start_time")) + + if 'end_utc' in column_names: + print("Renaming end_utc to end_time...") + conn.execute(text("ALTER TABLE time_entries RENAME COLUMN end_utc TO end_time")) + + # Update indexes + print("Updating indexes...") + + # Drop old index if it exists + try: + conn.execute(text("DROP INDEX IF EXISTS idx_time_entries_start_utc")) + except: + pass + + # Create new index + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time)")) + + # Commit transaction + trans.commit() + print("โœ“ Migration completed successfully") + return True + + except Exception as e: + trans.rollback() + print(f"โœ— Migration failed: {e}") + return False + + except Exception as e: + print(f"โœ— Error during migration: {e}") + return False + +def verify_migration(engine): + """Verify the migration was successful""" + print("Verifying migration...") + + try: + inspector = inspect(engine) + columns = inspector.get_columns('time_entries') + column_names = [col['name'] for col in columns] + + has_new_fields = 'start_time' in column_names and 'end_time' in column_names + has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names + + if has_new_fields and not has_old_fields: + print("โœ“ Migration verified: new field names are present, old ones are gone") + return True + else: + print(f"โœ— Migration verification failed: new fields: {has_new_fields}, old fields: {has_old_fields}") + return False + + except Exception as e: + print(f"โœ— Error verifying migration: {e}") + return False + +def main(): + """Main function""" + url = os.getenv("DATABASE_URL", "") + + if not url.startswith("postgresql"): + print("No PostgreSQL database configured, skipping migration") + return + + print(f"Database URL: {url}") + + # Wait for database to be ready + engine = wait_for_database(url) + + # Check if migration is needed + if not check_migration_needed(engine): + print("No migration needed, exiting...") + return + + # Perform migration + if not migrate_database(engine): + print("Migration failed, exiting...") + sys.exit(1) + + # Verify migration + if not verify_migration(engine): + print("Migration verification failed, exiting...") + sys.exit(1) + + print("โœ“ Database migration completed successfully!") + +if __name__ == "__main__": + import time + main() diff --git a/docker/start.sh b/docker/start.sh index caee142..b1a1cbb 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,9 +1,10 @@ #!/bin/bash set -e - cd /app export FLASK_APP=app +echo "=== Starting TimeTracker ===" + echo "Waiting for database to be ready..." # Wait for Postgres to be ready python - <<"PY" @@ -47,7 +48,7 @@ if url.startswith("postgresql"): # Check if our main tables exist existing_tables = inspector.get_table_names() - required_tables = ['users', 'projects', 'time_entries', 'settings'] + required_tables = ["users", "projects", "time_entries", "settings"] missing_tables = [table for table in required_tables if table not in existing_tables] @@ -57,7 +58,20 @@ if url.startswith("postgresql"): sys.exit(1) # Exit with error to trigger initialization else: print("Database is already initialized with all required tables") - sys.exit(0) # Exit successfully, no initialization needed + # Check if migration is needed + try: + columns = inspector.get_columns("time_entries") + column_names = [col['name'] for col in columns] + has_old_fields = 'start_utc' in column_names or 'end_utc' in column_names + if has_old_fields: + print("Migration needed for field names") + sys.exit(2) # Special exit code for migration + else: + print("No migration needed") + sys.exit(0) # Exit successfully, no initialization needed + except Exception as e: + print(f"Error checking migration status: {e}") + sys.exit(0) # Assume no migration needed except Exception as e: print(f"Error checking database initialization: {e}") @@ -68,12 +82,62 @@ else: PY if [ $? -eq 1 ]; then - echo "Initializing database..." - python /app/docker/init-database.py - if [ $? -eq 0 ]; then - echo "Database initialized successfully" - else - echo "Database initialization failed, but continuing..." + echo "Initializing database with SQL-based script..." + python /app/docker/init-database-sql.py + if [ $? -ne 0 ]; then + echo "Database initialization failed. Exiting to prevent infinite loop." + exit 1 + fi + echo "Database initialized successfully" + + # Run migration if needed + echo "Running database migration..." + python /app/docker/migrate-field-names.py + + # Verify initialization worked + echo "Verifying database initialization..." +elif [ $? -eq 2 ]; then + echo "Running database migration for existing database..." + python /app/docker/migrate-field-names.py + if [ $? -ne 0 ]; then + echo "Database migration failed. Exiting." + exit 1 + fi + echo "Database migration completed successfully" + python - <<"PY" +import os +import sys +from sqlalchemy import create_engine, text, inspect + +url = os.getenv("DATABASE_URL", "") +if url.startswith("postgresql"): + try: + engine = create_engine(url, pool_pre_ping=True) + inspector = inspect(engine) + + existing_tables = inspector.get_table_names() + required_tables = ["users", "projects", "time_entries", "settings"] + + missing_tables = [table for table in required_tables if table not in existing_tables] + + if missing_tables: + print(f"Database verification failed. Missing tables: {missing_tables}") + sys.exit(1) + else: + print("Database verification successful") + sys.exit(0) + + except Exception as e: + print(f"Error verifying database: {e}") + sys.exit(1) +else: + print("No PostgreSQL database configured, skipping verification") + sys.exit(0) +PY + + if [ $? -eq 1 ]; then + echo "Database verification failed after initialization. Exiting to prevent infinite loop." + exit 1 fi else echo "Database already initialized, skipping initialization" diff --git a/docker/test-db-simple.py b/docker/test-db-simple.py new file mode 100644 index 0000000..bd7abf8 --- /dev/null +++ b/docker/test-db-simple.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Simple database test script to debug connection and table creation issues +""" + +import os +import sys +from sqlalchemy import create_engine, text, inspect + +def test_database(): + """Test database connection and basic operations""" + url = os.getenv("DATABASE_URL", "") + + if not url.startswith("postgresql"): + print("No PostgreSQL database configured") + return False + + print(f"Testing database URL: {url}") + + try: + # Test connection + print("Testing database connection...") + engine = create_engine(url, pool_pre_ping=True) + + with engine.connect() as conn: + result = conn.execute(text("SELECT 1")) + print("โœ“ Database connection successful") + + # Test table inspection + print("Testing table inspection...") + inspector = inspect(engine) + tables = inspector.get_table_names() + print(f"โœ“ Found {len(tables)} tables: {tables}") + + # Test creating a simple table + print("Testing table creation...") + with engine.connect() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS test_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) + ) + """)) + conn.commit() + print("โœ“ Test table created successfully") + + # Verify table was created + tables_after = inspector.get_table_names() + if 'test_table' in tables_after: + print("โœ“ Test table verification successful") + else: + print("โœ— Test table verification failed") + return False + + # Clean up test table + with engine.connect() as conn: + conn.execute(text("DROP TABLE test_table")) + conn.commit() + print("โœ“ Test table cleaned up") + + return True + + except Exception as e: + print(f"โœ— Database test failed: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = test_database() + sys.exit(0 if success else 1) diff --git a/docker/test-startup.sh b/docker/test-startup.sh new file mode 100644 index 0000000..a9035e9 --- /dev/null +++ b/docker/test-startup.sh @@ -0,0 +1,32 @@ +#!/bin/bash +echo "=== Testing Startup Script ===" + +echo "Current working directory: $(pwd)" +echo "Current user: $(whoami)" +echo "Current user ID: $(id)" + +echo "Checking if startup script exists..." +if [ -f "/app/docker/start.sh" ]; then + echo "โœ“ Startup script exists at /app/docker/start.sh" + echo "File permissions: $(ls -la /app/docker/start.sh)" + echo "File owner: $(stat -c '%U:%G' /app/docker/start.sh)" + + echo "Testing if script is executable..." + if [ -x "/app/docker/start.sh" ]; then + echo "โœ“ Startup script is executable" + echo "Script first few lines:" + head -5 /app/docker/start.sh + else + echo "โœ— Startup script is NOT executable" + fi +else + echo "โœ— Startup script does NOT exist at /app/docker/start.sh" + echo "Contents of /app/docker/:" + ls -la /app/docker/ || echo "Directory /app/docker/ does not exist" +fi + +echo "Checking /app directory structure..." +echo "Contents of /app:" +ls -la /app/ || echo "Directory /app/ does not exist" + +echo "=== Test Complete ===" diff --git a/env.example b/env.example index c1b8744..dc75e65 100644 --- a/env.example +++ b/env.example @@ -13,7 +13,7 @@ POSTGRES_USER=timetracker POSTGRES_PASSWORD=timetracker # Timezone and Localization -TZ=Europe/Brussels +TZ=Europe/Rome CURRENCY=EUR # Time Tracking Settings diff --git a/index.html b/index.html deleted file mode 100644 index 73b336e..0000000 --- a/index.html +++ /dev/null @@ -1,899 +0,0 @@ - - - - - - TimeTracker - Self-Hosted Time Tracking Solution - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- -

Self-hosted time tracking for teams and freelancers

- -
-
-
- - -
-
-

Why Choose TimeTracker?

-

Built for reliability, designed for simplicity, and engineered for performance

- -
-
- ๐Ÿ• -

Persistent Timers

-

Server-side timers that survive browser restarts and computer reboots. Never lose track of your time again.

-
- -
- โ˜๏ธ -

Self-Hosted

-

Full control over your data with no cloud dependencies. Deploy on your own infrastructure with confidence.

-
- -
- ๐Ÿ“Š -

Rich Reporting

-

Comprehensive reports and analytics with CSV export capabilities for external analysis and billing.

-
- -
- ๐Ÿ‘ฅ -

Team Management

-

User roles, project organization, and billing support for teams of any size.

-
- -
- ๐Ÿณ -

Docker Ready

-

Simple deployment with Docker and Docker Compose. Perfect for Raspberry Pi and production environments.

-
- -
- ๐Ÿ”’ -

Open Source

-

Licensed under GPL v3, ensuring derivatives remain open source and accessible to everyone.

-
-
-
-
- - -
-
-

See TimeTracker in Action

-

Beautiful, intuitive interface designed for productivity and ease of use

- -
-
-
- TimeTracker Dashboard - Clean interface showing active timers and recent activity -
-
-

Dashboard View

-

Clean, intuitive interface showing active timers and recent activity. Quick access to start/stop timers and manual time entry.

-
-
- -
-
- TimeTracker Projects - Client and project organization with billing information -
-
-

Project Management

-

Client and project organization with billing information. Time tracking across multiple projects simultaneously.

-
-
- -
-
- TimeTracker Reports - Comprehensive time reports with export capabilities -
-
-

Reports & Analytics

-

Comprehensive time reports with export capabilities. Visual breakdowns of time allocation and productivity.

-
-
-
-
-
- - -
-
-
-
-

Solving Real Time Tracking Problems

-
    -
  • - โŒ - Traditional timers lose data when browsers close -
  • -
  • - โŒ - Cloud dependencies and privacy concerns -
  • -
  • - โŒ - Complex setup and maintenance -
  • -
  • - โŒ - Limited reporting and export options -
  • -
-
-
-

TimeTracker Solutions

-

โœ… Persistent server-side timers

-

โœ… Self-hosted, no cloud required

-

โœ… Simple Docker deployment

-

โœ… Rich reporting and exports

-

โœ… Team collaboration features

-
-
-
-
- - -
-
-

Get Started in Minutes

-

Deploy TimeTracker on your Raspberry Pi or any Linux system with just a few commands

- -
-
-
1
-

Clone Repository

-

Get the latest version of TimeTracker from GitHub

-
- # Clone the repository
- git clone https://github.com/DRYTRIX/TimeTracker.git
- cd TimeTracker -
-
- -
-
2
-

Configure Environment

-

Set up your environment variables and preferences

-
- # Copy and edit environment file
- cp .env.example .env
- # Edit with your settings -
-
- -
-
3
-

Start Application

-

Launch TimeTracker with Docker Compose

-
- # Start the application
- docker-compose up -d -
-
- -
-
4
-

Access & Use

-

Open your browser and start tracking time

-
- # Access at
- http://your-pi-ip:8080 -
-
-
-
-
- - -
-
-
-
-

100%

-

Open Source

-
-
-

0

-

Cloud Dependencies

-
-
-

โˆž

-

Customization

-
-
-

๐Ÿš€

-

Easy Deployment

-
-
-
-
- - - - - - - - diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 437a7c8..f3a4165 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -136,12 +136,12 @@ {{ entry.project.name }} - {{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }} + {{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} {{ entry.duration_formatted }} - {% if entry.end_utc %} + {% if entry.end_time %} Completed {% else %} Running diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 7b767ed..1644d53 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -28,7 +28,127 @@
- + + + Select your local timezone for proper time display. Times shown include DST adjustments.
@@ -80,6 +200,39 @@ Save Settings
+ +
+
+
+
+ + Current Time: + Loading... +
+
+ + in {{ settings.timezone if settings else 'Europe/Rome' }} timezone + +
+
+
+
+
+ + + This time updates every second and shows the current time in your selected timezone + +
+
+ + + Current offset: -- + +
+
+
+
+
@@ -103,6 +256,62 @@ + + {% endblock %} diff --git a/templates/projects/view.html b/templates/projects/view.html index 88e3c60..bb8eac8 100644 --- a/templates/projects/view.html +++ b/templates/projects/view.html @@ -190,15 +190,15 @@ {% for entry in entries %} {{ entry.user.username }} - {{ entry.start_utc.strftime('%Y-%m-%d') }} - - {{ entry.start_utc.strftime('%H:%M') }} - - {% if entry.end_utc %} - {{ entry.end_utc.strftime('%H:%M') }} - {% else %} - Running - {% endif %} - + {{ entry.start_time.strftime('%Y-%m-%d') }} + + {{ entry.start_time.strftime('%H:%M') }} - + {% if entry.end_time %} + {{ entry.end_time.strftime('%H:%M') }} + {% else %} + Running + {% endif %} + {{ entry.duration_formatted }} diff --git a/templates/reports/index.html b/templates/reports/index.html index 98080d7..07a637c 100644 --- a/templates/reports/index.html +++ b/templates/reports/index.html @@ -166,7 +166,7 @@ {{ entry.project.name }} - {{ entry.start_utc.strftime('%Y-%m-%d') }} + {{ entry.start_time.strftime('%Y-%m-%d') }} {{ entry.duration_formatted }} diff --git a/templates/reports/project_report.html b/templates/reports/project_report.html index 0684765..2892430 100644 --- a/templates/reports/project_report.html +++ b/templates/reports/project_report.html @@ -262,15 +262,15 @@ {{ entry.project.name }} - {{ entry.start_utc.strftime('%Y-%m-%d') }} - - {{ entry.start_utc.strftime('%H:%M') }} - - {% if entry.end_utc %} - {{ entry.end_utc.strftime('%H:%M') }} - {% else %} - Running - {% endif %} - + {{ entry.start_time.strftime('%Y-%m-%d') }} + + {{ entry.start_time.strftime('%H:%M') }} - + {% if entry.end_time %} + {{ entry.end_time.strftime('%H:%M') }} + {% else %} + Running + {% endif %} + {{ entry.duration_formatted }} diff --git a/templates/reports/user_report.html b/templates/reports/user_report.html index 3d86ac5..bf748c7 100644 --- a/templates/reports/user_report.html +++ b/templates/reports/user_report.html @@ -207,15 +207,15 @@ {{ entry.project.name }} - {{ entry.start_utc.strftime('%Y-%m-%d') }} - - {{ entry.start_utc.strftime('%H:%M') }} - - {% if entry.end_utc %} - {{ entry.end_utc.strftime('%H:%M') }} - {% else %} - Running - {% endif %} - + {{ entry.start_time.strftime('%Y-%m-%d') }} + + {{ entry.start_time.strftime('%H:%M') }} - + {% if entry.end_time %} + {{ entry.end_time.strftime('%H:%M') }} + {% else %} + Running + {% endif %} + {{ entry.duration_formatted }} diff --git a/templates/timer/edit_timer.html b/templates/timer/edit_timer.html index a004ebb..0f60219 100644 --- a/templates/timer/edit_timer.html +++ b/templates/timer/edit_timer.html @@ -21,14 +21,14 @@
- Start: {{ timer.start_utc.strftime('%Y-%m-%d %H:%M') }} + Start: {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
End: - {% if timer.end_utc %} - {{ timer.end_utc.strftime('%Y-%m-%d %H:%M') }} + {% if timer.end_time %} + {{ timer.end_time.strftime('%Y-%m-%d %H:%M') }} {% else %} Running {% endif %} diff --git a/templates/timer/timer.html b/templates/timer/timer.html index 8650dc6..00877b3 100644 --- a/templates/timer/timer.html +++ b/templates/timer/timer.html @@ -412,10 +412,10 @@ function loadRecentEntries() { ${entry.notes ? `${entry.notes}` : ''} - ${new Date(entry.start_utc).toLocaleDateString()} + ${new Date(entry.start_time).toLocaleDateString()} - ${new Date(entry.start_utc).toLocaleTimeString()} - - ${entry.end_utc ? new Date(entry.end_utc).toLocaleTimeString() : 'Running'} + ${new Date(entry.start_time).toLocaleTimeString()} - + ${entry.end_time ? new Date(entry.end_time).toLocaleTimeString() : 'Running'}
@@ -519,16 +519,16 @@ function editEntry(entryId) { .then(data => { document.getElementById('editEntryId').value = entryId; document.getElementById('editProjectSelect').value = data.project_id; - document.getElementById('editStartTime').value = data.start_utc.slice(0, 16); - document.getElementById('editEndTime').value = data.end_utc ? data.end_utc.slice(0, 16) : ''; + document.getElementById('editStartTime').value = data.start_time.slice(0, 16); + document.getElementById('editEndTime').value = data.end_time ? data.end_time.slice(0, 16) : ''; document.getElementById('editNotes').value = data.notes || ''; document.getElementById('editTags').value = data.tags || ''; document.getElementById('editBillable').checked = data.billable; // Calculate and display duration - if (data.end_utc) { - const start = new Date(data.start_utc); - const end = new Date(data.end_utc); + if (data.end_time) { + const start = new Date(data.start_time); + const end = new Date(data.end_time); const duration = Math.floor((end - start) / 1000); const hours = Math.floor(duration / 3600); const minutes = Math.floor((duration % 3600) / 60); diff --git a/tests/test_basic.py b/tests/test_basic.py index 70d6a5b..670e341 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -121,8 +121,8 @@ def test_time_entry_creation(app, user, project): entry = TimeEntry( user_id=user.id, project_id=project.id, - start_utc=start_time, - end_utc=end_time, + start_time=start_time, + end_time=end_time, notes='Test entry', tags='test,work', source='manual' @@ -142,19 +142,19 @@ def test_active_timer(app, user, project): timer = TimeEntry( user_id=user.id, project_id=project.id, - start_utc=datetime.utcnow(), + start_time=datetime.utcnow(), source='auto' ) db.session.add(timer) db.session.commit() assert timer.is_active is True - assert timer.end_utc is None + assert timer.end_time is None # Stop timer timer.stop_timer() assert timer.is_active is False - assert timer.end_utc is not None + assert timer.end_time is not None assert timer.duration_seconds > 0 def test_user_active_timer_property(app, user, project): @@ -167,7 +167,7 @@ def test_user_active_timer_property(app, user, project): timer = TimeEntry( user_id=user.id, project_id=project.id, - start_utc=datetime.utcnow(), + start_time=datetime.utcnow(), source='auto' ) db.session.add(timer) @@ -185,15 +185,15 @@ def test_project_totals(app, user, project): entry1 = TimeEntry( user_id=user.id, project_id=project.id, - start_utc=start_time, - end_utc=start_time + timedelta(hours=2), + start_time=start_time, + end_time=start_time + timedelta(hours=2), source='manual' ) entry2 = TimeEntry( user_id=user.id, project_id=project.id, - start_utc=start_time + timedelta(hours=3), - end_utc=start_time + timedelta(hours=5), + start_time=start_time + timedelta(hours=3), + end_time=start_time + timedelta(hours=5), source='manual' ) db.session.add_all([entry1, entry2]) diff --git a/tests/test_timezone.py b/tests/test_timezone.py new file mode 100644 index 0000000..90e3465 --- /dev/null +++ b/tests/test_timezone.py @@ -0,0 +1,227 @@ +import pytest +from datetime import datetime, timedelta +from app import create_app, db +from app.models import Settings, TimeEntry, User, Project +from app.utils.timezone import get_app_timezone, utc_to_local, local_to_utc, now_in_app_timezone + + +@pytest.fixture +def app(): + """Create application for testing""" + app = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'WTF_CSRF_ENABLED': False, + 'SECRET_KEY': 'test-secret-key' + }) + + with app.app_context(): + db.create_all() + yield app + db.drop_all() + + +@pytest.fixture +def client(app): + """Create test client""" + return app.test_client() + + +@pytest.fixture +def user(app): + """Create test user""" + with app.app_context(): + user = User(username='testuser', role='user') + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture +def project(app): + """Create test project""" + with app.app_context(): + project = Project(name='Test Project', client='Test Client', billable=True, hourly_rate=50.0) + db.session.add(project) + db.session.commit() + return project + + +def test_timezone_default_from_environment(app): + """Test that timezone defaults to environment variable when no database settings exist""" + with app.app_context(): + # Clear any existing settings + Settings.query.delete() + db.session.commit() + + # Test default timezone + timezone = get_app_timezone() + assert timezone == 'Europe/Rome' # Default from config + + +def test_timezone_from_database_settings(app): + """Test that timezone is read from database settings""" + with app.app_context(): + # Create settings with custom timezone + settings = Settings(timezone='America/New_York') + db.session.add(settings) + db.session.commit() + + # Test that timezone is read from database + timezone = get_app_timezone() + assert timezone == 'America/New_York' + + +def test_timezone_change_affects_display(app, user, project): + """Test that changing timezone affects how times are displayed""" + with app.app_context(): + # Create settings with Europe/Rome timezone + settings = Settings(timezone='Europe/Rome') + db.session.add(settings) + db.session.commit() + + # Create a time entry at a specific UTC time + utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) + + # Refresh the user and project objects to ensure they're attached to the session + user = db.session.merge(user) + project = db.session.merge(project) + + entry = TimeEntry( + user_id=user.id, + project_id=project.id, + start_time=utc_time, + end_time=utc_time + timedelta(hours=2), + source='manual' + ) + db.session.add(entry) + db.session.commit() + + # Get time in Rome timezone (UTC+1 or UTC+2 depending on DST) + rome_time = utc_to_local(entry.start_time) + + # Change timezone to America/New_York + settings.timezone = 'America/New_York' + db.session.commit() + + # Get time in New York timezone (UTC-5 or UTC-4 depending on DST) + ny_time = utc_to_local(entry.start_time) + + # Times should be different + assert rome_time != ny_time + + # New York time should be earlier than Rome time (behind UTC) + # This is a basic check - actual difference depends on DST + assert ny_time.hour != rome_time.hour or abs(ny_time.hour - rome_time.hour) > 1 + + +def test_timezone_aware_current_time(app): + """Test that current time is returned in the configured timezone""" + with app.app_context(): + # Set timezone to Europe/Rome + settings = Settings(timezone='Europe/Rome') + db.session.add(settings) + db.session.commit() + + # Get current time in app timezone + app_time = now_in_app_timezone() + utc_time = datetime.now().replace(tzinfo=None) + + # App time should be in Rome timezone + assert app_time.tzinfo is not None + assert 'Europe/Rome' in str(app_time.tzinfo) + + # Times should be close (within a few seconds) + time_diff = abs((app_time.replace(tzinfo=None) - utc_time).total_seconds()) + assert time_diff < 10 + + +def test_timezone_conversion_utc_to_local(app): + """Test UTC to local timezone conversion""" + with app.app_context(): + # Set timezone to Asia/Tokyo + settings = Settings(timezone='Asia/Tokyo') + db.session.add(settings) + db.session.commit() + + # Create a UTC time + utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) + + # Convert to Tokyo time + tokyo_time = utc_to_local(utc_time) + + # Tokyo time should be ahead of UTC (UTC+9) + assert tokyo_time.tzinfo is not None + assert 'Asia/Tokyo' in str(tokyo_time.tzinfo) + + # Tokyo time should be ahead of UTC time + # Tokyo is UTC+9, so 12:00 UTC should be 21:00 in Tokyo + assert tokyo_time.hour == 21 + + +def test_timezone_conversion_local_to_utc(app): + """Test local timezone to UTC conversion""" + with app.app_context(): + # Set timezone to Europe/London + settings = Settings(timezone='Europe/London') + db.session.add(settings) + db.session.commit() + + # Create a local time (assumed to be in London timezone) + local_time = datetime.now().replace(hour=15, minute=30, second=0, microsecond=0) + + # Convert to UTC + utc_time = local_to_utc(local_time) + + # UTC time should have timezone info + assert utc_time.tzinfo is not None + + # Convert back to local to verify + back_to_local = utc_to_local(utc_time) + assert back_to_local.hour == 15 + assert back_to_local.minute == 30 + + +def test_invalid_timezone_fallback(app): + """Test that invalid timezone falls back to UTC""" + with app.app_context(): + # Set invalid timezone + settings = Settings(timezone='Invalid/Timezone') + db.session.add(settings) + db.session.commit() + + # Should fall back to UTC + timezone = get_app_timezone() + assert timezone == 'Invalid/Timezone' # Still stored in database + + # But timezone object should fall back to UTC + from app.utils.timezone import get_timezone_obj + tz_obj = get_timezone_obj() + assert 'UTC' in str(tz_obj) + + +def test_timezone_settings_update(app): + """Test that updating timezone settings takes effect immediately""" + with app.app_context(): + # Create initial settings + settings = Settings(timezone='Europe/Rome') + db.session.add(settings) + db.session.commit() + + # Verify initial timezone + assert get_app_timezone() == 'Europe/Rome' + + # Update timezone + settings.timezone = 'America/Los_Angeles' + db.session.commit() + + # Verify timezone change takes effect + assert get_app_timezone() == 'America/Los_Angeles' + + # Test that time conversion uses new timezone + utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) + la_time = utc_to_local(utc_time) + + # LA time should be in Pacific timezone + assert la_time.tzinfo is not None + assert 'America/Los_Angeles' in str(la_time.tzinfo)