first commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.cover
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
# CSV files
|
||||
*.csv
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Others
|
||||
*.bak
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -0,0 +1,15 @@
|
||||
# ZynkTime Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Required: Kleer API Key
|
||||
KLEER_API_KEY=your_api_key_here
|
||||
|
||||
# Optional: Username (defaults to "Christopher Juhlin")
|
||||
KLEER_USERNAME=Christopher Juhlin
|
||||
|
||||
# Optional: Company ID (defaults to "1336")
|
||||
KLEER_COMPANY_ID=1336
|
||||
|
||||
# Server Configuration (for advanced users)
|
||||
# SERVER_HOST=0.0.0.0
|
||||
# SERVER_PORT=8000
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.cover
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# CSV files (user data)
|
||||
*.csv
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Guidelines for zynktime
|
||||
|
||||
## Setup
|
||||
- **Install dependencies**: `pip install -r requirements.txt`
|
||||
- **Set environment**: `export KLEER_API_KEY=your_key_here` (required)
|
||||
- **Optional env vars**: `KLEER_USERNAME`, `KLEER_COMPANY_ID`
|
||||
|
||||
## Run Commands
|
||||
- **Run API server**: `python main.py` (starts FastAPI on port 8000)
|
||||
- **Run CLI mode**: `python main.py cli`
|
||||
- **Run tests**: `pytest` or `pytest test_main.py -v`
|
||||
- **Run single test**: `pytest test_main.py::TestServices::test_validate_date_valid -v`
|
||||
|
||||
## Project Structure
|
||||
- **config.py**: Application configuration and environment variables
|
||||
- **models.py**: Pydantic models for data validation
|
||||
- **api_client.py**: Kleer API client with error handling
|
||||
- **services.py**: Business logic (CSV parsing, time extraction, event creation)
|
||||
- **main.py**: FastAPI application and endpoints
|
||||
- **cli.py**: CLI interface for interactive time reporting
|
||||
- **test_main.py**: Unit tests with pytest
|
||||
|
||||
## Code Style
|
||||
|
||||
**Imports**: Standard library first, third-party second, local imports last. Group by category with blank lines between groups.
|
||||
|
||||
**Formatting**: 4 spaces indentation, no trailing whitespace. Use type hints from `typing` (List, Dict, Any, Optional).
|
||||
|
||||
**Naming**: snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants. Be descriptive.
|
||||
|
||||
**Error Handling**: Use specific exceptions. Log with `logging.error()`. For API endpoints, raise `HTTPException` with proper status codes (404, 500, 502).
|
||||
|
||||
**Logging**: Use appropriate levels - INFO (normal ops), WARNING (skippable issues), ERROR (failures), DEBUG (detailed info).
|
||||
|
||||
**Configuration**: Use `Config` class from config.py. Never hardcode values - use Config constants or environment variables.
|
||||
|
||||
**API Design**: Use Pydantic models for validation. Document endpoints with docstrings. Return meaningful error messages.
|
||||
|
||||
**Testing**: Write tests for new features. Use mocks for external API calls. Maintain test coverage.
|
||||
@@ -0,0 +1,66 @@
|
||||
# CSV Format Examples
|
||||
|
||||
ZynkTime supports flexible CSV formats with automatic delimiter and column detection.
|
||||
|
||||
## Example 1: Standard Time Reporting Format
|
||||
|
||||
```csv
|
||||
reg_period,xreg_period,T,Time code,Time code (T),Vouch.date,Project Activity,Project Activity (T),Work package,Work package (T),Hours,Workflow status,Text
|
||||
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
|
||||
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
|
||||
202544,,A,0,Standard Time,10/27/2025,,,,,4.00,N,
|
||||
202544,,A,0,Standard Time,10/28/2025,,,,,4.00,N,
|
||||
202544,,A,0,Standard Time,10/28/2025,,,,,1.00,N,
|
||||
```
|
||||
|
||||
**Result**:
|
||||
- 2025-10-27: 8.0 hours
|
||||
- 2025-10-28: 5.0 hours
|
||||
|
||||
## Example 2: Semicolon-Separated Format
|
||||
|
||||
```csv
|
||||
project;task;user;status;notes;date;client;region;type;category;hours;approved
|
||||
ProjectA;Task1;John;Done;Notes;01/15/2024;ClientX;EU;Dev;Backend;8.5;Yes
|
||||
ProjectA;Task2;John;Done;Notes;01/16/2024;ClientX;EU;Dev;Backend;7,5;Yes
|
||||
```
|
||||
|
||||
**Result**:
|
||||
- 2024-01-15: 8.5 hours
|
||||
- 2024-01-16: 7.5 hours
|
||||
|
||||
## Features
|
||||
|
||||
### Automatic Detection
|
||||
- ✅ **Delimiter**: Auto-detects comma (`,`) or semicolon (`;`)
|
||||
- ✅ **Date Column**: Searches for "date", "vouch", or similar keywords
|
||||
- ✅ **Hours Column**: Searches for "hour", "time" keywords
|
||||
- ✅ **Date Formats**: Supports `MM/DD/YYYY` and `YYYY-MM-DD`
|
||||
- ✅ **Decimal Separator**: Handles both comma and dot (`,` or `.`)
|
||||
|
||||
### Data Processing
|
||||
- Header row automatically skipped
|
||||
- Whitespace trimmed from values
|
||||
- Hours below threshold (0.2) filtered out
|
||||
- Multiple entries for same date are aggregated
|
||||
- Invalid rows logged and skipped gracefully
|
||||
|
||||
## Testing Your CSV
|
||||
|
||||
To test if your CSV will work:
|
||||
|
||||
```bash
|
||||
# Method 1: Use the API
|
||||
curl -X POST "http://localhost:8000/report-time?project_name=YourProject" \
|
||||
-F "file=@your_file.csv"
|
||||
|
||||
# Method 2: Use the CLI
|
||||
python main.py cli
|
||||
# Then follow the interactive prompts
|
||||
```
|
||||
|
||||
The application will:
|
||||
1. Auto-detect your CSV format
|
||||
2. Show detected columns in logs
|
||||
3. Display parsed work time before submission
|
||||
4. Ask for confirmation before reporting
|
||||
@@ -0,0 +1,485 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
Complete guide for deploying ZynkTime with Docker.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Basic Setup
|
||||
|
||||
```bash
|
||||
# Clone and navigate to project
|
||||
cd zynktime
|
||||
|
||||
# Create environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your API key
|
||||
nano .env
|
||||
|
||||
# Start the application
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:8000
|
||||
```
|
||||
|
||||
### 2. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Test health endpoint
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Edit `.env` file:
|
||||
|
||||
```env
|
||||
# Required
|
||||
KLEER_API_KEY=your_actual_api_key_here
|
||||
|
||||
# Optional (with defaults)
|
||||
KLEER_USERNAME=Christopher Juhlin
|
||||
KLEER_COMPANY_ID=1336
|
||||
```
|
||||
|
||||
### Docker Compose Options
|
||||
|
||||
**Development Mode** (with live reload):
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
build: .
|
||||
volumes:
|
||||
- ./static:/app/static:ro
|
||||
- ./:/app:ro # Mount source code
|
||||
environment:
|
||||
- RELOAD=true
|
||||
```
|
||||
|
||||
**Production Mode** (optimized):
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
image: zynktime:1.0.0
|
||||
restart: always
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Starting and Stopping
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
|
||||
# Start in foreground (see logs)
|
||||
docker-compose up
|
||||
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
|
||||
# Stop and remove volumes
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# Follow all logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose logs --tail=100
|
||||
|
||||
# Specific service logs
|
||||
docker-compose logs -f zynktime
|
||||
```
|
||||
|
||||
### Updating the Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose up -d --build
|
||||
|
||||
# Or force recreate
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
### Accessing the Container
|
||||
|
||||
```bash
|
||||
# Open bash shell
|
||||
docker-compose exec zynktime bash
|
||||
|
||||
# Run CLI mode
|
||||
docker-compose exec zynktime python main.py cli
|
||||
|
||||
# Run tests
|
||||
docker-compose exec zynktime pytest -v
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### With Nginx Reverse Proxy
|
||||
|
||||
**nginx.conf:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name time.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zynktime:
|
||||
build: .
|
||||
restart: always
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
- nginx-proxy
|
||||
|
||||
networks:
|
||||
nginx-proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
### With Traefik
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zynktime:
|
||||
build: .
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.zynktime.rule=Host(`time.example.com`)"
|
||||
- "traefik.http.routers.zynktime.entrypoints=websecure"
|
||||
- "traefik.http.routers.zynktime.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.zynktime.loadbalancer.server.port=8000"
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
### SSL/HTTPS Setup
|
||||
|
||||
**Option 1: Let's Encrypt with Traefik**
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v2.9
|
||||
command:
|
||||
- "[email protected]"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
```
|
||||
|
||||
**Option 2: Manual SSL with Nginx**
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name time.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resource Management
|
||||
|
||||
### Memory and CPU Limits
|
||||
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
build: .
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
```
|
||||
|
||||
### Scaling
|
||||
|
||||
```bash
|
||||
# Run multiple instances
|
||||
docker-compose up -d --scale zynktime=3
|
||||
|
||||
# With load balancer
|
||||
docker-compose up -d --scale zynktime=3 nginx
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check health status
|
||||
docker inspect zynktime | jq '.[0].State.Health'
|
||||
|
||||
# Watch health status
|
||||
watch -n 5 'docker inspect zynktime | jq ".[0].State.Health"'
|
||||
```
|
||||
|
||||
### Resource Usage
|
||||
|
||||
```bash
|
||||
# Real-time stats
|
||||
docker stats zynktime
|
||||
|
||||
# Detailed info
|
||||
docker-compose exec zynktime top
|
||||
```
|
||||
|
||||
### Log Management
|
||||
|
||||
**Rotate logs:**
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
**Send to external logging:**
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
logging:
|
||||
driver: "syslog"
|
||||
options:
|
||||
syslog-address: "tcp://logs.example.com:514"
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup Configuration
|
||||
|
||||
```bash
|
||||
# Backup .env file
|
||||
cp .env .env.backup
|
||||
|
||||
# Backup logs (if using volume)
|
||||
docker-compose exec zynktime tar -czf /tmp/logs.tar.gz /app/logs
|
||||
docker cp zynktime:/tmp/logs.tar.gz ./logs-backup.tar.gz
|
||||
```
|
||||
|
||||
### Disaster Recovery
|
||||
|
||||
```bash
|
||||
# Export container
|
||||
docker commit zynktime zynktime-backup:$(date +%Y%m%d)
|
||||
docker save zynktime-backup:latest | gzip > zynktime-backup.tar.gz
|
||||
|
||||
# Restore from backup
|
||||
docker load < zynktime-backup.tar.gz
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs zynktime
|
||||
|
||||
# Check container details
|
||||
docker inspect zynktime
|
||||
|
||||
# Verify environment
|
||||
docker-compose config
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
|
||||
```bash
|
||||
# Fix ownership (if needed)
|
||||
sudo chown -R 1000:1000 ./static ./logs
|
||||
|
||||
# Check user inside container
|
||||
docker-compose exec zynktime id
|
||||
```
|
||||
|
||||
### Network Issues
|
||||
|
||||
```bash
|
||||
# Test connectivity
|
||||
docker-compose exec zynktime curl http://localhost:8000/health
|
||||
|
||||
# Check network
|
||||
docker network inspect zynktime-network
|
||||
|
||||
# Recreate network
|
||||
docker-compose down
|
||||
docker network prune
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### API Key Issues
|
||||
|
||||
```bash
|
||||
# Verify environment variables
|
||||
docker-compose exec zynktime env | grep KLEER
|
||||
|
||||
# Test API connection
|
||||
docker-compose exec zynktime python -c "from config import Config; Config.validate_config()"
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Run as Non-Root User
|
||||
Already configured in Dockerfile (user: zynktime, UID: 1000)
|
||||
|
||||
### 2. Read-Only Filesystem
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
### 3. Drop Capabilities
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
```
|
||||
|
||||
### 4. Network Isolation
|
||||
```yaml
|
||||
services:
|
||||
zynktime:
|
||||
networks:
|
||||
- internal
|
||||
# Only expose necessary ports
|
||||
```
|
||||
|
||||
### 5. Secrets Management
|
||||
```bash
|
||||
# Use Docker secrets instead of .env
|
||||
echo "my_api_key" | docker secret create kleer_api_key -
|
||||
|
||||
# In compose file:
|
||||
services:
|
||||
zynktime:
|
||||
secrets:
|
||||
- kleer_api_key
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Image Size Optimization
|
||||
|
||||
```dockerfile
|
||||
# Multi-stage build
|
||||
FROM python:3.11-slim as builder
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
COPY . /app
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
|
||||
```bash
|
||||
# Use BuildKit for better caching
|
||||
DOCKER_BUILDKIT=1 docker-compose build
|
||||
|
||||
# Cache requirements separately
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build and push
|
||||
run: |
|
||||
docker build -t zynktime:latest .
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
stage: deploy
|
||||
script:
|
||||
- docker-compose build
|
||||
- docker-compose up -d
|
||||
only:
|
||||
- main
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [Docker Compose Reference](https://docs.docker.com/compose/)
|
||||
- [FastAPI Deployment](https://fastapi.tiangolo.com/deployment/)
|
||||
- [Python Docker Best Practices](https://docs.docker.com/language/python/build-images/)
|
||||
@@ -0,0 +1,97 @@
|
||||
# Docker Quick Start Guide
|
||||
|
||||
Get ZynkTime running in 3 simple steps!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed ([Get Docker](https://docs.docker.com/get-docker/))
|
||||
- Docker Compose installed (included with Docker Desktop)
|
||||
|
||||
## Step 1: Configure
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your API key
|
||||
nano .env
|
||||
```
|
||||
|
||||
Add your API key:
|
||||
```env
|
||||
KLEER_API_KEY=your_actual_api_key_here
|
||||
KLEER_USERNAME=Your Name
|
||||
KLEER_COMPANY_ID=1336
|
||||
```
|
||||
|
||||
## Step 2: Start
|
||||
|
||||
```bash
|
||||
# Build and start the application
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the Docker image
|
||||
- Start the container
|
||||
- Expose the application on port 8000
|
||||
|
||||
## Step 3: Use
|
||||
|
||||
Open your browser:
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
You should see the beautiful ZynkTime web interface!
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop application
|
||||
docker-compose down
|
||||
|
||||
# Restart after changes
|
||||
docker-compose restart
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't start?**
|
||||
```bash
|
||||
docker-compose logs zynktime
|
||||
```
|
||||
|
||||
**API key not working?**
|
||||
```bash
|
||||
# Check environment variables
|
||||
docker-compose exec zynktime env | grep KLEER
|
||||
```
|
||||
|
||||
**Port already in use?**
|
||||
Edit `docker-compose.yml` and change the port:
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:8000" # Change 8080 to any free port
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- See [DOCKER.md](DOCKER.md) for advanced configuration
|
||||
- See [README.md](README.md) for full documentation
|
||||
- See [UI_FEATURES.md](UI_FEATURES.md) for UI details
|
||||
|
||||
## Support
|
||||
|
||||
For issues, check the logs first:
|
||||
```bash
|
||||
docker-compose logs -f zynktime
|
||||
```
|
||||
|
||||
Happy time reporting! 🎉
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -m -u 1000 zynktime && \
|
||||
chown -R zynktime:zynktime /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER zynktime
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,312 @@
|
||||
# ZynkTime
|
||||
|
||||
A time reporting application for the Kleer API with both FastAPI web interface and CLI.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 **Modern Web UI**: Beautiful, responsive web interface with drag-and-drop file upload
|
||||
- 📊 **CSV Processing**: Parse and extract work time from CSV files
|
||||
- 🌐 **REST API**: FastAPI endpoints for time reporting
|
||||
- 💻 **CLI Interface**: Interactive command-line time reporting
|
||||
- ✅ **Input Validation**: Pydantic models for data validation
|
||||
- 🧪 **Tested**: Unit tests with pytest
|
||||
- 📝 **Well-Documented**: Comprehensive docstrings and type hints
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 🐳 Docker (Recommended)
|
||||
|
||||
The easiest way to run ZynkTime is with Docker:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
cd zynktime
|
||||
|
||||
# 2. Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Edit .env and add your API key
|
||||
nano .env # or use your favorite editor
|
||||
|
||||
# 4. Start with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# 5. Open your browser
|
||||
# Navigate to: http://localhost:8000
|
||||
```
|
||||
|
||||
**Docker Commands:**
|
||||
```bash
|
||||
# Start the application
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop the application
|
||||
docker-compose down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose up -d --build
|
||||
|
||||
# Access container shell
|
||||
docker-compose exec zynktime bash
|
||||
```
|
||||
|
||||
### 🐍 Manual Installation
|
||||
|
||||
If you prefer to run without Docker:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
cd zynktime
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**Option 1: Environment File (Recommended)**
|
||||
```bash
|
||||
# Copy example file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your values
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Option 2: Export Variables**
|
||||
```bash
|
||||
export KLEER_API_KEY="your_api_key_here"
|
||||
export KLEER_USERNAME="Your Name" # Optional, defaults to "Christopher Juhlin"
|
||||
export KLEER_COMPANY_ID="1336" # Optional, defaults to "1336"
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
**Web UI Mode (Recommended):**
|
||||
```bash
|
||||
python main.py
|
||||
# Open your browser and navigate to: http://localhost:8000
|
||||
```
|
||||
|
||||
**CLI Mode:**
|
||||
```bash
|
||||
python main.py cli
|
||||
# Interactive CLI for processing CSV files
|
||||
```
|
||||
|
||||
### Using the Web UI
|
||||
|
||||
1. **Open your browser** to `http://localhost:8000`
|
||||
2. **Upload your CSV file** by dragging and dropping or clicking to browse
|
||||
3. **Select a project** from the dropdown
|
||||
4. **Click "Report Time"** to submit
|
||||
5. View the **results summary** with total hours and days reported
|
||||
|
||||
The web interface features:
|
||||
- 🎯 Drag-and-drop file upload
|
||||
- 📋 Real-time project list
|
||||
- 💫 Smooth animations and transitions
|
||||
- 📱 Fully responsive design
|
||||
- 🔔 Toast notifications for feedback
|
||||
- ✅ Connection status indicator
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /
|
||||
Serves the modern web UI interface.
|
||||
|
||||
### GET /api
|
||||
Returns API information and available endpoints.
|
||||
|
||||
### GET /health
|
||||
Health check endpoint - verifies API key configuration.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"api_key_configured": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET /projects
|
||||
Lists all active projects for the configured user.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"Project Name": {
|
||||
"id": 123,
|
||||
"activity": {"id": 456}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /report-time
|
||||
Upload a CSV file and report time to a specific project.
|
||||
|
||||
**Parameters:**
|
||||
- `project_name` (query): Name of the project
|
||||
- `file` (form): CSV file with time entries
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Time reported successfully.",
|
||||
"reported_days": 5,
|
||||
"total_hours": 40.0,
|
||||
"project_name": "Project Name",
|
||||
"user_name": "Your Name"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /docs
|
||||
Interactive API documentation (Swagger UI).
|
||||
|
||||
## CSV Format
|
||||
|
||||
The application **automatically detects** the CSV delimiter (comma or semicolon) and column structure.
|
||||
|
||||
### Supported Formats
|
||||
|
||||
**Format 1: Comma-separated with headers**
|
||||
```csv
|
||||
reg_period,xreg_period,T,Time code,Time code (T),Vouch.date,Project Activity,Project Activity (T),Work package,Work package (T),Hours,Workflow status,Text
|
||||
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
|
||||
202544,,A,0,Standard Time,10/28/2025,,,,,4.00,N,
|
||||
```
|
||||
|
||||
**Format 2: Semicolon-separated**
|
||||
```csv
|
||||
...;...;...;...;...;01/15/2024;...;...;...;...;8.5;...
|
||||
...;...;...;...;...;01/16/2024;...;...;...;...;7,5;...
|
||||
```
|
||||
|
||||
### Column Detection
|
||||
|
||||
- **Date column**: Automatically detected by searching for columns containing "date" or "vouch" in the header
|
||||
- **Hours column**: Automatically detected by searching for columns containing "hour" in the header
|
||||
- **Date formats**: Supports both `MM/DD/YYYY` and `YYYY-MM-DD`
|
||||
- **Hours format**: Supports decimal hours with comma (`,`) or dot (`.`) as separator
|
||||
- **Whitespace**: Automatically trimmed from values
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
zynktime/
|
||||
├── static/
|
||||
│ ├── index.html # Modern web UI
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Responsive styling
|
||||
│ └── js/
|
||||
│ └── app.js # Frontend logic
|
||||
├── main.py # FastAPI application entry point
|
||||
├── cli.py # CLI interface
|
||||
├── config.py # Configuration management
|
||||
├── models.py # Pydantic models
|
||||
├── api_client.py # Kleer API client
|
||||
├── services.py # Business logic
|
||||
├── test_main.py # Unit tests
|
||||
├── requirements.txt # Python dependencies
|
||||
├── AGENTS.md # Guidelines for AI agents
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v
|
||||
|
||||
# Run specific test
|
||||
pytest test_main.py::TestServices::test_validate_date_valid -v
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
The codebase follows:
|
||||
- **Type hints**: Full typing support
|
||||
- **Error handling**: Specific exceptions with proper logging
|
||||
- **Separation of concerns**: Modular architecture
|
||||
- **Documentation**: Comprehensive docstrings
|
||||
- **Testing**: Unit tests for core functionality
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `KLEER_API_KEY` | Yes | - | API key for Kleer API |
|
||||
| `KLEER_USERNAME` | No | Christopher Juhlin | Username for API requests |
|
||||
| `KLEER_COMPANY_ID` | No | 1336 | Company ID for API requests |
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production, you can customize the docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zynktime:
|
||||
image: zynktime:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "80:8000" # Expose on port 80
|
||||
environment:
|
||||
- KLEER_API_KEY=${KLEER_API_KEY}
|
||||
- KLEER_USERNAME=${KLEER_USERNAME}
|
||||
- KLEER_COMPANY_ID=${KLEER_COMPANY_ID}
|
||||
env_file:
|
||||
- .env
|
||||
```
|
||||
|
||||
### Behind a Reverse Proxy
|
||||
|
||||
If deploying behind nginx or traefik:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
zynktime:
|
||||
image: zynktime:latest
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.zynktime.rule=Host(`time.example.com`)"
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
The Docker container includes health checks:
|
||||
- **Endpoint**: `/health`
|
||||
- **Interval**: 30 seconds
|
||||
- **Timeout**: 10 seconds
|
||||
- **Retries**: 3
|
||||
|
||||
Monitor with:
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker inspect zynktime | grep Health
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is for internal use with the Kleer API.
|
||||
Binary file not shown.
+206
@@ -0,0 +1,206 @@
|
||||
# ZynkTime Web UI Features
|
||||
|
||||
## 🎨 Modern Design
|
||||
|
||||
The ZynkTime web interface features a beautiful, modern design with:
|
||||
|
||||
### Visual Features
|
||||
- **Gradient Background**: Eye-catching purple gradient background
|
||||
- **Glassmorphism Cards**: Clean white cards with subtle shadows
|
||||
- **Smooth Animations**: Fade-in, slide-in, and hover animations
|
||||
- **Responsive Layout**: Works perfectly on desktop, tablet, and mobile
|
||||
- **Professional Typography**: Inter font family for clean readability
|
||||
|
||||
### Color Scheme
|
||||
- **Primary**: Indigo (#6366f1) - Main actions and accents
|
||||
- **Secondary**: Purple (#8b5cf6) - Gradient accents
|
||||
- **Success**: Green (#10b981) - Success states
|
||||
- **Danger**: Red (#ef4444) - Error states
|
||||
- **Warning**: Amber (#f59e0b) - Warning states
|
||||
|
||||
## 📋 Main Features
|
||||
|
||||
### 1. Header Section
|
||||
- **Logo & Branding**: ZynkTime logo with clock icon
|
||||
- **Status Badge**: Real-time connection status
|
||||
- 🟢 Green dot = Connected and healthy
|
||||
- 🔴 Red dot = API key not configured
|
||||
- ⚫ Gray dot = Checking connection
|
||||
|
||||
### 2. File Upload Section
|
||||
**Drag & Drop Zone**
|
||||
- Large, inviting upload area
|
||||
- Upload icon with clear instructions
|
||||
- "Drop your CSV file here or browse files"
|
||||
- Supported formats indicator
|
||||
- Hover effect with color change
|
||||
- Drag-over visual feedback
|
||||
|
||||
**File Preview**
|
||||
- Shows selected filename
|
||||
- Displays file size
|
||||
- Remove button to clear selection
|
||||
- Smooth slide-in animation
|
||||
|
||||
### 3. Project Selection
|
||||
- Dropdown with all available projects
|
||||
- Clean select styling with custom arrow
|
||||
- Disabled until projects load
|
||||
- Updates dynamically when refreshed
|
||||
|
||||
### 4. Submit Button
|
||||
- Full-width primary button
|
||||
- Gradient background (indigo to purple)
|
||||
- Right arrow icon
|
||||
- Disabled state when file or project not selected
|
||||
- Hover lift effect
|
||||
- Loading state during submission
|
||||
|
||||
### 5. Results Display
|
||||
**Success Card**
|
||||
- Animated slide-in appearance
|
||||
- Checkmark icon
|
||||
- Success message
|
||||
- 4-column stats grid:
|
||||
- Days Reported
|
||||
- Total Hours
|
||||
- Project Name
|
||||
- User Name
|
||||
- Large, bold numbers for quick scanning
|
||||
|
||||
### 6. Available Projects Section
|
||||
- Lists all active projects
|
||||
- Each project shows:
|
||||
- Project name
|
||||
- Project ID
|
||||
- Activity ID
|
||||
- Refresh button with spin animation
|
||||
- Hover effects on project items
|
||||
|
||||
## 🔔 Toast Notifications
|
||||
|
||||
Beautiful toast notifications appear in top-right corner:
|
||||
- **Success**: Green border, checkmark icon
|
||||
- **Error**: Red border, X icon
|
||||
- **Warning**: Amber border, triangle icon
|
||||
- **Info**: Blue border, info icon
|
||||
|
||||
Features:
|
||||
- Slide-in animation from right
|
||||
- Auto-dismiss after 3 seconds
|
||||
- Slide-out animation on dismiss
|
||||
- Multiple toasts stack vertically
|
||||
|
||||
## ⏳ Loading States
|
||||
|
||||
### Full-Screen Overlay
|
||||
- Semi-transparent dark background
|
||||
- Blur effect (backdrop-filter)
|
||||
- Spinning loader animation
|
||||
- "Processing your request..." message
|
||||
- Prevents interaction during loading
|
||||
|
||||
### Inline Loading
|
||||
- "Loading projects..." text
|
||||
- Shown while fetching data
|
||||
- Replaced with content when ready
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
### Desktop (> 768px)
|
||||
- Max width: 1200px
|
||||
- Centered layout
|
||||
- 2rem padding
|
||||
- Multi-column grid layouts
|
||||
|
||||
### Mobile (< 768px)
|
||||
- Full-width layout
|
||||
- 1rem padding
|
||||
- Header stacks vertically
|
||||
- Toast notifications full-width
|
||||
- Single-column layouts
|
||||
- Optimized touch targets
|
||||
|
||||
## 🎭 Interactions & Animations
|
||||
|
||||
### Hover Effects
|
||||
- Cards lift slightly on hover
|
||||
- Buttons change shade
|
||||
- Links show underline
|
||||
- Icons change color
|
||||
- Smooth 0.2s transitions
|
||||
|
||||
### Click Effects
|
||||
- Buttons press down (active state)
|
||||
- Ripple-like visual feedback
|
||||
- State changes are immediate
|
||||
|
||||
### Loading Animations
|
||||
- Spinner rotation (0.8s linear infinite)
|
||||
- Pulse animation for status dots
|
||||
- Smooth fade transitions
|
||||
|
||||
## 🎯 User Experience
|
||||
|
||||
### Visual Feedback
|
||||
- Every action has a response
|
||||
- Status indicators update in real-time
|
||||
- Form validation with button states
|
||||
- Clear error messages in toasts
|
||||
- Success celebrations with stats
|
||||
|
||||
### Accessibility
|
||||
- Semantic HTML structure
|
||||
- Proper heading hierarchy
|
||||
- ARIA labels where needed
|
||||
- Keyboard navigation support
|
||||
- Focus states on interactive elements
|
||||
- High contrast text
|
||||
- Readable font sizes (min 14px)
|
||||
|
||||
### Progressive Enhancement
|
||||
- Works without JavaScript for basic tasks
|
||||
- Graceful degradation
|
||||
- Error handling with user-friendly messages
|
||||
- Offline detection
|
||||
|
||||
## 🚀 Performance
|
||||
|
||||
### Optimizations
|
||||
- Minimal DOM manipulation
|
||||
- CSS animations (GPU accelerated)
|
||||
- Debounced event handlers
|
||||
- Efficient event delegation
|
||||
- Lazy loading where possible
|
||||
|
||||
### Fast Loading
|
||||
- No external dependencies (except Google Fonts)
|
||||
- Inline critical CSS option
|
||||
- Minimal JavaScript bundle
|
||||
- Optimized SVG icons
|
||||
|
||||
## 📊 Data Display
|
||||
|
||||
### Stats Cards
|
||||
- Large, bold numbers
|
||||
- Descriptive labels
|
||||
- Grid layout for easy scanning
|
||||
- Highlighted with primary color
|
||||
- Rounded corners for modern look
|
||||
|
||||
### Project List
|
||||
- Clean, scannable format
|
||||
- Metadata clearly displayed
|
||||
- Subtle hover states
|
||||
- Easy to identify projects
|
||||
- Refresh option always visible
|
||||
|
||||
## 💅 Polish & Details
|
||||
|
||||
- Rounded corners throughout (0.5rem - 1rem)
|
||||
- Consistent spacing (multiples of 0.5rem)
|
||||
- Box shadows for depth
|
||||
- Gradient accents for visual interest
|
||||
- Smooth color transitions
|
||||
- Professional iconography
|
||||
- Thoughtful micro-interactions
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
class KleerAPIError(Exception):
|
||||
"""Custom exception for Kleer API errors."""
|
||||
pass
|
||||
|
||||
|
||||
class KleerAPI:
|
||||
"""Client for interacting with the Kleer API."""
|
||||
|
||||
def __init__(self, api_url: Optional[str] = None, company_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize Kleer API client.
|
||||
|
||||
Args:
|
||||
api_url: Base URL for the Kleer API
|
||||
company_id: Company ID for API requests
|
||||
|
||||
Raises:
|
||||
ValueError: If API key is not configured
|
||||
"""
|
||||
self.api_url = api_url or Config.API_URL
|
||||
self.company_id = company_id or Config.get_company_id()
|
||||
self.api_key = Config.get_api_key()
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("KLEER_API_KEY environment variable is not set")
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
return {
|
||||
'X-token': self.api_key,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def api_request(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: str = 'GET',
|
||||
data: Optional[Any] = None
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Make a request to the Kleer API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path
|
||||
method: HTTP method (GET, POST, PUT)
|
||||
data: Request payload for POST/PUT requests
|
||||
|
||||
Returns:
|
||||
JSON response from the API
|
||||
|
||||
Raises:
|
||||
HTTPException: If the request fails
|
||||
"""
|
||||
url = f"{self.api_url}/{endpoint}"
|
||||
headers = self._get_headers()
|
||||
|
||||
response = None
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = requests.get(url, headers=headers)
|
||||
elif method == 'POST':
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
elif method == 'PUT':
|
||||
response = requests.put(url, headers=headers, json=data)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.HTTPError as e:
|
||||
logging.error(f"HTTP error during API request: {e}")
|
||||
raise HTTPException(
|
||||
status_code=response.status_code if response else 502,
|
||||
detail=f"API request failed: {str(e)}"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logging.error(f"API request failed: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"API request failed: {str(e)}")
|
||||
|
||||
def get_events(self, user_id: int, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve events for a user within a date range.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
start_date: Start date in YYYY-MM-DD format
|
||||
end_date: End date in YYYY-MM-DD format
|
||||
|
||||
Returns:
|
||||
Event data from API
|
||||
"""
|
||||
endpoint = (
|
||||
f"company/{self.company_id}/event"
|
||||
f"?userId={user_id}&startDate={start_date}&endDate={end_date}"
|
||||
)
|
||||
return self.api_request(endpoint, method='GET') or {}
|
||||
|
||||
def approve_events(self, user_id: int, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Approve events for a user within a date range.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
start_date: Start date in YYYY-MM-DD format
|
||||
end_date: End date in YYYY-MM-DD format
|
||||
|
||||
Returns:
|
||||
Approval response from API
|
||||
"""
|
||||
endpoint = f"company/{self.company_id}/event/approve"
|
||||
payload = {
|
||||
"user-id": {"id": user_id},
|
||||
"start-date": start_date,
|
||||
"end-date": end_date
|
||||
}
|
||||
return self.api_request(endpoint, method='POST', data=payload) or {}
|
||||
|
||||
def put_event(self, event: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Submit a time event to the Kleer API.
|
||||
|
||||
Args:
|
||||
event: Time event data
|
||||
|
||||
Returns:
|
||||
API response
|
||||
|
||||
Raises:
|
||||
HTTPException: If the request fails
|
||||
"""
|
||||
url = f"company/{self.company_id}/event"
|
||||
try:
|
||||
response = self.api_request(url, method='PUT', data=event)
|
||||
logging.info(f"Successfully reported event for date: {event.get('date')}")
|
||||
return response
|
||||
except HTTPException as e:
|
||||
logging.error(
|
||||
f"Failed to put event for date {event.get('date')}. "
|
||||
f"Status: {e.status_code}, Detail: {e.detail}"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Unexpected error while putting event for date {event.get('date')}: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"An unexpected error occurred: {str(e)}"
|
||||
)
|
||||
|
||||
def get_projects(self, user_id: int) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve active projects for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID to filter projects
|
||||
|
||||
Returns:
|
||||
Dictionary mapping project names to project info
|
||||
"""
|
||||
url = f"company/{self.company_id}/client-project?filter=active"
|
||||
data = self.api_request(url, method='GET')
|
||||
|
||||
if not data:
|
||||
logging.warning("No data received from projects API.")
|
||||
return {}
|
||||
|
||||
projects_data = data.get('client-project-readables') if isinstance(data, dict) else data
|
||||
if not isinstance(projects_data, list):
|
||||
logging.error("Unexpected projects data format.")
|
||||
return {}
|
||||
|
||||
projects = {}
|
||||
for project in projects_data:
|
||||
if not isinstance(project, dict):
|
||||
logging.warning(f"Unexpected project data format: {project}")
|
||||
continue
|
||||
|
||||
project_id = project.get('id')
|
||||
project_name = project.get('name')
|
||||
activity_id = None
|
||||
|
||||
users = project.get('users', [])
|
||||
for user in users:
|
||||
if user.get('user', {}).get('id') != user_id:
|
||||
continue
|
||||
activities = user.get('activities', [])
|
||||
for activity in activities:
|
||||
activity_id = activity.get('activity', {})
|
||||
if activity_id:
|
||||
break
|
||||
if activity_id:
|
||||
break
|
||||
|
||||
if not project_id or not project_name:
|
||||
logging.info(f"Skipping project with missing ID or name: {project}")
|
||||
continue
|
||||
|
||||
if activity_id:
|
||||
projects[project_name] = {"id": project_id, "activity": activity_id}
|
||||
else:
|
||||
logging.info(
|
||||
f"Project '{project_name}' (ID: {project_id}) has no associated activity."
|
||||
)
|
||||
|
||||
logging.info(f"Found {len(projects)} valid projects.")
|
||||
return projects
|
||||
|
||||
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve user information from the Kleer API.
|
||||
|
||||
Returns:
|
||||
User information dictionary or None if not found
|
||||
"""
|
||||
url = f"company/{self.company_id}/user"
|
||||
data = self.api_request(url, method='GET')
|
||||
|
||||
if data and isinstance(data, dict):
|
||||
logging.info("User info retrieved from Kleer API.")
|
||||
return data
|
||||
|
||||
logging.warning("No user information found or data format is incorrect.")
|
||||
return None
|
||||
@@ -0,0 +1,147 @@
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from api_client import KleerAPI
|
||||
from config import Config
|
||||
from services import (
|
||||
create_time_event,
|
||||
extract_work_time,
|
||||
get_user_by_name,
|
||||
parse_csv,
|
||||
parse_xlsx,
|
||||
)
|
||||
|
||||
|
||||
def run_cli():
|
||||
"""Run the CLI interface for time reporting."""
|
||||
# Validate configuration
|
||||
try:
|
||||
Config.validate_config()
|
||||
except ValueError as e:
|
||||
logging.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
# Find CSV and XLSX files
|
||||
current_directory = pathlib.Path.cwd()
|
||||
data_files = list(current_directory.glob('*.csv')) + list(current_directory.glob('*.xlsx'))
|
||||
|
||||
if not data_files:
|
||||
logging.error("No CSV or XLSX files found in the current directory.")
|
||||
sys.exit(1)
|
||||
|
||||
logging.info(f"Data files found: {', '.join(f.name for f in data_files)}")
|
||||
|
||||
# Initialize API client
|
||||
kleer_api = KleerAPI()
|
||||
|
||||
# Process each CSV file
|
||||
for data_file in data_files:
|
||||
logging.info(f"Processing file: {data_file.name}")
|
||||
|
||||
try:
|
||||
# Get user information
|
||||
users_data = kleer_api.get_user_info()
|
||||
username = Config.get_username()
|
||||
|
||||
try:
|
||||
user_id = get_user_by_name(users_data, username)
|
||||
except ValueError as e:
|
||||
logging.error(str(e))
|
||||
continue
|
||||
|
||||
logging.info(f"Using user ID: {user_id} for events.")
|
||||
|
||||
# Get projects
|
||||
projects = kleer_api.get_projects(user_id=user_id)
|
||||
if not projects:
|
||||
logging.warning("No projects found for user.")
|
||||
continue
|
||||
|
||||
logging.info("Available projects from Kleer API:")
|
||||
for name, value in projects.items():
|
||||
logging.info(f"- {name}: {value}")
|
||||
|
||||
# Parse CSV file
|
||||
if data_file.suffix.lower() == '.xlsx':
|
||||
content = data_file.read_bytes()
|
||||
rows = parse_xlsx(content)
|
||||
else:
|
||||
with data_file.open(mode='r', newline='', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
rows = parse_csv(content)
|
||||
|
||||
work_time = extract_work_time(rows)
|
||||
|
||||
if not work_time:
|
||||
logging.info(f"No valid work time found in {data_file.name}.")
|
||||
continue
|
||||
|
||||
# Let user select project
|
||||
project_info = _select_project(projects)
|
||||
if not project_info:
|
||||
logging.warning("No project selected, skipping file.")
|
||||
continue
|
||||
|
||||
# Display work time summary
|
||||
logging.info("Total work time by date:")
|
||||
total_work_time = 0.0
|
||||
|
||||
for date, total_time in sorted(work_time.items()):
|
||||
logging.info(f"{date}: {total_time:.2f} hours")
|
||||
total_work_time += total_time
|
||||
|
||||
# Create and submit event
|
||||
event = create_time_event(
|
||||
user_id=user_id,
|
||||
project_id=project_info["id"],
|
||||
activity=project_info["activity"],
|
||||
date=date,
|
||||
hours=total_time
|
||||
)
|
||||
kleer_api.put_event(event)
|
||||
|
||||
logging.info(f"Total work time for all dates: {total_work_time:.2f} hours")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing {csv_file.name}: {e}")
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
|
||||
def _select_project(projects: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Let user select a project from available projects.
|
||||
|
||||
Args:
|
||||
projects: Dictionary of available projects
|
||||
|
||||
Returns:
|
||||
Selected project info or None if cancelled
|
||||
"""
|
||||
while True:
|
||||
print("\nAvailable projects:")
|
||||
project_list = list(projects.items())
|
||||
|
||||
for idx, (name, value) in enumerate(project_list, start=1):
|
||||
print(f"{idx}. {name} (ID: {value['id']}, Activity: {value['activity']})")
|
||||
|
||||
project_choice = input("\nSelect a project by number (or 'q' to quit): ")
|
||||
|
||||
if project_choice.lower() == 'q':
|
||||
return None
|
||||
|
||||
try:
|
||||
project_index = int(project_choice) - 1
|
||||
if 0 <= project_index < len(project_list):
|
||||
_, project_info = project_list[project_index]
|
||||
return project_info
|
||||
else:
|
||||
logging.error("Invalid project selection. Please try again.")
|
||||
except ValueError:
|
||||
logging.error("Invalid input. Please enter a number or 'q' to quit.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_cli()
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration management."""
|
||||
|
||||
# API Configuration
|
||||
API_URL: str = "https://api.kleer.se/v1"
|
||||
DEFAULT_COMPANY_ID: str = "1336"
|
||||
API_KEY : str = "rjAXm8PM70kqTUt" # To be set via environment variable
|
||||
|
||||
# Date Formats
|
||||
CSV_DATE_FORMAT: str = '%m/%d/%Y'
|
||||
API_DATE_FORMAT: str = '%Y-%m-%d'
|
||||
XLSX_SHEET_NAME: str = "AGRESSO"
|
||||
|
||||
# CSV Column Indices
|
||||
DATE_COL_INDEX: int = 5
|
||||
HOURS_COL_INDEX: int = 10
|
||||
|
||||
# Business Rules
|
||||
MIN_HOURS_THRESHOLD: float = 0.2
|
||||
|
||||
# Server Configuration
|
||||
SERVER_HOST: str = "0.0.0.0"
|
||||
SERVER_PORT: int = 8000
|
||||
|
||||
# Environment Variables
|
||||
@staticmethod
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get Kleer API key from environment."""
|
||||
return os.getenv('KLEER_API_KEY',Config.API_KEY)
|
||||
|
||||
@staticmethod
|
||||
def get_username() -> str:
|
||||
"""Get username from environment with fallback."""
|
||||
return os.getenv('KLEER_USERNAME', 'Christopher Juhlin')
|
||||
|
||||
@staticmethod
|
||||
def get_company_id() -> str:
|
||||
"""Get company ID from environment with fallback."""
|
||||
return os.getenv('KLEER_COMPANY_ID', Config.DEFAULT_COMPANY_ID)
|
||||
|
||||
@staticmethod
|
||||
def validate_config() -> None:
|
||||
"""Validate required configuration is present."""
|
||||
if not Config.get_api_key():
|
||||
raise ValueError("KLEER_API_KEY environment variable is not set")
|
||||
@@ -0,0 +1,42 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zynktime:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: zynktime
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- KLEER_API_KEY=${KLEER_API_KEY}
|
||||
- KLEER_USERNAME=${KLEER_USERNAME:-Christopher Juhlin}
|
||||
- KLEER_COMPANY_ID=${KLEER_COMPANY_ID:-1336}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Mount for development (comment out for production)
|
||||
- ./static:/app/static:ro
|
||||
# Volume for logs (optional)
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- zynktime-network
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
labels:
|
||||
- "com.zynktime.description=ZynkTime - Time Reporting Application"
|
||||
- "com.zynktime.version=1.0.0"
|
||||
|
||||
networks:
|
||||
zynktime-network:
|
||||
driver: bridge
|
||||
name: zynktime-network
|
||||
|
||||
volumes:
|
||||
logs:
|
||||
driver: local
|
||||
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
ZynkTime - Time reporting application for Kleer API.
|
||||
|
||||
This module provides FastAPI endpoints for time reporting and a CLI interface.
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import fastapi
|
||||
import uvicorn
|
||||
from fastapi import File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api_client import KleerAPI
|
||||
from cli import run_cli
|
||||
from config import Config
|
||||
from models import TimeReportResponse
|
||||
from services import (
|
||||
create_time_event,
|
||||
extract_work_time,
|
||||
get_user_by_name,
|
||||
parse_csv,
|
||||
parse_xlsx,
|
||||
validate_date,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = fastapi.FastAPI(
|
||||
title="ZynkTime API",
|
||||
description="Time reporting API for Kleer",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
"""Serve the web UI."""
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/api")
|
||||
def api_info():
|
||||
"""API information endpoint."""
|
||||
return {
|
||||
"name": "ZynkTime API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"projects": "/projects",
|
||||
"report_time": "/report-time"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
try:
|
||||
Config.validate_config()
|
||||
return {"status": "healthy", "api_key_configured": True}
|
||||
except ValueError:
|
||||
return {"status": "unhealthy", "api_key_configured": False}
|
||||
|
||||
|
||||
@app.get("/projects")
|
||||
def list_projects():
|
||||
"""
|
||||
List all active projects for the configured user.
|
||||
|
||||
Returns:
|
||||
Dictionary of project names to project information
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not found or API request fails
|
||||
"""
|
||||
try:
|
||||
kleer_api = KleerAPI()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
users_data = kleer_api.get_user_info()
|
||||
username = Config.get_username()
|
||||
|
||||
try:
|
||||
user_id = get_user_by_name(users_data, username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
projects = kleer_api.get_projects(user_id=user_id)
|
||||
return projects
|
||||
|
||||
|
||||
@app.post("/report-time", response_model=TimeReportResponse)
|
||||
async def report_time_from_csv(
|
||||
project_name: str,
|
||||
file: UploadFile = File(...)
|
||||
):
|
||||
"""
|
||||
Upload a CSV file and report time to a specified Kleer project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project to report time to
|
||||
file: CSV file containing time entries
|
||||
|
||||
Returns:
|
||||
TimeReportResponse with summary of reported time
|
||||
|
||||
Raises:
|
||||
HTTPException: If validation fails or API request fails
|
||||
"""
|
||||
# Initialize API client
|
||||
try:
|
||||
kleer_api = KleerAPI()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Get user ID
|
||||
users_data = kleer_api.get_user_info()
|
||||
username = Config.get_username()
|
||||
|
||||
try:
|
||||
user_id = get_user_by_name(users_data, username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# Get project info
|
||||
projects = kleer_api.get_projects(user_id=user_id)
|
||||
project_info = projects.get(project_name)
|
||||
|
||||
if not project_info:
|
||||
available_projects = ", ".join(projects.keys())
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Project '{project_name}' not found. Available projects: {available_projects}"
|
||||
)
|
||||
|
||||
# Process file content
|
||||
content = await file.read()
|
||||
filename = (file.filename or "").lower()
|
||||
content_type = (file.content_type or "").lower()
|
||||
|
||||
try:
|
||||
if filename.endswith(".xlsx") or "spreadsheetml" in content_type:
|
||||
rows = parse_xlsx(content)
|
||||
else:
|
||||
rows = parse_csv(content.decode('utf-8-sig'))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
||||
|
||||
work_time = extract_work_time(rows)
|
||||
|
||||
if not work_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No valid work time found in the uploaded CSV."
|
||||
)
|
||||
|
||||
# Submit events
|
||||
total_work_time = 0.0
|
||||
failed_dates = []
|
||||
|
||||
for date, total_time in work_time.items():
|
||||
try:
|
||||
event = create_time_event(
|
||||
user_id=user_id,
|
||||
project_id=project_info["id"],
|
||||
activity=project_info["activity"],
|
||||
date=date,
|
||||
hours=total_time
|
||||
)
|
||||
kleer_api.put_event(event)
|
||||
total_work_time += total_time
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to report time for {date}: {e}")
|
||||
failed_dates.append(date)
|
||||
|
||||
# Check if any submissions failed
|
||||
if failed_dates:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to report time for dates: {', '.join(failed_dates)}"
|
||||
)
|
||||
|
||||
return TimeReportResponse(
|
||||
message="Time reported successfully.",
|
||||
reported_days=len(work_time),
|
||||
total_hours=total_work_time,
|
||||
project_name=project_name,
|
||||
user_name=username
|
||||
)
|
||||
|
||||
@app.get("/salary")
|
||||
def get_salary_info():
|
||||
"""
|
||||
Placeholder endpoint for salary information.
|
||||
|
||||
Returns:
|
||||
Static message indicating feature is under development.
|
||||
"""
|
||||
# Initialize API client
|
||||
try:
|
||||
kleer_api = KleerAPI()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Get user ID
|
||||
users_data = kleer_api.get_user_info()
|
||||
username = Config.get_username()
|
||||
|
||||
try:
|
||||
user_id = get_user_by_name(users_data, username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
|
||||
return {"message": "Salary information feature is under development."}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the application."""
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'cli':
|
||||
# Run CLI mode
|
||||
run_cli()
|
||||
else:
|
||||
# Run API server
|
||||
print("Starting ZynkTime FastAPI server...")
|
||||
print("To run CLI mode, use: python main.py cli")
|
||||
print(f"Server will be available at http://{Config.SERVER_HOST}:{Config.SERVER_PORT}")
|
||||
print("API documentation available at /docs")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=Config.SERVER_HOST,
|
||||
port=Config.SERVER_PORT
|
||||
)
|
||||
|
||||
|
||||
def _get_user_id(kleer_api: KleerAPI) -> int:
|
||||
"""Resolve configured username to user ID using API data."""
|
||||
users_data = kleer_api.get_user_info()
|
||||
username = Config.get_username()
|
||||
|
||||
try:
|
||||
return get_user_by_name(users_data, username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
def _validate_date_range(start_date: str, end_date: str) -> None:
|
||||
"""Validate date strings and ordering."""
|
||||
if not (validate_date(start_date) and validate_date(end_date)):
|
||||
raise HTTPException(status_code=400, detail="Dates must be in YYYY-MM-DD format")
|
||||
|
||||
if datetime.fromisoformat(start_date) > datetime.fromisoformat(end_date):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Start date must be on or before end date"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/events")
|
||||
def list_events(start_date: str, end_date: str):
|
||||
"""
|
||||
List events for the configured user between two dates (inclusive).
|
||||
"""
|
||||
_validate_date_range(start_date, end_date)
|
||||
|
||||
try:
|
||||
kleer_api = KleerAPI()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
user_id = _get_user_id(kleer_api)
|
||||
|
||||
events = kleer_api.get_events(user_id=user_id, start_date=start_date, end_date=end_date)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"events": events.get("event-readables", events)
|
||||
}
|
||||
|
||||
|
||||
@app.post("/approve-events")
|
||||
def approve_events(start_date: str, end_date: str):
|
||||
"""
|
||||
Approve events for the configured user in a date range.
|
||||
"""
|
||||
_validate_date_range(start_date, end_date)
|
||||
|
||||
try:
|
||||
kleer_api = KleerAPI()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
user_id = _get_user_id(kleer_api)
|
||||
|
||||
approval_response = kleer_api.approve_events(
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Events approved",
|
||||
"user_id": user_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"approval": approval_response
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectInfo(BaseModel):
|
||||
"""Project information model."""
|
||||
id: int
|
||||
activity: Dict[str, Any]
|
||||
|
||||
|
||||
class TimeReportResponse(BaseModel):
|
||||
"""Response model for time reporting endpoint."""
|
||||
message: str
|
||||
reported_days: int
|
||||
total_hours: float
|
||||
project_name: str
|
||||
user_name: str
|
||||
|
||||
|
||||
class TimeEvent(BaseModel):
|
||||
"""Time event model for Kleer API."""
|
||||
foreign_id: str = Field(default="", alias="foreign-id")
|
||||
user: Dict[str, int]
|
||||
client_project: int = Field(alias="client-project")
|
||||
activity: Dict[str, Any]
|
||||
date: str
|
||||
hours: float
|
||||
comment: str = ""
|
||||
internal_comment: str = Field(default="", alias="internal-comment")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""User information model."""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
"""Project response model."""
|
||||
id: int
|
||||
name: str
|
||||
activity_id: Dict[str, Any]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Core dependencies
|
||||
fastapi==0.120.3
|
||||
uvicorn[standard]==0.38.0
|
||||
pydantic==2.12.3
|
||||
requests==2.32.5
|
||||
openpyxl==3.1.5
|
||||
|
||||
# Development dependencies
|
||||
pytest==8.4.2
|
||||
pytest-asyncio==1.2.0
|
||||
pytest-cov==7.0.0
|
||||
httpx==0.28.1 # For testing FastAPI endpoints
|
||||
openpyxl==3.1.5
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Dict, List
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def validate_date(date_text: str) -> bool:
|
||||
"""
|
||||
Validate if date string is in ISO format (YYYY-MM-DD).
|
||||
|
||||
Args:
|
||||
date_text: Date string to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
try:
|
||||
datetime.fromisoformat(date_text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_csv(file_content: str) -> List[List[str]]:
|
||||
"""
|
||||
Parse CSV content into rows with automatic delimiter detection.
|
||||
|
||||
Args:
|
||||
file_content: CSV file content as string
|
||||
|
||||
Returns:
|
||||
List of rows, where each row is a list of values
|
||||
"""
|
||||
# Use csv.Sniffer to auto-detect delimiter
|
||||
try:
|
||||
sample = file_content[:1024] # Use first 1KB for detection
|
||||
sniffer = csv.Sniffer()
|
||||
delimiter = sniffer.sniff(sample).delimiter
|
||||
logging.info(f"Detected CSV delimiter: '{delimiter}'")
|
||||
except Exception as e:
|
||||
# Fallback to comma if detection fails
|
||||
logging.warning(f"Could not detect delimiter, using comma: {e}")
|
||||
delimiter = ','
|
||||
|
||||
reader = csv.reader(io.StringIO(file_content), delimiter=delimiter)
|
||||
return list(reader)
|
||||
|
||||
|
||||
def parse_xlsx(file_bytes: bytes) -> List[List[str]]:
|
||||
"""
|
||||
Parse XLSX content into rows from the configured worksheet.
|
||||
|
||||
Args:
|
||||
file_bytes: XLSX file content as bytes
|
||||
|
||||
Returns:
|
||||
List of rows, where each row is a list of values
|
||||
|
||||
Raises:
|
||||
ValueError: If the worksheet is missing or the file cannot be read
|
||||
"""
|
||||
try:
|
||||
workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
|
||||
except Exception as exc: # pragma: no cover - relies on openpyxl internals
|
||||
raise ValueError(f"Failed to read XLSX file: {exc}") from exc
|
||||
|
||||
if Config.XLSX_SHEET_NAME not in workbook.sheetnames:
|
||||
raise ValueError(f"Worksheet '{Config.XLSX_SHEET_NAME}' not found in XLSX file")
|
||||
|
||||
sheet = workbook[Config.XLSX_SHEET_NAME]
|
||||
rows: List[List[str]] = []
|
||||
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
cleaned_row = []
|
||||
for cell in row:
|
||||
if cell is None:
|
||||
cleaned_row.append("")
|
||||
elif isinstance(cell, datetime):
|
||||
cleaned_row.append(cell.date().isoformat())
|
||||
elif isinstance(cell, date):
|
||||
cleaned_row.append(cell.isoformat())
|
||||
else:
|
||||
cleaned_row.append(str(cell).strip())
|
||||
if any(value for value in cleaned_row):
|
||||
rows.append(cleaned_row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def extract_work_time(rows: List[List[str]]) -> Dict[str, float]:
|
||||
"""
|
||||
Extract and aggregate work time from CSV rows.
|
||||
|
||||
Args:
|
||||
rows: A list of lists representing the rows of a CSV file
|
||||
|
||||
Returns:
|
||||
A dictionary mapping dates (in 'YYYY-MM-DD' format) to total hours worked
|
||||
"""
|
||||
work_time: Dict[str, float] = {}
|
||||
|
||||
# Try to detect column indices from header
|
||||
date_col_idx = Config.DATE_COL_INDEX
|
||||
hours_col_idx = Config.HOURS_COL_INDEX
|
||||
|
||||
if rows and len(rows) > 0:
|
||||
header = [str(col).strip().lower() for col in rows[0]]
|
||||
# Look for date-related columns
|
||||
for idx, col in enumerate(header):
|
||||
if 'date' in col or 'vouch' in col:
|
||||
date_col_idx = idx
|
||||
logging.info(f"Detected date column at index {idx}: '{rows[0][idx]}'")
|
||||
break
|
||||
# Look for hours-related columns
|
||||
for idx, col in enumerate(header):
|
||||
if 'hour' in col or 'time' in col and 'code' not in col:
|
||||
hours_col_idx = idx
|
||||
logging.info(f"Detected hours column at index {idx}: '{rows[0][idx]}'")
|
||||
break
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
# Skip header row
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
# Skip short rows
|
||||
if len(row) <= max(date_col_idx, hours_col_idx):
|
||||
logging.debug(
|
||||
f"Skipping row {i+1} as it has fewer than "
|
||||
f"{max(date_col_idx, hours_col_idx)+1} columns."
|
||||
)
|
||||
continue
|
||||
|
||||
date_str = row[date_col_idx].strip() if date_col_idx < len(row) else ""
|
||||
hours_str = row[hours_col_idx].strip() if hours_col_idx < len(row) else ""
|
||||
|
||||
if not date_str or not hours_str:
|
||||
logging.debug(f"Skipping row {i+1} due to missing date or hours.")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Parse date, trying API format first, then CSV format
|
||||
try:
|
||||
parsed_date = datetime.strptime(date_str, Config.API_DATE_FORMAT)
|
||||
except ValueError:
|
||||
parsed_date = datetime.strptime(date_str, Config.CSV_DATE_FORMAT)
|
||||
|
||||
api_formatted_date = parsed_date.strftime(Config.API_DATE_FORMAT)
|
||||
|
||||
# Parse hours, allowing for comma as decimal separator and whitespace
|
||||
hours = float(hours_str.replace(',', '.').strip())
|
||||
|
||||
if hours > Config.MIN_HOURS_THRESHOLD:
|
||||
work_time[api_formatted_date] = work_time.get(api_formatted_date, 0.0) + hours
|
||||
else:
|
||||
logging.debug(
|
||||
f"Skipping row {i+1} with hours below threshold. Hours: {hours}"
|
||||
)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logging.debug(f"Skipping row {i+1} due to parsing error: {e}")
|
||||
|
||||
logging.info(f"Extracted work time for {len(work_time)} unique dates.")
|
||||
return work_time
|
||||
|
||||
|
||||
def create_time_event(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
activity: Dict,
|
||||
date: str,
|
||||
hours: float
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a time event dictionary for the Kleer API.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
project_id: Project ID
|
||||
activity: Activity dictionary
|
||||
date: Date in YYYY-MM-DD format
|
||||
hours: Number of hours
|
||||
|
||||
Returns:
|
||||
Time event dictionary
|
||||
"""
|
||||
return {
|
||||
"foreign-id": "",
|
||||
"user": {"id": user_id},
|
||||
"client-project": project_id,
|
||||
"activity": activity,
|
||||
"date": date,
|
||||
"hours": hours,
|
||||
"comment": "",
|
||||
"internal-comment": ""
|
||||
}
|
||||
|
||||
|
||||
def get_user_by_name(users_data: Dict, username: str) -> int:
|
||||
"""
|
||||
Get user ID by username from users data.
|
||||
|
||||
Args:
|
||||
users_data: User data from API
|
||||
username: Username to search for
|
||||
|
||||
Returns:
|
||||
User ID
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
if not users_data or "users" not in users_data:
|
||||
raise ValueError("Invalid user data received from API")
|
||||
|
||||
users = {
|
||||
user.get('name'): user.get('id')
|
||||
for user in users_data.get("users", [])
|
||||
if isinstance(user, dict)
|
||||
}
|
||||
|
||||
user_id = users.get(username)
|
||||
if not user_id:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
return user_id
|
||||
@@ -0,0 +1,750 @@
|
||||
:root {
|
||||
--primary: #0f766e;
|
||||
--primary-dark: #0b4f48;
|
||||
--primary-light: #16a394;
|
||||
--secondary: #f97316;
|
||||
--success: #16a34a;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #2563eb;
|
||||
|
||||
--bg-primary: #0b1220;
|
||||
--bg-secondary: #11192a;
|
||||
--bg-tertiary: #0f172a;
|
||||
--panel: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 25px 45px rgba(0, 0, 0, 0.45);
|
||||
--shadow-xl: 0 40px 70px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--radius-sm: 0.5rem;
|
||||
--radius: 0.75rem;
|
||||
--radius-md: 1rem;
|
||||
--radius-lg: 1.25rem;
|
||||
|
||||
--transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Space Grotesk', 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(22, 163, 148, 0.15), transparent 30%),
|
||||
radial-gradient(circle at 80% 0%, rgba(249, 115, 22, 0.12), transparent 30%),
|
||||
linear-gradient(135deg, #0b1220 0%, #0b182b 40%, #0c1c32 100%);
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
padding: 2.5rem 1.25rem 3rem;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(1200px circle at 15% 15%, rgba(22, 163, 148, 0.08), transparent 35%),
|
||||
radial-gradient(900px circle at 90% 10%, rgba(249, 115, 22, 0.06), transparent 30%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border);
|
||||
animation: slideDown 0.5s ease;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--secondary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-badge.healthy .status-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.unhealthy .status-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
animation: fadeIn 0.6s ease 0.2s both;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Upload Zone */
|
||||
.upload-zone {
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--primary-light);
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.upload-zone.drag-over {
|
||||
border-color: var(--primary-light);
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.12) 0%, rgba(249, 115, 22, 0.12) 100%);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--primary-light);
|
||||
margin-bottom: 1rem;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.upload-zone h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-zone p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.browse-link {
|
||||
color: var(--secondary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-formats {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* File Preview */
|
||||
.file-preview {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius);
|
||||
animation: slideIn 0.3s ease;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--primary-light);
|
||||
stroke-width: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid var(--text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-light);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-light);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 120%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-top: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.btn-icon-right {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Projects List */
|
||||
.projects-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Approvals */
|
||||
.approvals-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.event-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-status.approved {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.event-status.pending {
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.result-success {
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(16, 163, 148, 0.18) 0%, rgba(16, 163, 148, 0.1) 100%);
|
||||
border: 1px solid rgba(16, 163, 148, 0.35);
|
||||
border-radius: var(--radius);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.result-success h3 {
|
||||
color: var(--success);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 300px;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--danger);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid var(--info);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
color: white;
|
||||
margin-top: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZynkTime - Time Reporting</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
<h1>ZynkTime</h1>
|
||||
</div>
|
||||
<div class="status-badge" id="statusBadge">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Checking...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Upload Section -->
|
||||
<section class="card upload-section">
|
||||
<div class="card-header">
|
||||
<h2>Report Your Time</h2>
|
||||
<p>Upload your CSV file and select a project to report time</p>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- File Upload -->
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<input type="file" id="fileInput" accept=".csv,.xlsx" hidden>
|
||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
<h3>Drop your CSV or XLSX file here</h3>
|
||||
<p>or <span class="browse-link" id="browseLink">browse files</span></p>
|
||||
<span class="file-formats">Supports: CSV (comma or semicolon separated) and XLSX (AGRESSO sheet)</span>
|
||||
</div>
|
||||
|
||||
<div class="file-preview" id="filePreview" style="display: none;">
|
||||
<div class="file-info">
|
||||
<svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
<div class="file-details">
|
||||
<span class="file-name" id="fileName"></span>
|
||||
<span class="file-size" id="fileSize"></span>
|
||||
</div>
|
||||
<button class="btn-icon" id="removeFile" title="Remove file">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Selection -->
|
||||
<div class="form-group">
|
||||
<label for="projectSelect">Select Project</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="projectSelect" disabled>
|
||||
<option value="">Loading projects...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button class="btn btn-primary" id="submitBtn" disabled>
|
||||
<span class="btn-text">Report Time</span>
|
||||
<svg class="btn-icon-right" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Results Section -->
|
||||
<section class="card results-section" id="resultsSection" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h2>Results</h2>
|
||||
</div>
|
||||
<div class="card-body" id="resultsContent"></div>
|
||||
</section>
|
||||
|
||||
<!-- Approvals Section -->
|
||||
<section class="card projects-section">
|
||||
<div class="card-header">
|
||||
<h2>Approve Events</h2>
|
||||
</div>
|
||||
<div class="card-body approvals-body">
|
||||
<div class="form-group">
|
||||
<label for="startDate">Start Date</label>
|
||||
<input type="date" id="startDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endDate">End Date</label>
|
||||
<input type="date" id="endDate">
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<button class="btn btn-secondary" id="loadEvents">Load Events</button>
|
||||
<button class="btn btn-primary" id="approveEvents">Approve Events</button>
|
||||
</div>
|
||||
<div class="events-list" id="eventsList">
|
||||
<div class="loading">No events loaded yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Projects Info Section -->
|
||||
<section class="card projects-section">
|
||||
<div class="card-header">
|
||||
<h2>Available Projects</h2>
|
||||
<button class="btn-icon" id="refreshProjects" title="Refresh projects">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="projects-list" id="projectsList">
|
||||
<div class="loading">Loading projects...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<p>ZynkTime v1.0 - Powered by Kleer API</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>Processing your request...</p>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,443 @@
|
||||
// State
|
||||
let selectedFile = null;
|
||||
let projects = {};
|
||||
let lastEvents = [];
|
||||
|
||||
// DOM Elements
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const browseLink = document.getElementById('browseLink');
|
||||
const filePreview = document.getElementById('filePreview');
|
||||
const fileName = document.getElementById('fileName');
|
||||
const fileSize = document.getElementById('fileSize');
|
||||
const removeFileBtn = document.getElementById('removeFile');
|
||||
const projectSelect = document.getElementById('projectSelect');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
const resultsSection = document.getElementById('resultsSection');
|
||||
const resultsContent = document.getElementById('resultsContent');
|
||||
const projectsList = document.getElementById('projectsList');
|
||||
const refreshProjectsBtn = document.getElementById('refreshProjects');
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
const toastContainer = document.getElementById('toastContainer');
|
||||
const startDateInput = document.getElementById('startDate');
|
||||
const endDateInput = document.getElementById('endDate');
|
||||
const loadEventsBtn = document.getElementById('loadEvents');
|
||||
const approveEventsBtn = document.getElementById('approveEvents');
|
||||
const eventsList = document.getElementById('eventsList');
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkHealth();
|
||||
loadProjects();
|
||||
setDefaultDateRange();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// File upload
|
||||
uploadZone.addEventListener('click', () => fileInput.click());
|
||||
browseLink.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
removeFileBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeFile();
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
uploadZone.addEventListener('dragover', handleDragOver);
|
||||
uploadZone.addEventListener('dragleave', handleDragLeave);
|
||||
uploadZone.addEventListener('drop', handleDrop);
|
||||
|
||||
// Submit
|
||||
submitBtn.addEventListener('click', handleSubmit);
|
||||
|
||||
// Refresh projects
|
||||
refreshProjectsBtn.addEventListener('click', () => {
|
||||
refreshProjectsBtn.classList.add('spin');
|
||||
loadProjects().finally(() => {
|
||||
setTimeout(() => refreshProjectsBtn.classList.remove('spin'), 500);
|
||||
});
|
||||
});
|
||||
|
||||
// Approvals
|
||||
loadEventsBtn.addEventListener('click', fetchEvents);
|
||||
approveEventsBtn.addEventListener('click', approveEvents);
|
||||
}
|
||||
|
||||
// API Functions
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
|
||||
const statusText = statusBadge.querySelector('.status-text');
|
||||
|
||||
if (data.status === 'healthy') {
|
||||
statusBadge.classList.add('healthy');
|
||||
statusText.textContent = 'Connected';
|
||||
} else {
|
||||
statusBadge.classList.add('unhealthy');
|
||||
statusText.textContent = 'No API Key';
|
||||
showToast('Please configure KLEER_API_KEY environment variable', 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
statusBadge.classList.add('unhealthy');
|
||||
statusBadge.querySelector('.status-text').textContent = 'Offline';
|
||||
showToast('Cannot connect to server', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
projects = await response.json();
|
||||
populateProjectSelect();
|
||||
displayProjects();
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
projectSelect.innerHTML = '<option value="">Failed to load projects</option>';
|
||||
projectsList.innerHTML = `<div class="loading" style="color: var(--danger);">Failed to load projects: ${error.message}</div>`;
|
||||
showToast('Failed to load projects', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectSelect() {
|
||||
projectSelect.innerHTML = '<option value="">Select a project...</option>';
|
||||
|
||||
Object.keys(projects).forEach(projectName => {
|
||||
const option = document.createElement('option');
|
||||
option.value = projectName;
|
||||
option.textContent = projectName;
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
|
||||
projectSelect.disabled = false;
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function displayProjects() {
|
||||
const projectNames = Object.keys(projects);
|
||||
|
||||
if (projectNames.length === 0) {
|
||||
projectsList.innerHTML = '<div class="loading">No projects found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
projectsList.innerHTML = '';
|
||||
|
||||
projectNames.forEach(projectName => {
|
||||
const project = projects[projectName];
|
||||
const projectItem = document.createElement('div');
|
||||
projectItem.className = 'project-item';
|
||||
projectItem.innerHTML = `
|
||||
<div class="project-name">${projectName}</div>
|
||||
<div class="project-meta">
|
||||
<span>ID: ${project.id}</span>
|
||||
<span>Activity: ${project.activity.id || 'N/A'}</span>
|
||||
</div>
|
||||
`;
|
||||
projectsList.appendChild(projectItem);
|
||||
});
|
||||
}
|
||||
|
||||
// File Handling
|
||||
function handleFileSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (isSupportedFile(file.name)) {
|
||||
setFile(file);
|
||||
} else {
|
||||
showToast('Please select a CSV or XLSX file', 'warning');
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
if (isSupportedFile(file.name)) {
|
||||
setFile(file);
|
||||
} else {
|
||||
showToast('Please drop a CSV or XLSX file', 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedFile(fileName) {
|
||||
const lower = fileName.toLowerCase();
|
||||
return lower.endsWith('.csv') || lower.endsWith('.xlsx');
|
||||
}
|
||||
|
||||
function setFile(file) {
|
||||
selectedFile = file;
|
||||
|
||||
// Show file preview
|
||||
uploadZone.style.display = 'none';
|
||||
filePreview.style.display = 'block';
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatFileSize(file.size);
|
||||
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function removeFile() {
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
uploadZone.style.display = 'block';
|
||||
filePreview.style.display = 'none';
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
submitBtn.disabled = !(selectedFile && projectSelect.value);
|
||||
}
|
||||
|
||||
function setDefaultDateRange() {
|
||||
const today = new Date();
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - 7);
|
||||
|
||||
startDateInput.value = start.toISOString().slice(0, 10);
|
||||
endDateInput.value = today.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Submit Handler
|
||||
async function handleSubmit() {
|
||||
if (!selectedFile || !projectSelect.value) {
|
||||
showToast('Please select a file and project', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const projectName = projectSelect.value;
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/report-time?project_name=${encodeURIComponent(projectName)}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Show success
|
||||
displayResults(data);
|
||||
showToast('Time reported successfully!', 'success');
|
||||
|
||||
// Reset form
|
||||
removeFile();
|
||||
projectSelect.value = '';
|
||||
updateSubmitButton();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Results Display
|
||||
function displayResults(data) {
|
||||
resultsContent.innerHTML = `
|
||||
<div class="result-success">
|
||||
<h3>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
${data.message}
|
||||
</h3>
|
||||
<div class="result-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.reported_days}</div>
|
||||
<div class="stat-label">Days Reported</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.total_hours.toFixed(2)}</div>
|
||||
<div class="stat-label">Total Hours</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.project_name}</div>
|
||||
<div class="stat-label">Project</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.user_name}</div>
|
||||
<div class="stat-label">User</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsSection.style.display = 'block';
|
||||
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
loadingOverlay.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
${getToastIcon(type)}
|
||||
</svg>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideInRight 0.3s ease reverse';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
const icons = {
|
||||
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>',
|
||||
error: '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
|
||||
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
|
||||
info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
// Project select change handler
|
||||
projectSelect.addEventListener('change', updateSubmitButton);
|
||||
|
||||
// Approvals helpers
|
||||
function renderEvents(events) {
|
||||
if (!events || events.length === 0) {
|
||||
eventsList.innerHTML = '<div class="loading">No events found for range</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
eventsList.innerHTML = '';
|
||||
events.forEach(event => {
|
||||
const approved = event.approved;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'event-item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<div class="project-name">${event.date || 'Unknown date'}</div>
|
||||
<div class="event-meta">
|
||||
<span>Hours: ${event.hours ?? '-'}</span>
|
||||
<span>Activity: ${event.activity?.id ?? '-'}</span>
|
||||
<span>Project: ${event["client-project"]?.id ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-status ${approved ? 'approved' : 'pending'}">
|
||||
${approved ? 'Approved' : 'Pending'}
|
||||
</div>
|
||||
`;
|
||||
eventsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function getDateRange() {
|
||||
const startDate = startDateInput.value;
|
||||
const endDate = endDateInput.value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showToast('Please select start and end dates', 'warning');
|
||||
return null;
|
||||
}
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
showToast('Start date cannot be after end date', 'warning');
|
||||
return null;
|
||||
}
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
const range = getDateRange();
|
||||
if (!range) return;
|
||||
|
||||
showLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/events?start_date=${range.startDate}&end_date=${range.endDate}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
lastEvents = data.events || [];
|
||||
renderEvents(lastEvents);
|
||||
showToast('Events loaded', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
showToast(`Failed to load events: ${error.message}`, 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function approveEvents() {
|
||||
const range = getDateRange();
|
||||
if (!range) return;
|
||||
|
||||
showLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/approve-events?start_date=${range.startDate}&end_date=${range.endDate}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
showToast('Events approved', 'success');
|
||||
await fetchEvents();
|
||||
} catch (error) {
|
||||
console.error('Error approving events:', error);
|
||||
showToast(`Failed to approve events: ${error.message}`, 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Tests for ZynkTime application.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook
|
||||
|
||||
from config import Config
|
||||
from main import app
|
||||
from services import (
|
||||
create_time_event,
|
||||
extract_work_time,
|
||||
get_user_by_name,
|
||||
parse_csv,
|
||||
parse_xlsx,
|
||||
validate_date,
|
||||
)
|
||||
|
||||
|
||||
# Test client for FastAPI
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestServices:
|
||||
"""Tests for service functions."""
|
||||
|
||||
def test_validate_date_valid(self):
|
||||
"""Test date validation with valid date."""
|
||||
assert validate_date("2024-01-15") is True
|
||||
|
||||
def test_validate_date_invalid(self):
|
||||
"""Test date validation with invalid date."""
|
||||
assert validate_date("invalid-date") is False
|
||||
assert validate_date("2024-13-01") is False
|
||||
|
||||
def test_parse_csv_semicolon(self):
|
||||
"""Test CSV parsing with semicolon delimiter."""
|
||||
csv_content = "col1;col2;col3\nval1;val2;val3"
|
||||
result = parse_csv(csv_content)
|
||||
assert len(result) == 2
|
||||
assert result[0] == ["col1", "col2", "col3"]
|
||||
assert result[1] == ["val1", "val2", "val3"]
|
||||
|
||||
def test_parse_csv_comma(self):
|
||||
"""Test CSV parsing with comma delimiter."""
|
||||
csv_content = "col1,col2,col3\nval1,val2,val3"
|
||||
result = parse_csv(csv_content)
|
||||
assert len(result) == 2
|
||||
assert result[0] == ["col1", "col2", "col3"]
|
||||
assert result[1] == ["val1", "val2", "val3"]
|
||||
|
||||
def test_extract_work_time(self):
|
||||
"""Test work time extraction from CSV rows."""
|
||||
rows = [
|
||||
["h1", "h2", "h3", "h4", "h5", "01/15/2024", "h7", "h8", "h9", "h10", "8.5"],
|
||||
["v1", "v2", "v3", "v4", "v5", "01/16/2024", "v7", "v8", "v9", "v10", "7,5"],
|
||||
]
|
||||
result = extract_work_time(rows)
|
||||
assert "2024-01-15" in result
|
||||
assert "2024-01-16" in result
|
||||
assert result["2024-01-15"] == 8.5
|
||||
assert result["2024-01-16"] == 7.5
|
||||
|
||||
def test_extract_work_time_with_header(self):
|
||||
"""Test work time extraction with header row."""
|
||||
rows = [
|
||||
["reg_period", "xreg_period", "T", "Time code", "Time code (T)", "Vouch.date",
|
||||
"Project Activity", "Project Activity (T)", "Work package", "Work package (T)",
|
||||
"Hours", "Workflow status", "Text"],
|
||||
["202544", "", "A", "0", "Standard Time", "10/27/2025", "", "", "", "", "2.00 ", "N", ""],
|
||||
["202544", "", "A", "0", "Standard Time", "10/27/2025", "", "", "", "", "4.00 ", "N", ""],
|
||||
]
|
||||
result = extract_work_time(rows)
|
||||
assert "2025-10-27" in result
|
||||
assert result["2025-10-27"] == 6.0
|
||||
|
||||
def test_extract_work_time_below_threshold(self):
|
||||
"""Test that hours below threshold are filtered out."""
|
||||
rows = [
|
||||
["h1", "h2", "h3", "h4", "h5", "01/15/2024", "h7", "h8", "h9", "h10", "0.1"],
|
||||
]
|
||||
result = extract_work_time(rows)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_parse_xlsx_agresso_sheet(self):
|
||||
"""Test XLSX parsing using the AGRESSO worksheet."""
|
||||
workbook = Workbook()
|
||||
workbook.active.title = "Other"
|
||||
sheet = workbook.create_sheet("AGRESSO")
|
||||
sheet.append(["Date", "Hours"])
|
||||
sheet.append(["01/15/2024", "8.5"])
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
|
||||
rows = parse_xlsx(buffer.getvalue())
|
||||
assert rows[0] == ["Date", "Hours"]
|
||||
assert rows[1][0] == "01/15/2024"
|
||||
assert rows[1][1] == "8.5"
|
||||
|
||||
def test_parse_xlsx_missing_agresso(self):
|
||||
"""Test XLSX parsing failure when AGRESSO worksheet is missing."""
|
||||
workbook = Workbook()
|
||||
workbook.active.title = "Other"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
|
||||
with pytest.raises(ValueError, match="Worksheet 'AGRESSO' not found"):
|
||||
parse_xlsx(buffer.getvalue())
|
||||
|
||||
def test_create_time_event(self):
|
||||
"""Test time event creation."""
|
||||
event = create_time_event(
|
||||
user_id=123,
|
||||
project_id=456,
|
||||
activity={"id": 789},
|
||||
date="2024-01-15",
|
||||
hours=8.5
|
||||
)
|
||||
assert event["user"]["id"] == 123
|
||||
assert event["client-project"] == 456
|
||||
assert event["activity"]["id"] == 789
|
||||
assert event["date"] == "2024-01-15"
|
||||
assert event["hours"] == 8.5
|
||||
|
||||
def test_get_user_by_name_success(self):
|
||||
"""Test getting user by name successfully."""
|
||||
users_data = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice"},
|
||||
{"id": 2, "name": "Bob"}
|
||||
]
|
||||
}
|
||||
user_id = get_user_by_name(users_data, "Bob")
|
||||
assert user_id == 2
|
||||
|
||||
def test_get_user_by_name_not_found(self):
|
||||
"""Test getting user by name when user not found."""
|
||||
users_data = {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice"}
|
||||
]
|
||||
}
|
||||
with pytest.raises(ValueError, match="User 'Bob' not found"):
|
||||
get_user_by_name(users_data, "Bob")
|
||||
|
||||
def test_get_user_by_name_invalid_data(self):
|
||||
"""Test getting user by name with invalid data."""
|
||||
with pytest.raises(ValueError, match="Invalid user data"):
|
||||
get_user_by_name(None, "Bob")
|
||||
|
||||
|
||||
class TestAPI:
|
||||
"""Tests for FastAPI endpoints."""
|
||||
|
||||
def test_root_endpoint(self):
|
||||
"""Test root endpoint."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "name" in response.json()
|
||||
assert response.json()["name"] == "ZynkTime API"
|
||||
|
||||
def test_health_check_without_api_key(self):
|
||||
"""Test health check without API key configured."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "unhealthy"
|
||||
assert data["api_key_configured"] is False
|
||||
|
||||
def test_health_check_with_api_key(self):
|
||||
"""Test health check with API key configured."""
|
||||
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key"}):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["api_key_configured"] is True
|
||||
|
||||
@patch('main.KleerAPI')
|
||||
def test_list_projects_success(self, mock_kleer_api):
|
||||
"""Test listing projects successfully."""
|
||||
# Set up mock
|
||||
mock_instance = Mock()
|
||||
mock_instance.get_user_info.return_value = {
|
||||
"users": [{"id": 1, "name": "Test User"}]
|
||||
}
|
||||
mock_instance.get_projects.return_value = {
|
||||
"Project A": {"id": 1, "activity": {"id": 10}}
|
||||
}
|
||||
mock_kleer_api.return_value = mock_instance
|
||||
|
||||
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
|
||||
response = client.get("/projects")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "Project A" in data
|
||||
|
||||
@patch('main.KleerAPI')
|
||||
def test_list_events_success(self, mock_kleer_api):
|
||||
"""Test listing events within date range."""
|
||||
mock_instance = Mock()
|
||||
mock_instance.get_user_info.return_value = {"users": [{"id": 1, "name": "Test User"}]}
|
||||
mock_instance.get_events.return_value = {"event-readables": [{"id": {"id": 1}, "date": "2020-07-20"}]}
|
||||
mock_kleer_api.return_value = mock_instance
|
||||
|
||||
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
|
||||
response = client.get("/events", params={"start_date": "2020-07-20", "end_date": "2020-07-21"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == 1
|
||||
assert len(data["events"]) == 1
|
||||
|
||||
@patch('main.KleerAPI')
|
||||
def test_approve_events_success(self, mock_kleer_api):
|
||||
"""Test approving events."""
|
||||
mock_instance = Mock()
|
||||
mock_instance.get_user_info.return_value = {"users": [{"id": 1, "name": "Test User"}]}
|
||||
mock_instance.approve_events.return_value = {"id": 3118}
|
||||
mock_kleer_api.return_value = mock_instance
|
||||
|
||||
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
|
||||
response = client.post("/approve-events", params={"start_date": "2020-07-20", "end_date": "2020-07-21"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["approval"] == {"id": 3118}
|
||||
assert data["user_id"] == 1
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Tests for configuration."""
|
||||
|
||||
def test_get_username_default(self):
|
||||
"""Test getting default username."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
username = Config.get_username()
|
||||
assert username == "Christopher Juhlin"
|
||||
|
||||
def test_get_username_from_env(self):
|
||||
"""Test getting username from environment."""
|
||||
with patch.dict(os.environ, {"KLEER_USERNAME": "Custom User"}):
|
||||
username = Config.get_username()
|
||||
assert username == "Custom User"
|
||||
|
||||
def test_validate_config_no_api_key(self):
|
||||
"""Test config validation without API key."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with pytest.raises(ValueError, match="KLEER_API_KEY"):
|
||||
Config.validate_config()
|
||||
|
||||
def test_validate_config_with_api_key(self):
|
||||
"""Test config validation with API key."""
|
||||
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key"}):
|
||||
Config.validate_config() # Should not raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user