diff --git a/.gitignore b/.gitignore index 9a9313b..8297ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,6 @@ cython_debug/ .idea/ # Database migrations -migrations/ +# migrations/ +timetrack.db +uploads/ diff --git a/app/__init__.py b/app/__init__.py index 1dc783f..24b3eb3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,5 @@ from flask import Flask, redirect, url_for -from flask_migrate import Migrate +from flask_migrate import Migrate # type: ignore from app.db.database import db, init_db from app.routes.main import main @@ -24,4 +24,8 @@ def create_app(config_object): app.register_blueprint(monthly_log_bp) app.register_blueprint(settings_bp) + from app.routes.import_log import import_log_bp + + app.register_blueprint(import_log_bp) + return app diff --git a/app/models/models.py b/app/models/models.py index 4cb54b7..d098fcb 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -27,6 +27,7 @@ class ScheduleEntry(db.Model): # type: ignore date = Column(SQLADate, nullable=False) entries = Column(JSON, nullable=False) absence_code = Column(String, nullable=True) + observation = Column(String, nullable=True) employee: Mapped["Employee"] = relationship( "Employee", back_populates="schedule_entries" diff --git a/app/routes/import_log.py b/app/routes/import_log.py new file mode 100644 index 0000000..9c20887 --- /dev/null +++ b/app/routes/import_log.py @@ -0,0 +1,174 @@ +import dataclasses +import json +import logging +import os +import pathlib +import shutil +import tempfile +import uuid +from datetime import datetime +from typing import List + +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + url_for, +) +from werkzeug.utils import secure_filename + +from app.db.database import db +from app.models.models import Employee, ScheduleEntry +from app.services.importer.factory import ImporterFactory +from app.services.importer.protocol import ImportResult +from app.utils.time_calculator import calculate_daily_hours + +logger = logging.getLogger(__name__) + + +import_log_bp = Blueprint("import_log", __name__, url_prefix="/import") + +# Configure upload folder +UPLOAD_FOLDER = os.path.join(os.getcwd(), "uploads") +os.makedirs(UPLOAD_FOLDER, exist_ok=True) + + +@import_log_bp.route("/", methods=["GET", "POST"]) +def upload_file(): + if request.method == "POST": + if "file" not in request.files: + flash("No file part", "error") + return redirect(request.url) + + file = request.files["file"] + if file.filename == "": + flash("No selected file", "error") + return redirect(request.url) + + if file: + filename = secure_filename(file.filename or "") + file_ext = filename.split(".")[-1].lower() + + if file_ext not in ["pdf", "xlsx", "xls"]: + flash("Unsupported file type", "error") + return redirect(request.url) + + # Generate unique ID for this upload + upload_id = str(uuid.uuid4()) + temp_filename = f"{upload_id}.{file_ext}" + filepath = os.path.join(UPLOAD_FOLDER, temp_filename) + file.save(filepath) + + return redirect(url_for("import_log.preview", upload_id=upload_id)) + + return render_template("import_upload.html") + + +@import_log_bp.route("/preview/", methods=["GET"]) +def preview(upload_id): + # Find file + filepath = _get_filepath(upload_id) + if not filepath: + flash("File not found or expired", "error") + return redirect(url_for("import_log.upload_file")) + + try: + importer = ImporterFactory.get_importer(filepath) + with open(filepath, "rb") as f: + content = f.read() + result = importer.parse(content) + + return render_template( + "import_preview.html", result=result, upload_id=upload_id + ) + except Exception as e: + flash(f"Error parsing file: {str(e)}", "error") + return redirect(url_for("import_log.upload_file")) + + +@import_log_bp.route("/confirm/", methods=["POST"]) +def confirm(upload_id): + filepath = _get_filepath(upload_id) + if not filepath: + flash("File not found or expired", "error") + return redirect(url_for("import_log.upload_file")) + + try: + importer = ImporterFactory.get_importer(filepath) + with open(filepath, "rb") as f: + content = f.read() + result = importer.parse(content) + + # Import valid records + count = 0 + # Assume for now we are importing for Employee ID 1 or passed in form + # Ideally user selects employee in Upload or Preview + # For now, let's hardcode 1 or get from request if we added it + employee_id = 1 + + for record in result.records: + if not record.is_valid: + continue + + # Check duplicate/overwrite? + entry_date = datetime.strptime(record.date, "%Y-%m-%d").date() + existing = ScheduleEntry.query.filter_by( + employee_id=employee_id, date=entry_date + ).first() + + entries_data = [] + if record.entry_time and record.exit_time: + entries_data.append( + {"entry": record.entry_time, "exit": record.exit_time} + ) + + if existing: + existing.entries = entries_data + existing.observation = record.observation + # If valid entries exist, we assume normal work day, so unset absence? + if entries_data: + existing.absence_code = None + else: + new_entry = ScheduleEntry( + employee_id=employee_id, + date=entry_date, + entries=entries_data, + observation=record.observation, + ) + db.session.add(new_entry) + count += 1 + + db.session.commit() + + # Cleanup + os.remove(filepath) + + flash(f"Successfully imported {count} records", "success") + return redirect(url_for("monthly_log.view_monthly_log")) + + except Exception as e: + db.session.rollback() + flash(f"Error importing data: {str(e)}", "error") + return redirect(url_for("import_log.preview", upload_id=upload_id)) + + +@import_log_bp.route("/cancel/", methods=["POST"]) +def cancel(upload_id): + filepath = _get_filepath(upload_id) + if filepath: + try: + os.remove(filepath) + except: + pass + return redirect(url_for("import_log.upload_file")) + + +def _get_filepath(upload_id): + # Search for file with upload_id prefix + for f in os.listdir(UPLOAD_FOLDER): + if f.startswith(upload_id): + return os.path.join(UPLOAD_FOLDER, f) + return None diff --git a/app/services/importer/excel_importer.py b/app/services/importer/excel_importer.py new file mode 100644 index 0000000..dbf0a57 --- /dev/null +++ b/app/services/importer/excel_importer.py @@ -0,0 +1,108 @@ +import io +from typing import Any, List, Optional + +import pandas as pd # type: ignore + +from app.services.importer.protocol import ( + ImporterProtocol, + ImportResult, + TimeEntryRecord, +) +from app.utils.validators import validate_date, validate_time_format + + +class ExcelImporter(ImporterProtocol): + def parse(self, file_content: Any) -> ImportResult: + records: List[TimeEntryRecord] = [] + errors: List[str] = [] + + try: + # Read Excel file + df = pd.read_excel(io.BytesIO(file_content)) + + # Normalize headers + df.columns = df.columns.astype(str).str.lower().str.strip() + + # Map columns + col_map = {} + for col in df.columns: + if "fecha" in col or "date" in col: + col_map["date"] = col + elif "entrada" in col or "in" in col: + col_map["entry"] = col + elif "salida" in col or "out" in col: + col_map["exit"] = col + elif "observ" in col or "note" in col: + col_map["obs"] = col + + if "date" not in col_map: + errors.append("Could not find 'Fecha' or 'Date' column") + return ImportResult([], 0, 0, errors) + + for _, row in df.iterrows(): + date_val = row[col_map["date"]] + if pd.isna(date_val): + continue + + # Handle dates + if isinstance(date_val, pd.Timestamp): + date_str = date_val.strftime("%Y-%m-%d") + else: + date_str = str(date_val).strip() + + entry_val = ( + row.get(col_map.get("entry")) if "entry" in col_map else None + ) + exit_val = row.get(col_map.get("exit")) if "exit" in col_map else None + obs_val = row.get(col_map.get("obs")) if "obs" in col_map else None + + entry_str = self._format_time(entry_val) + exit_str = self._format_time(exit_val) + + # Logic for validating + is_valid = True + error_msg = None + + if not validate_date(date_str): + is_valid = False + error_msg = f"Invalid date format: {date_str}" + elif entry_str and not validate_time_format(entry_str): + is_valid = False + error_msg = f"Invalid entry time: {entry_str}" + elif exit_str and not validate_time_format(exit_str): + is_valid = False + error_msg = f"Invalid exit time: {exit_str}" + + records.append( + TimeEntryRecord( + date=date_str, + entry_time=entry_str, + exit_time=exit_str, + observation=str(obs_val) if pd.notna(obs_val) else None, + is_valid=is_valid, + error_message=error_msg, + ) + ) + + except Exception as e: + errors.append(f"Error parsing Excel: {str(e)}") + + valid_records = sum(1 for r in records if r.is_valid) + return ImportResult(records, len(records), valid_records, errors) + + def _format_time(self, val: Any) -> Optional[str]: + if pd.isna(val): + return None + + if isinstance(val, pd.Timestamp): + return str(val.strftime("%H:%M")) + + # If it's a datetime.time object + try: + return str(val.strftime("%H:%M")) + except AttributeError: + pass + + s = str(val).strip() + # Basic fixes + return s diff --git a/app/services/importer/factory.py b/app/services/importer/factory.py new file mode 100644 index 0000000..ecb0c32 --- /dev/null +++ b/app/services/importer/factory.py @@ -0,0 +1,21 @@ +from typing import Dict, Type + +from app.services.importer.excel_importer import ExcelImporter +from app.services.importer.pdf_importer import PDFImporter +from app.services.importer.protocol import ImporterProtocol + + +class ImporterFactory: + _importers: Dict[str, Type[ImporterProtocol]] = { + "pdf": PDFImporter, + "xlsx": ExcelImporter, + "xls": ExcelImporter, + } + + @classmethod + def get_importer(cls, filename: str) -> ImporterProtocol: + ext = filename.split(".")[-1].lower() + importer_class = cls._importers.get(ext) + if not importer_class: + raise ValueError(f"Unsupported file extension: {ext}") + return importer_class() diff --git a/app/services/importer/pdf_importer.py b/app/services/importer/pdf_importer.py new file mode 100644 index 0000000..6e59f7b --- /dev/null +++ b/app/services/importer/pdf_importer.py @@ -0,0 +1,132 @@ +import io +import re +from typing import Any, List, Optional + +import pdfplumber # type: ignore + +from app.services.importer.protocol import ( + ImporterProtocol, + ImportResult, + TimeEntryRecord, +) +from app.utils.validators import validate_date, validate_time_format + + +class PDFImporter(ImporterProtocol): + def parse(self, file_content: Any) -> ImportResult: + records: List[TimeEntryRecord] = [] + errors: List[str] = [] + + try: + with pdfplumber.open(io.BytesIO(file_content)) as pdf: + for page in pdf.pages: + tables = page.extract_tables() + for table in tables: + records.extend(self._process_table(table)) + except Exception as e: + errors.append(f"Error parsing PDF: {str(e)}") + + valid_records = sum(1 for r in records if r.is_valid) + return ImportResult( + records=records, + total_records=len(records), + valid_records=valid_records, + errors=errors, + ) + + def _process_table(self, table: List[List[Optional[str]]]) -> List[TimeEntryRecord]: + records = [] + # Simple heuristic: find header row + header_map = {} + data_start_idx = 0 + + for idx, row in enumerate(table): + # Clean row values + row_clean = [str(c).lower().strip() if c else "" for c in row] + if "fecha" in row_clean or "date" in row_clean: + # Map columns + for col_idx, val in enumerate(row_clean): + if "fecha" in val or "date" in val: + header_map["date"] = col_idx + elif "entrada" in val or "in" in val: + header_map["entry"] = col_idx + elif "salida" in val or "out" in val: + header_map["exit"] = col_idx + elif "observ" in val or "note" in val: + header_map["obs"] = col_idx + data_start_idx = idx + 1 + break + + if not header_map: + return [] + + for row in table[data_start_idx:]: + if not row: + continue + + # Skip if row doesn't have enough columns or date is missing + if len(row) <= max(header_map.values()): + continue + + date_val = row[header_map.get("date", -1)] if "date" in header_map else None + if not date_val: + continue + + # Normalize date + # Assuming YYYY-MM-DD or DD/MM/YYYY + date_str = self._normalize_date(str(date_val)) + + entry_val = ( + row[header_map.get("entry", -1)] if "entry" in header_map else None + ) + exit_val = row[header_map.get("exit", -1)] if "exit" in header_map else None + obs_val = row[header_map.get("obs", -1)] if "obs" in header_map else None + + # Normalize times + entry_str = self._normalize_time(str(entry_val)) if entry_val else None + exit_str = self._normalize_time(str(exit_val)) if exit_val else None + + # Validation + is_valid = True + error_msg = None + + if not validate_date(date_str): + is_valid = False + error_msg = f"Invalid date format: {date_val}" + elif entry_str and not validate_time_format(entry_str): + is_valid = False + error_msg = f"Invalid entry time: {entry_val}" + elif exit_str and not validate_time_format(exit_str): + is_valid = False + error_msg = f"Invalid exit time: {exit_val}" + + records.append( + TimeEntryRecord( + date=date_str, + entry_time=entry_str, + exit_time=exit_str, + observation=str(obs_val) if obs_val else None, + is_valid=is_valid, + error_message=error_msg, + ) + ) + + return records + + def _normalize_date(self, date_str: str) -> str: + # Simple cleanup + date_str = date_str.strip() + # Handle DD/MM/YYYY to YYYY-MM-DD + if re.match(r"\d{2}/\d{2}/\d{4}", date_str): + parts = date_str.split("/") + return f"{parts[2]}-{parts[1]}-{parts[0]}" + return date_str + + def _normalize_time(self, time_str: str) -> Optional[str]: + time_str = time_str.strip() + # Ensure HH:MM + if re.match(r"^\d{1,2}:\d{2}$", time_str): + if len(time_str) == 4: # H:MM + return f"0{time_str}" + return time_str + return None diff --git a/app/services/importer/protocol.py b/app/services/importer/protocol.py new file mode 100644 index 0000000..a69b7ba --- /dev/null +++ b/app/services/importer/protocol.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Any, List, Optional, Protocol + + +@dataclass +class TimeEntryRecord: + date: str # YYYY-MM-DD + entry_time: Optional[str] = None # HH:MM + exit_time: Optional[str] = None # HH:MM + observation: Optional[str] = None + is_valid: bool = True + error_message: Optional[str] = None + + +@dataclass +class ImportResult: + records: List[TimeEntryRecord] + total_records: int + valid_records: int + errors: List[str] + + +class ImporterProtocol(Protocol): + def parse(self, file_content: Any) -> ImportResult: + """Parse file content and return import result.""" + ... diff --git a/app/templates/base.html b/app/templates/base.html index f562f9f..5590b1a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,6 @@ + @@ -8,6 +9,7 @@ +