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