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 @@
Self-hosted time tracking for teams and freelancers
- -Built for reliability, designed for simplicity, and engineered for performance
- -Server-side timers that survive browser restarts and computer reboots. Never lose track of your time again.
-Full control over your data with no cloud dependencies. Deploy on your own infrastructure with confidence.
-Comprehensive reports and analytics with CSV export capabilities for external analysis and billing.
-User roles, project organization, and billing support for teams of any size.
-Simple deployment with Docker and Docker Compose. Perfect for Raspberry Pi and production environments.
-Licensed under GPL v3, ensuring derivatives remain open source and accessible to everyone.
-Beautiful, intuitive interface designed for productivity and ease of use
- -
- Clean, intuitive interface showing active timers and recent activity. Quick access to start/stop timers and manual time entry.
-
- Client and project organization with billing information. Time tracking across multiple projects simultaneously.
-
- Comprehensive time reports with export capabilities. Visual breakdowns of time allocation and productivity.
-โ Persistent server-side timers
-โ Self-hosted, no cloud required
-โ Simple Docker deployment
-โ Rich reporting and exports
-โ Team collaboration features
-Deploy TimeTracker on your Raspberry Pi or any Linux system with just a few commands
- -Get the latest version of TimeTracker from GitHub
-Set up your environment variables and preferences
-Launch TimeTracker with Docker Compose
-Open your browser and start tracking time
-Open Source
-Cloud Dependencies
-Customization
-Easy Deployment
-