mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-27 15:08:57 -06:00
Merge pull request #8 from DRYTRIX/7-wrong-timezone
feat: comprehensive project cleanup and timezone enhancement
This commit is contained in:
@@ -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
|
||||
|
||||
112
Dockerfile
112
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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
135
PROJECT_STRUCTURE.md
Normal file
135
PROJECT_STRUCTURE.md
Normal file
@@ -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.
|
||||
37
README.md
37
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
73
_config.yml
73
_config.yml
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{{ active_timer.duration_formatted }}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Started at {{ active_timer.start_utc.strftime('%H:%M') }}
|
||||
Started at {{ active_timer.start_time.strftime('%H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,8 +254,8 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ entry.start_utc.strftime('%b %d') }}</span>
|
||||
<small class="text-muted">{{ entry.start_utc.strftime('%H:%M') }}</small>
|
||||
<span class="fw-semibold">{{ entry.start_time.strftime('%b %d') }}</span>
|
||||
<small class="text-muted">{{ entry.start_time.strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">{{ entry.project.name }}</a>
|
||||
</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ entry.end_utc.strftime('%Y-%m-%d %H:%M') if entry.end_utc else '-' }}</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ entry.end_time.strftime('%Y-%m-%d %H:%M') if entry.end_time else '-' }}</td>
|
||||
<td><strong>{{ entry.duration_formatted }}</strong></td>
|
||||
<td>
|
||||
{% if entry.notes %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
33
app/utils/template_filters.py
Normal file
33
app/utils/template_filters.py
Normal file
@@ -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')
|
||||
113
app/utils/timezone.py
Normal file
113
app/utils/timezone.py
Normal file
@@ -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
|
||||
100
deploy.sh
100
deploy.sh
@@ -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!"
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
286
docker/init-database-sql.py
Normal file
286
docker/init-database-sql.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
165
docker/migrate-field-names.py
Normal file
165
docker/migrate-field-names.py
Normal file
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
71
docker/test-db-simple.py
Normal file
71
docker/test-db-simple.py
Normal file
@@ -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)
|
||||
32
docker/test-startup.sh
Normal file
32
docker/test-startup.sh
Normal file
@@ -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 ==="
|
||||
@@ -13,7 +13,7 @@ POSTGRES_USER=timetracker
|
||||
POSTGRES_PASSWORD=timetracker
|
||||
|
||||
# Timezone and Localization
|
||||
TZ=Europe/Brussels
|
||||
TZ=Europe/Rome
|
||||
CURRENCY=EUR
|
||||
|
||||
# Time Tracking Settings
|
||||
|
||||
899
index.html
899
index.html
@@ -1,899 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeTracker - Self-Hosted Time Tracking Solution</title>
|
||||
<meta name="description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
|
||||
<meta name="keywords" content="time tracking, self-hosted, flask, python, docker, raspberry pi, open source">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://drytrix.github.io/TimeTracker/">
|
||||
<meta property="og:title" content="TimeTracker - Self-Hosted Time Tracking Solution">
|
||||
<meta property="og:description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
|
||||
<meta property="og:image" content="https://drytrix.github.io/TimeTracker/assets/og-image.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://drytrix.github.io/TimeTracker/">
|
||||
<meta property="twitter:title" content="TimeTracker - Self-Hosted Time Tracking Solution">
|
||||
<meta property="twitter:description" content="A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies.">
|
||||
<meta property="twitter:image" content="https://drytrix.github.io/TimeTracker/assets/og-image.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
|
||||
<!-- CSS -->
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--secondary-color: #64748b;
|
||||
--accent-color: #f59e0b;
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--background: #ffffff;
|
||||
--surface: #f8fafc;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.9;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 15px 30px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #d97706;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: white;
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 100px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 60px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 40px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
padding: 40px 30px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Screenshots Section */
|
||||
.screenshots {
|
||||
padding: 100px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.screenshots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 60px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-item:nth-child(even) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.screenshot-item:nth-child(odd) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.screenshot-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.screenshot-image {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.screenshot-image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshot-image img:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.screenshot-caption h3 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.screenshot-caption p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Problem Section */
|
||||
.problem {
|
||||
padding: 100px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.problem-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.problem-text h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 30px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.problem-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.problem-list li {
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.problem-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.problem-icon {
|
||||
color: var(--danger-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.solution-icon {
|
||||
color: var(--success-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.problem-visual {
|
||||
background: var(--surface);
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.problem-visual h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.problem-visual p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Quick Start Section */
|
||||
.quick-start {
|
||||
padding: 100px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 30px;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Code Block */
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.code-block .comment {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.code-block .string {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats {
|
||||
padding: 80px 0;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item h3 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-item p {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
padding: 60px 0 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-section ul li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.footer-section ul li a {
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid #334155;
|
||||
padding-top: 30px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
color: #cbd5e1;
|
||||
font-size: 1.5rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.problem-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.screenshot-item:nth-child(even) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshot-item:nth-child(odd) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Scroll animations */
|
||||
.scroll-animate {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
|
||||
.scroll-animate.animate {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
⏱️ TimeTracker
|
||||
</div>
|
||||
<p class="tagline">Self-hosted time tracking for teams and freelancers</p>
|
||||
<div class="cta-buttons">
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker" class="btn btn-primary">
|
||||
🚀 Get Started
|
||||
</a>
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker" class="btn btn-secondary">
|
||||
📖 View Docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Why Choose TimeTracker?</h2>
|
||||
<p class="section-subtitle">Built for reliability, designed for simplicity, and engineered for performance</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">🕐</span>
|
||||
<h3 class="feature-title">Persistent Timers</h3>
|
||||
<p class="feature-description">Server-side timers that survive browser restarts and computer reboots. Never lose track of your time again.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">☁️</span>
|
||||
<h3 class="feature-title">Self-Hosted</h3>
|
||||
<p class="feature-description">Full control over your data with no cloud dependencies. Deploy on your own infrastructure with confidence.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">📊</span>
|
||||
<h3 class="feature-title">Rich Reporting</h3>
|
||||
<p class="feature-description">Comprehensive reports and analytics with CSV export capabilities for external analysis and billing.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">👥</span>
|
||||
<h3 class="feature-title">Team Management</h3>
|
||||
<p class="feature-description">User roles, project organization, and billing support for teams of any size.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">🐳</span>
|
||||
<h3 class="feature-title">Docker Ready</h3>
|
||||
<p class="feature-description">Simple deployment with Docker and Docker Compose. Perfect for Raspberry Pi and production environments.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card scroll-animate">
|
||||
<span class="feature-icon">🔒</span>
|
||||
<h3 class="feature-title">Open Source</h3>
|
||||
<p class="feature-description">Licensed under GPL v3, ensuring derivatives remain open source and accessible to everyone.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Screenshots Section -->
|
||||
<section class="screenshots">
|
||||
<div class="container">
|
||||
<h2 class="section-title">See TimeTracker in Action</h2>
|
||||
<p class="section-subtitle">Beautiful, intuitive interface designed for productivity and ease of use</p>
|
||||
|
||||
<div class="screenshots-grid">
|
||||
<div class="screenshot-item scroll-animate">
|
||||
<div class="screenshot-image">
|
||||
<img src="assets/screenshots/Dashboard.png" alt="TimeTracker Dashboard - Clean interface showing active timers and recent activity" loading="lazy">
|
||||
</div>
|
||||
<div class="screenshot-caption">
|
||||
<h3>Dashboard View</h3>
|
||||
<p>Clean, intuitive interface showing active timers and recent activity. Quick access to start/stop timers and manual time entry.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screenshot-item scroll-animate">
|
||||
<div class="screenshot-image">
|
||||
<img src="assets/screenshots/Projects.png" alt="TimeTracker Projects - Client and project organization with billing information" loading="lazy">
|
||||
</div>
|
||||
<div class="screenshot-caption">
|
||||
<h3>Project Management</h3>
|
||||
<p>Client and project organization with billing information. Time tracking across multiple projects simultaneously.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screenshot-item scroll-animate">
|
||||
<div class="screenshot-image">
|
||||
<img src="assets/screenshots/Reports.png" alt="TimeTracker Reports - Comprehensive time reports with export capabilities" loading="lazy">
|
||||
</div>
|
||||
<div class="screenshot-caption">
|
||||
<h3>Reports & Analytics</h3>
|
||||
<p>Comprehensive time reports with export capabilities. Visual breakdowns of time allocation and productivity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Problem & Solution Section -->
|
||||
<section class="problem">
|
||||
<div class="container">
|
||||
<div class="problem-content">
|
||||
<div class="problem-text">
|
||||
<h2>Solving Real Time Tracking Problems</h2>
|
||||
<ul class="problem-list">
|
||||
<li>
|
||||
<span class="problem-icon">❌</span>
|
||||
<span>Traditional timers lose data when browsers close</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="problem-icon">❌</span>
|
||||
<span>Cloud dependencies and privacy concerns</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="problem-icon">❌</span>
|
||||
<span>Complex setup and maintenance</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="problem-icon">❌</span>
|
||||
<span>Limited reporting and export options</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="problem-visual">
|
||||
<h3>TimeTracker Solutions</h3>
|
||||
<p>✅ Persistent server-side timers</p>
|
||||
<p>✅ Self-hosted, no cloud required</p>
|
||||
<p>✅ Simple Docker deployment</p>
|
||||
<p>✅ Rich reporting and exports</p>
|
||||
<p>✅ Team collaboration features</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quick Start Section -->
|
||||
<section class="quick-start">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Get Started in Minutes</h2>
|
||||
<p class="section-subtitle">Deploy TimeTracker on your Raspberry Pi or any Linux system with just a few commands</p>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step scroll-animate">
|
||||
<div class="step-number">1</div>
|
||||
<h3 class="step-title">Clone Repository</h3>
|
||||
<p class="step-description">Get the latest version of TimeTracker from GitHub</p>
|
||||
<div class="code-block">
|
||||
<span class="comment"># Clone the repository</span><br>
|
||||
git clone https://github.com/DRYTRIX/TimeTracker.git<br>
|
||||
cd TimeTracker
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step scroll-animate">
|
||||
<div class="step-number">2</div>
|
||||
<h3 class="step-title">Configure Environment</h3>
|
||||
<p class="step-description">Set up your environment variables and preferences</p>
|
||||
<div class="code-block">
|
||||
<span class="comment"># Copy and edit environment file</span><br>
|
||||
cp .env.example .env<br>
|
||||
<span class="comment"># Edit with your settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step scroll-animate">
|
||||
<div class="step-number">3</div>
|
||||
<h3 class="step-title">Start Application</h3>
|
||||
<p class="step-description">Launch TimeTracker with Docker Compose</p>
|
||||
<div class="code-block">
|
||||
<span class="comment"># Start the application</span><br>
|
||||
docker-compose up -d
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step scroll-animate">
|
||||
<div class="step-number">4</div>
|
||||
<h3 class="step-title">Access & Use</h3>
|
||||
<p class="step-description">Open your browser and start tracking time</p>
|
||||
<div class="code-block">
|
||||
<span class="comment"># Access at</span><br>
|
||||
<span class="string">http://your-pi-ip:8080</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="stats">
|
||||
<div class="container">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<h3>100%</h3>
|
||||
<p>Open Source</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>0</h3>
|
||||
<p>Cloud Dependencies</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>∞</h3>
|
||||
<p>Customization</p>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<h3>🚀</h3>
|
||||
<p>Easy Deployment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker">GitHub Repository</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues">Issue Tracker</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/discussions">Discussions</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/releases">Releases</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h4>Documentation</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/README.md">README</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/CONTRIBUTING.md">Contributing</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/CODE_OF_CONDUCT.md">Code of Conduct</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/blob/main/LICENSE">License</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h4>Community</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/stargazers">Stars</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/network">Forks</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/graphs/contributors">Contributors</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/pulse">Activity</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h4>Support</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues/new">Report Bug</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/issues/new">Request Feature</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/discussions">Ask Question</a></li>
|
||||
<li><a href="https://github.com/DRYTRIX/TimeTracker/wiki">Wiki</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© 2024 TimeTracker. Licensed under <a href="https://github.com/DRYTRIX/TimeTracker/blob/main/LICENSE" style="color: var(--accent-color);">GNU General Public License v3.0</a></p>
|
||||
<p>Made with ❤️ for the open source community</p>
|
||||
|
||||
<div class="social-links">
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker" title="GitHub">📚</a>
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker/issues" title="Issues">🐛</a>
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker/discussions" title="Discussions">💬</a>
|
||||
<a href="https://github.com/DRYTRIX/TimeTracker/stargazers" title="Stars">⭐</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript for scroll animations -->
|
||||
<script>
|
||||
// Scroll animation observer
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate');
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe all scroll-animate elements
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const scrollElements = document.querySelectorAll('.scroll-animate');
|
||||
scrollElements.forEach(el => observer.observe(el));
|
||||
});
|
||||
|
||||
// Smooth scrolling for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add fade-in animation to header elements
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const headerElements = document.querySelectorAll('.header-content > *');
|
||||
headerElements.forEach((el, index) => {
|
||||
el.style.animationDelay = `${index * 0.2}s`;
|
||||
el.classList.add('fade-in-up');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -136,12 +136,12 @@
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.end_utc %}
|
||||
{% if entry.end_time %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Running</span>
|
||||
|
||||
@@ -28,7 +28,127 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="timezone">Timezone</label>
|
||||
<input type="text" class="form-control" id="timezone" name="timezone" value="{{ settings.timezone if settings else 'Europe/Brussels' }}" placeholder="e.g. Europe/Brussels">
|
||||
|
||||
<select class="form-select" id="timezone" name="timezone">
|
||||
<optgroup label="UTC">
|
||||
<option value="UTC" {% if settings and settings.timezone == 'UTC' %}selected{% endif %}>UTC (UTC+0)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Europe">
|
||||
<option value="Europe/London" {% if settings and settings.timezone == 'Europe/London' %}selected{% endif %}>Europe/London (UTC+0/+1)</option>
|
||||
<option value="Europe/Paris" {% if settings and settings.timezone == 'Europe/Paris' %}selected{% endif %}>Europe/Paris (UTC+1/+2)</option>
|
||||
<option value="Europe/Berlin" {% if settings and settings.timezone == 'Europe/Berlin' %}selected{% endif %}>Europe/Berlin (UTC+1/+2)</option>
|
||||
<option value="Europe/Rome" {% if (settings and settings.timezone == 'Europe/Rome') or not settings %}selected{% endif %}>Europe/Rome (UTC+1/+2)</option>
|
||||
<option value="Europe/Madrid" {% if settings and settings.timezone == 'Europe/Madrid' %}selected{% endif %}>Europe/Madrid (UTC+1/+2)</option>
|
||||
<option value="Europe/Amsterdam" {% if settings and settings.timezone == 'Europe/Amsterdam' %}selected{% endif %}>Europe/Amsterdam (UTC+1/+2)</option>
|
||||
<option value="Europe/Brussels" {% if settings and settings.timezone == 'Europe/Brussels' %}selected{% endif %}>Europe/Brussels (UTC+1/+2)</option>
|
||||
<option value="Europe/Vienna" {% if settings and settings.timezone == 'Europe/Vienna' %}selected{% endif %}>Europe/Vienna (UTC+1/+2)</option>
|
||||
<option value="Europe/Zurich" {% if settings and settings.timezone == 'Europe/Zurich' %}selected{% endif %}>Europe/Zurich (UTC+1/+2)</option>
|
||||
<option value="Europe/Prague" {% if settings and settings.timezone == 'Europe/Prague' %}selected{% endif %}>Europe/Prague (UTC+1/+2)</option>
|
||||
<option value="Europe/Warsaw" {% if settings and settings.timezone == 'Europe/Warsaw' %}selected{% endif %}>Europe/Warsaw (UTC+1/+2)</option>
|
||||
<option value="Europe/Budapest" {% if settings and settings.timezone == 'Europe/Budapest' %}selected{% endif %}>Europe/Budapest (UTC+1/+2)</option>
|
||||
<option value="Europe/Stockholm" {% if settings and settings.timezone == 'Europe/Stockholm' %}selected{% endif %}>Europe/Stockholm (UTC+1/+2)</option>
|
||||
<option value="Europe/Oslo" {% if settings and settings.timezone == 'Europe/Oslo' %}selected{% endif %}>Europe/Oslo (UTC+1/+2)</option>
|
||||
<option value="Europe/Copenhagen" {% if settings and settings.timezone == 'Europe/Copenhagen' %}selected{% endif %}>Europe/Copenhagen (UTC+1/+2)</option>
|
||||
<option value="Europe/Helsinki" {% if settings and settings.timezone == 'Europe/Helsinki' %}selected{% endif %}>Europe/Helsinki (UTC+2/+3)</option>
|
||||
<option value="Europe/Athens" {% if settings and settings.timezone == 'Europe/Athens' %}selected{% endif %}>Europe/Athens (UTC+2/+3)</option>
|
||||
<option value="Europe/Istanbul" {% if settings and settings.timezone == 'Europe/Istanbul' %}selected{% endif %}>Europe/Istanbul (UTC+3)</option>
|
||||
<option value="Europe/Moscow" {% if settings and settings.timezone == 'Europe/Moscow' %}selected{% endif %}>Europe/Moscow (UTC+3)</option>
|
||||
<option value="Europe/Kiev" {% if settings and settings.timezone == 'Europe/Kiev' %}selected{% endif %}>Europe/Kiev (UTC+2/+3)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="North America">
|
||||
<option value="America/New_York" {% if settings and settings.timezone == 'America/New_York' %}selected{% endif %}>America/New_York (UTC-5/-4)</option>
|
||||
<option value="America/Chicago" {% if settings and settings.timezone == 'America/Chicago' %}selected{% endif %}>America/Chicago (UTC-6/-5)</option>
|
||||
<option value="America/Denver" {% if settings and settings.timezone == 'America/Denver' %}selected{% endif %}>America/Denver (UTC-7/-6)</option>
|
||||
<option value="America/Los_Angeles" {% if settings and settings.timezone == 'America/Los_Angeles' %}selected{% endif %}>America/Los_Angeles (UTC-8/-7)</option>
|
||||
<option value="America/Toronto" {% if settings and settings.timezone == 'America/Toronto' %}selected{% endif %}>America/Toronto (UTC-5/-4)</option>
|
||||
<option value="America/Vancouver" {% if settings and settings.timezone == 'America/Vancouver' %}selected{% endif %}>America/Vancouver (UTC-8/-7)</option>
|
||||
<option value="America/Mexico_City" {% if settings and settings.timezone == 'America/Mexico_City' %}selected{% endif %}>America/Mexico_City (UTC-6/-5)</option>
|
||||
<option value="America/Phoenix" {% if settings and settings.timezone == 'America/Phoenix' %}selected{% endif %}>America/Phoenix (UTC-7)</option>
|
||||
<option value="America/Anchorage" {% if settings and settings.timezone == 'America/Anchorage' %}selected{% endif %}>America/Anchorage (UTC-9/-8)</option>
|
||||
<option value="America/Honolulu" {% if settings and settings.timezone == 'America/Honolulu' %}selected{% endif %}>America/Honolulu (UTC-10)</option>
|
||||
<option value="America/Sao_Paulo" {% if settings and settings.timezone == 'America/Sao_Paulo' %}selected{% endif %}>America/Sao_Paulo (UTC-3/-2)</option>
|
||||
<option value="America/Buenos_Aires" {% if settings and settings.timezone == 'America/Buenos_Aires' %}selected{% endif %}>America/Buenos_Aires (UTC-3)</option>
|
||||
<option value="America/Santiago" {% if settings and settings.timezone == 'America/Santiago' %}selected{% endif %}>America/Santiago (UTC-3/-4)</option>
|
||||
<option value="America/Lima" {% if settings and settings.timezone == 'America/Lima' %}selected{% endif %}>America/Lima (UTC-5)</option>
|
||||
<option value="America/Bogota" {% if settings and settings.timezone == 'America/Bogota' %}selected{% endif %}>America/Bogota (UTC-5)</option>
|
||||
<option value="America/Caracas" {% if settings and settings.timezone == 'America/Caracas' %}selected{% endif %}>America/Caracas (UTC-4)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Asia">
|
||||
<option value="Asia/Tokyo" {% if settings and settings.timezone == 'Asia/Tokyo' %}selected{% endif %}>Asia/Tokyo (UTC+9)</option>
|
||||
<option value="Asia/Shanghai" {% if settings and settings.timezone == 'Asia/Shanghai' %}selected{% endif %}>Asia/Shanghai (UTC+8)</option>
|
||||
<option value="Asia/Seoul" {% if settings and settings.timezone == 'Asia/Seoul' %}selected{% endif %}>Asia/Seoul (UTC+9)</option>
|
||||
<option value="Asia/Hong_Kong" {% if settings and settings.timezone == 'Asia/Hong_Kong' %}selected{% endif %}>Asia/Hong_Kong (UTC+8)</option>
|
||||
<option value="Asia/Singapore" {% if settings and settings.timezone == 'Asia/Singapore' %}selected{% endif %}>Asia/Singapore (UTC+8)</option>
|
||||
<option value="Asia/Bangkok" {% if settings and settings.timezone == 'Asia/Bangkok' %}selected{% endif %}>Asia/Bangkok (UTC+7)</option>
|
||||
<option value="Asia/Ho_Chi_Minh" {% if settings and settings.timezone == 'Asia/Ho_Chi_Minh' %}selected{% endif %}>Asia/Ho_Chi_Minh (UTC+7)</option>
|
||||
<option value="Asia/Jakarta" {% if settings and settings.timezone == 'Asia/Jakarta' %}selected{% endif %}>Asia/Jakarta (UTC+7)</option>
|
||||
<option value="Asia/Manila" {% if settings and settings.timezone == 'Asia/Manila' %}selected{% endif %}>Asia/Manila (UTC+8)</option>
|
||||
<option value="Asia/Kolkata" {% if settings and settings.timezone == 'Asia/Kolkata' %}selected{% endif %}>Asia/Kolkata (UTC+5:30)</option>
|
||||
<option value="Asia/Dhaka" {% if settings and settings.timezone == 'Asia/Dhaka' %}selected{% endif %}>Asia/Dhaka (UTC+6)</option>
|
||||
<option value="Asia/Kathmandu" {% if settings and settings.timezone == 'Asia/Kathmandu' %}selected{% endif %}>Asia/Kathmandu (UTC+5:45)</option>
|
||||
<option value="Asia/Tashkent" {% if settings and settings.timezone == 'Asia/Tashkent' %}selected{% endif %}>Asia/Tashkent (UTC+5)</option>
|
||||
<option value="Asia/Dubai" {% if settings and settings.timezone == 'Asia/Dubai' %}selected{% endif %}>Asia/Dubai (UTC+4)</option>
|
||||
<option value="Asia/Tehran" {% if settings and settings.timezone == 'Asia/Tehran' %}selected{% endif %}>Asia/Tehran (UTC+3:30/+4:30)</option>
|
||||
<option value="Asia/Jerusalem" {% if settings and settings.timezone == 'Asia/Jerusalem' %}selected{% endif %}>Asia/Jerusalem (UTC+2/+3)</option>
|
||||
<option value="Asia/Riyadh" {% if settings and settings.timezone == 'Asia/Riyadh' %}selected{% endif %}>Asia/Riyadh (UTC+3)</option>
|
||||
<option value="Asia/Baghdad" {% if settings and settings.timezone == 'Asia/Baghdad' %}selected{% endif %}>Asia/Baghdad (UTC+3)</option>
|
||||
<option value="Asia/Kabul" {% if settings and settings.timezone == 'Asia/Kabul' %}selected{% endif %}>Asia/Kabul (UTC+4:30)</option>
|
||||
<option value="Asia/Almaty" {% if settings and settings.timezone == 'Asia/Almaty' %}selected{% endif %}>Asia/Almaty (UTC+6)</option>
|
||||
<option value="Asia/Novosibirsk" {% if settings and settings.timezone == 'Asia/Novosibirsk' %}selected{% endif %}>Asia/Novosibirsk (UTC+7)</option>
|
||||
<option value="Asia/Vladivostok" {% if settings and settings.timezone == 'Asia/Vladivostok' %}selected{% endif %}>Asia/Vladivostok (UTC+10)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Australia & Pacific">
|
||||
<option value="Australia/Sydney" {% if settings and settings.timezone == 'Australia/Sydney' %}selected{% endif %}>Australia/Sydney (UTC+10/+11)</option>
|
||||
<option value="Australia/Melbourne" {% if settings and settings.timezone == 'Australia/Melbourne' %}selected{% endif %}>Australia/Melbourne (UTC+10/+11)</option>
|
||||
<option value="Australia/Brisbane" {% if settings and settings.timezone == 'Australia/Brisbane' %}selected{% endif %}>Australia/Brisbane (UTC+10)</option>
|
||||
<option value="Australia/Perth" {% if settings and settings.timezone == 'Australia/Perth' %}selected{% endif %}>Australia/Perth (UTC+8)</option>
|
||||
<option value="Australia/Adelaide" {% if settings and settings.timezone == 'Australia/Adelaide' %}selected{% endif %}>Australia/Adelaide (UTC+9:30/+10:30)</option>
|
||||
<option value="Australia/Darwin" {% if settings and settings.timezone == 'Australia/Darwin' %}selected{% endif %}>Australia/Darwin (UTC+9:30)</option>
|
||||
<option value="Pacific/Auckland" {% if settings and settings.timezone == 'Pacific/Auckland' %}selected{% endif %}>Pacific/Auckland (UTC+12/+13)</option>
|
||||
<option value="Pacific/Fiji" {% if settings and settings.timezone == 'Pacific/Fiji' %}selected{% endif %}>Pacific/Fiji (UTC+12)</option>
|
||||
<option value="Pacific/Guam" {% if settings and settings.timezone == 'Pacific/Guam' %}selected{% endif %}>Pacific/Guam (UTC+10)</option>
|
||||
<option value="Pacific/Honolulu" {% if settings and settings.timezone == 'Pacific/Honolulu' %}selected{% endif %}>Pacific/Honolulu (UTC-10)</option>
|
||||
<option value="Pacific/Tahiti" {% if settings and settings.timezone == 'Pacific/Tahiti' %}selected{% endif %}>Pacific/Tahiti (UTC-10)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Africa">
|
||||
<option value="Africa/Cairo" {% if settings and settings.timezone == 'Africa/Cairo' %}selected{% endif %}>Africa/Cairo (UTC+2)</option>
|
||||
<option value="Africa/Johannesburg" {% if settings and settings.timezone == 'Africa/Johannesburg' %}selected{% endif %}>Africa/Johannesburg (UTC+2)</option>
|
||||
<option value="Africa/Lagos" {% if settings and settings.timezone == 'Africa/Lagos' %}selected{% endif %}>Africa/Lagos (UTC+1)</option>
|
||||
<option value="Africa/Nairobi" {% if settings and settings.timezone == 'Africa/Nairobi' %}selected{% endif %}>Africa/Nairobi (UTC+3)</option>
|
||||
<option value="Africa/Casablanca" {% if settings and settings.timezone == 'Africa/Casablanca' %}selected{% endif %}>Africa/Casablanca (UTC+0/+1)</option>
|
||||
<option value="Africa/Algiers" {% if settings and settings.timezone == 'Africa/Algiers' %}selected{% endif %}>Africa/Algiers (UTC+1)</option>
|
||||
<option value="Africa/Tunis" {% if settings and settings.timezone == 'Africa/Tunis' %}selected{% endif %}>Africa/Tunis (UTC+1)</option>
|
||||
<option value="Africa/Dar_es_Salaam" {% if settings and settings.timezone == 'Africa/Dar_es_Salaam' %}selected{% endif %}>Africa/Dar_es_Salaam (UTC+3)</option>
|
||||
<option value="Africa/Addis_Ababa" {% if settings and settings.timezone == 'Africa/Addis_Ababa' %}selected{% endif %}>Africa/Addis_Ababa (UTC+3)</option>
|
||||
<option value="Africa/Khartoum" {% if settings and settings.timezone == 'Africa/Khartoum' %}selected{% endif %}>Africa/Khartoum (UTC+2)</option>
|
||||
<option value="Africa/Luanda" {% if settings and settings.timezone == 'Africa/Luanda' %}selected{% endif %}>Africa/Luanda (UTC+1)</option>
|
||||
<option value="Africa/Kinshasa" {% if settings and settings.timezone == 'Africa/Kinshasa' %}selected{% endif %}>Africa/Kinshasa (UTC+1)</option>
|
||||
<option value="Africa/Harare" {% if settings and settings.timezone == 'Africa/Harare' %}selected{% endif %}>Africa/Harare (UTC+2)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Atlantic & Indian Ocean">
|
||||
<option value="Atlantic/Reykjavik" {% if settings and settings.timezone == 'Atlantic/Reykjavik' %}selected{% endif %}>Atlantic/Reykjavik (UTC+0)</option>
|
||||
<option value="Atlantic/Azores" {% if settings and settings.timezone == 'Atlantic/Azores' %}selected{% endif %}>Atlantic/Azores (UTC-1/+0)</option>
|
||||
<option value="Atlantic/Canary" {% if settings and settings.timezone == 'Atlantic/Canary' %}selected{% endif %}>Atlantic/Canary (UTC+0/+1)</option>
|
||||
<option value="Atlantic/Cape_Verde" {% if settings and settings.timezone == 'Atlantic/Cape_Verde' %}selected{% endif %}>Atlantic/Cape_Verde (UTC-1)</option>
|
||||
<option value="Indian/Mauritius" {% if settings and settings.timezone == 'Indian/Mauritius' %}selected{% endif %}>Indian/Mauritius (UTC+4)</option>
|
||||
<option value="Indian/Reunion" {% if settings and settings.timezone == 'Indian/Reunion' %}selected{% endif %}>Indian/Reunion (UTC+4)</option>
|
||||
<option value="Indian/Maldives" {% if settings and settings.timezone == 'Indian/Maldives' %}selected{% endif %}>Indian/Maldives (UTC+5)</option>
|
||||
<option value="Indian/Chagos" {% if settings and settings.timezone == 'Indian/Chagos' %}selected{% endif %}>Indian/Chagos (UTC+6)</option>
|
||||
</optgroup>
|
||||
|
||||
<optgroup label="Arctic & Antarctic">
|
||||
<option value="Arctic/Longyearbyen" {% if settings and settings.timezone == 'Arctic/Longyearbyen' %}selected{% endif %}>Arctic/Longyearbyen (UTC+1/+2)</option>
|
||||
<option value="Antarctica/McMurdo" {% if settings and settings.timezone == 'Antarctica/McMurdo' %}selected{% endif %}>Antarctica/McMurdo (UTC+12/+13)</option>
|
||||
<option value="Antarctica/Palmer" {% if settings and settings.timezone == 'Antarctica/Palmer' %}selected{% endif %}>Antarctica/Palmer (UTC-3)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<small class="form-text text-muted">Select your local timezone for proper time display. Times shown include DST adjustments.</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="currency">Currency</label>
|
||||
@@ -80,6 +200,39 @@
|
||||
<i class="fas fa-save"></i> Save Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-3">
|
||||
<div class="alert alert-info">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<i class="fas fa-clock me-2"></i>
|
||||
<strong>Current Time:</strong>
|
||||
<span id="current-time-display" class="h5 mb-0 ms-2">Loading...</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<small class="text-muted">
|
||||
in <strong>{{ settings.timezone if settings else 'Europe/Rome' }}</strong> timezone
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
This time updates every second and shows the current time in your selected timezone
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-globe me-1"></i>
|
||||
Current offset: <span id="timezone-offset">--</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -103,6 +256,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const timezoneSelect = document.getElementById('timezone');
|
||||
const currentTimeDisplay = document.getElementById('current-time-display');
|
||||
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timezone = timezoneSelect.value;
|
||||
|
||||
if (!timezone) {
|
||||
currentTimeDisplay.textContent = 'No timezone selected';
|
||||
document.getElementById('timezone-offset').textContent = '--';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format time in the selected timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const localTime = formatter.format(now);
|
||||
currentTimeDisplay.textContent = localTime;
|
||||
|
||||
// Calculate and display timezone offset
|
||||
const utcTime = new Date(now.toLocaleString("en-US", {timeZone: "UTC"}));
|
||||
const localTimeObj = new Date(now.toLocaleString("en-US", {timeZone: timezone}));
|
||||
const offset = (localTimeObj - utcTime) / (1000 * 60 * 60);
|
||||
|
||||
const offsetText = offset >= 0 ? `UTC+${offset.toFixed(1)}` : `UTC${offset.toFixed(1)}`;
|
||||
document.getElementById('timezone-offset').textContent = offsetText;
|
||||
|
||||
} catch (e) {
|
||||
currentTimeDisplay.textContent = 'Invalid timezone';
|
||||
document.getElementById('timezone-offset').textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
// Update time when timezone changes
|
||||
timezoneSelect.addEventListener('change', updateCurrentTime);
|
||||
|
||||
// Update time every second
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// Initial update with a small delay to ensure DOM is ready
|
||||
setTimeout(updateCurrentTime, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -190,15 +190,15 @@
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_utc.strftime('%H:%M') }} -
|
||||
{% if entry.end_utc %}
|
||||
{{ entry.end_utc.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_time.strftime('%H:%M') }} -
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
|
||||
@@ -262,15 +262,15 @@
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_utc.strftime('%H:%M') }} -
|
||||
{% if entry.end_utc %}
|
||||
{{ entry.end_utc.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_time.strftime('%H:%M') }} -
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
|
||||
@@ -207,15 +207,15 @@
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_utc.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_utc.strftime('%H:%M') }} -
|
||||
{% if entry.end_utc %}
|
||||
{{ entry.end_utc.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_time.strftime('%H:%M') }} -
|
||||
{% if entry.end_time %}
|
||||
{{ entry.end_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Start:</strong> {{ timer.start_utc.strftime('%Y-%m-%d %H:%M') }}
|
||||
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>End:</strong>
|
||||
{% 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 %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -412,10 +412,10 @@ function loadRecentEntries() {
|
||||
${entry.notes ? `<small class="text-muted d-block">${entry.notes}</small>` : ''}
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
${new Date(entry.start_utc).toLocaleDateString()}
|
||||
${new Date(entry.start_time).toLocaleDateString()}
|
||||
<i class="fas fa-clock ms-2 me-1"></i>
|
||||
${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'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -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])
|
||||
|
||||
227
tests/test_timezone.py
Normal file
227
tests/test_timezone.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user