diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 33af451..7a1e0b1 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -11,7 +11,7 @@ from .maintenance import ( ) from .company import Company, Part, PartStockMovement, Alert, Service from .planning import ( - WorkSchedule, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, + WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable, PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TechnicianAvailability, AdminTask, ZoneAccessRule @@ -27,7 +27,7 @@ __all__ = [ 'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument', 'Lot', 'LotTask', 'LotService', 'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service', - 'WorkSchedule', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', + 'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 7db6385..297ce76 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -17,15 +17,82 @@ class WorkSchedule(db.Model): end_time = db.Column(db.Time) lunch_start = db.Column(db.Time, nullable=True) # Début pause déjeuner lunch_end = db.Column(db.Time, nullable=True) # Fin pause déjeuner + template_id = db.Column(db.Integer, db.ForeignKey("work_schedule_templates.id"), nullable=True) is_active = db.Column(db.Boolean, default=True) user = db.relationship("User", backref=db.backref("work_schedules", lazy="dynamic")) + template = db.relationship("WorkScheduleTemplate", back_populates="assignments") def __repr__(self): return f"" +class WorkScheduleTemplate(db.Model): + """Un des trois profils horaires paramétrables de l'agent.""" + __tablename__ = "work_schedule_templates" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + name = db.Column(db.String(80), nullable=False) + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + lunch_start = db.Column(db.Time, nullable=True) + lunch_end = db.Column(db.Time, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + user = db.relationship("User", backref=db.backref("work_schedule_templates", lazy="dynamic")) + assignments = db.relationship("WorkSchedule", back_populates="template") + + @property + def duration_minutes(self): + total = (self.end_time.hour * 60 + self.end_time.minute) - (self.start_time.hour * 60 + self.start_time.minute) + if self.lunch_start and self.lunch_end: + total -= (self.lunch_end.hour * 60 + self.lunch_end.minute) - (self.lunch_start.hour * 60 + self.lunch_start.minute) + return max(total, 0) + + +class AnnualTimeConfig(db.Model): + """Objectif annuel et déduction de pénibilité pour un agent.""" + __tablename__ = "annual_time_configs" + __table_args__ = (db.UniqueConstraint("user_id", "year", name="uq_annual_time_user_year"),) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + year = db.Column(db.Integer, nullable=False) + target_minutes = db.Column(db.Integer, nullable=False, default=1607 * 60) + hardship_deduction_minutes = db.Column(db.Integer, nullable=False, default=0) + notes = db.Column(db.Text, nullable=True) + + user = db.relationship("User", backref=db.backref("annual_time_configs", lazy="dynamic")) + + @property + def minutes_to_work(self): + return max((self.target_minutes or 0) - (self.hardship_deduction_minutes or 0), 0) + + +class TimeEntry(db.Model): + """Suivi réel d'une journée : retard, présence, permanence ou heures sup.""" + __tablename__ = "work_time_entries" + __table_args__ = (db.UniqueConstraint("user_id", "work_date", name="uq_work_time_user_date"),) + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + work_date = db.Column(db.Date, nullable=False, index=True) + planned_minutes = db.Column(db.Integer, nullable=False, default=0) + actual_minutes = db.Column(db.Integer, nullable=False, default=0) + entry_type = db.Column(db.String(30), nullable=False, default="travail") + note = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + user = db.relationship("User", backref=db.backref("work_time_entries", lazy="dynamic")) + + @property + def variance_minutes(self): + return (self.actual_minutes or 0) - (self.planned_minutes or 0) + + class CollegeClosure(db.Model): """Fermetures du collège / Vacances scolaires.""" __tablename__ = "college_closures" diff --git a/app_new/core/services/planning_service.py b/app_new/core/services/planning_service.py index abb70fa..0937d4a 100644 --- a/app_new/core/services/planning_service.py +++ b/app_new/core/services/planning_service.py @@ -193,9 +193,10 @@ class PlanningService: # Horaires normaux - chercher le premier schedule avec des horaires valides schedules = WorkSchedule.query.filter_by(day_of_week=day_of_week).all() for schedule in schedules: - if schedule.start_time and schedule.end_time: - return (schedule.start_time, schedule.end_time, - schedule.lunch_start, schedule.lunch_end) + source = schedule.template if getattr(schedule, 'template', None) and schedule.template.is_active else schedule + if source.start_time and source.end_time: + return (source.start_time, source.end_time, + source.lunch_start, source.lunch_end) # Si un schedule existe mais sans horaires, considérer comme non travaillé if schedules: diff --git a/app_new/lib_ext/vacances_scolaires.py b/app_new/lib_ext/vacances_scolaires.py index 10d1924..b354454 100644 --- a/app_new/lib_ext/vacances_scolaires.py +++ b/app_new/lib_ext/vacances_scolaires.py @@ -83,7 +83,7 @@ def import_vacances_scolaires(zone="A", annee=None, user_id=None): for vac in vacances: # Vérifier si cette fermeture existe déjà existing = CollegeClosure.query.filter( - CollegeClosure.type == "vacances_scolaires", + CollegeClosure.closure_type == "vacances_scolaires", CollegeClosure.start_date == vac["start_date"], CollegeClosure.end_date == vac["end_date"] ).first() @@ -94,13 +94,12 @@ def import_vacances_scolaires(zone="A", annee=None, user_id=None): try: closure = CollegeClosure( - type="vacances_scolaires", - label=vac["description"], + name=vac["description"], start_date=vac["start_date"], end_date=vac["end_date"], - zone=zone, - is_exception=False, - created_by_id=user_id + closure_type="vacances_scolaires", + work_hours_type="none", + notes=f"Zone académique {zone} — import officiel", ) db.session.add(closure) ajoutees += 1 @@ -229,7 +228,7 @@ def import_jours_feries(annee=None, user_id=None): for date, nom in jours: existing = CollegeClosure.query.filter( - CollegeClosure.type == "jour_ferie", + CollegeClosure.closure_type == "jour_ferie", CollegeClosure.start_date == date, CollegeClosure.end_date == date ).first() @@ -239,13 +238,12 @@ def import_jours_feries(annee=None, user_id=None): continue closure = CollegeClosure( - type="jour_ferie", - label=nom, + name=nom, start_date=date, end_date=date, - zone=None, - is_exception=False, - created_by_id=user_id + closure_type="jour_ferie", + work_hours_type="none", + notes="Import automatique des jours fériés français", ) db.session.add(closure) ajoutees += 1 @@ -253,4 +251,4 @@ def import_jours_feries(annee=None, user_id=None): if ajoutees > 0: db.session.commit() - return ajoutees, ignorees \ No newline at end of file + return ajoutees, ignorees diff --git a/app_new/planning/routes.py b/app_new/planning/routes.py index 09a8ecc..dd5c5d4 100644 --- a/app_new/planning/routes.py +++ b/app_new/planning/routes.py @@ -4,6 +4,7 @@ Ce module re-exporte le blueprint apres avoir charge tous les sous-modules. # Import dans cet ordre pour attacher les routes from . import schedules from . import closures +from . import time_tracking from . import admin_rules from .schedules import planning_bp diff --git a/app_new/planning/templates/planning/time_tracking.html b/app_new/planning/templates/planning/time_tracking.html new file mode 100644 index 0000000..8ab3df8 --- /dev/null +++ b/app_new/planning/templates/planning/time_tracking.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Horaires et suivi annuel — GMAO{% endblock %} +{% block content %} +
+
+

Horaires et suivi des heures

Configurez vos profils, permanences et écarts d'heures.

+ +
+ +
+
À réaliser

{{ '%d h %02d'|format(required_minutes // 60, required_minutes % 60) }}

1607 h moins pénibilité
+
Prévu par calendrier

{{ '%d h %02d'|format(planned_minutes // 60, planned_minutes % 60) }}

+
Réalisé saisi

{{ '%d h %02d'|format(actual_minutes // 60, actual_minutes % 60) }}

+
Solde réalisé / dû

{{ '+' if variance_minutes >= 0 else '' }}{{ '%d h %02d'|format(abs(variance_minutes) // 60, abs(variance_minutes) % 60) }}

{% if variance_minutes >= 0 %}heures à récupérer{% else %}heures à faire{% endif %}
+
+ +
+
+
Objectif annuel {{ year }}
+
+
+ +
+
+ +
Horaires types (maximum 3){{ templates|length }}/3
+ {% for template in templates %}
{{ template.name }}
{{ template.start_time.strftime('%H:%M') }}–{{ template.end_time.strftime('%H:%M') }}{% if template.lunch_start %} · pause {{ template.lunch_start.strftime('%H:%M') }}–{{ template.lunch_end.strftime('%H:%M') }}{% endif %}
{% else %}

Aucun profil défini.

{% endfor %} + {% if templates|length < 3 %}
{% endif %} +
+
+ +
+
Affectation par jour
+ {% for day_num, day_name in days.items() %}
{{ day_name }}
{% endfor %} +
+ +
Importer les calendriers
+ +
Ajouter une permanenceUne permanence ouvre une date précise dans une période de vacances.
+ +
Saisir un retard ou une heure supplémentaire
+
+
+ +
Historique {{ year }}
{% for entry in entries[:50] %}{% else %}{% endfor %}
DateTypePrévuRéaliséÉcartNote
{{ entry.work_date.strftime('%d/%m/%Y') }}{{ entry.entry_type }}{{ entry.planned_minutes }} min{{ entry.actual_minutes }} min{{ '+' if entry.variance_minutes > 0 else '' }}{{ entry.variance_minutes }} min{{ entry.note or '—' }}
Aucune saisie.
+
+{% endblock %} diff --git a/app_new/planning/time_tracking.py b/app_new/planning/time_tracking.py new file mode 100644 index 0000000..e9a4d20 --- /dev/null +++ b/app_new/planning/time_tracking.py @@ -0,0 +1,255 @@ +"""Configuration des horaires et suivi annuel des heures de l'agent.""" +from datetime import date, datetime, timedelta + +from flask import flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from ..extensions import db +from ..core.models.planning import ( + AnnualTimeConfig, ClosureWorkDay, CollegeClosure, TimeEntry, + WorkSchedule, WorkScheduleTemplate, +) +from .schedules import planning_bp + + +def _time(value): + return datetime.strptime(value, "%H:%M").time() if value else None + + +def _minutes(start, end, lunch_start=None, lunch_end=None): + if not start or not end: + return 0 + value = (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute) + if lunch_start and lunch_end: + value -= (lunch_end.hour * 60 + lunch_end.minute) - (lunch_start.hour * 60 + lunch_start.minute) + return max(value, 0) + + +def _redirect(year=None): + return redirect(url_for("planning.time_tracking", year=year or date.today().year)) + + +@planning_bp.route("/time") +@login_required +def time_tracking(): + year = request.args.get("year", date.today().year, type=int) + config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first() + if not config: + config = AnnualTimeConfig(user_id=current_user.id, year=year) + db.session.add(config) + db.session.commit() + templates = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).order_by(WorkScheduleTemplate.id).all() + assignments = {item.day_of_week: item for item in WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all()} + closures = CollegeClosure.query.filter( + CollegeClosure.end_date >= date(year, 1, 1), CollegeClosure.start_date <= date(year, 12, 31) + ).order_by(CollegeClosure.start_date).all() + from ..core.models.planning import ClosureSchedule, PersonalLeave, Training, TrainingParticipant + from ..core.services.planning_service import get_french_public_holidays + closure_ids = [item.id for item in closures] + work_days = ClosureWorkDay.query.filter(ClosureWorkDay.closure_id.in_(closure_ids)).all() if closure_ids else [] + closure_schedules = ClosureSchedule.query.filter(ClosureSchedule.closure_id.in_(closure_ids), ClosureSchedule.is_active.is_(True)).all() if closure_ids else [] + work_day_map = {item.work_date: item for item in work_days} + closure_schedule_map = {(item.closure_id, item.day_of_week): item for item in closure_schedules} + holidays = {item[0] for item in get_french_public_holidays(year)} + leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= date(year, 1, 1), PersonalLeave.start_date <= date(year, 12, 31)).all() + leave_dates = {day for leave in leaves for day in (leave.start_date + timedelta(days=i) for i in range((leave.end_date - leave.start_date).days + 1))} + entries = TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.work_date >= date(year, 1, 1), TimeEntry.work_date <= date(year, 12, 31), + ).order_by(TimeEntry.work_date.desc()).all() + planned = 0 + current = date(year, 1, 1) + while current.year == year: + hours = None + if current not in holidays and current not in leave_dates: + closure = next((item for item in closures if item.start_date <= current <= item.end_date), None) + if closure: + exceptional = work_day_map.get(current) + if exceptional: + hours = (exceptional.start_time, exceptional.end_time, exceptional.lunch_start, exceptional.lunch_end) + elif closure.work_hours_type == "reduced": + hours = (_time("09:00"), _time("12:00"), None, None) + elif closure.work_hours_type == "custom": + item = closure_schedule_map.get((closure.id, current.weekday())) + if item: + hours = (item.start_time, item.end_time, item.lunch_start, item.lunch_end) + elif current.weekday() < 5 and assignments.get(current.weekday()): + assignment = assignments[current.weekday()] + source = assignment.template if assignment.template and assignment.template.is_active else assignment + hours = (source.start_time, source.end_time, source.lunch_start, source.lunch_end) + if hours: + planned += _minutes(*hours) + current += timedelta(days=1) + actual = sum(item.actual_minutes or 0 for item in entries) + return render_template( + "planning/time_tracking.html", year=year, time_config=config, templates=templates, + assignments=assignments, closures=closures, entries=entries, + days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"}, + planned_minutes=planned, actual_minutes=actual, + required_minutes=config.minutes_to_work, variance_minutes=actual - config.minutes_to_work, + ) + + +@planning_bp.route("/time/config", methods=["POST"]) +@login_required +def save_time_config(): + year = request.form.get("year", date.today().year, type=int) + config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first() + if not config: + config = AnnualTimeConfig(user_id=current_user.id, year=year) + db.session.add(config) + config.target_minutes = round(float(request.form.get("target_hours") or 1607) * 60) + config.hardship_deduction_minutes = round(float(request.form.get("hardship_hours") or 0) * 60) + config.notes = request.form.get("notes") or None + db.session.commit() + flash("Objectif annuel et déduction de pénibilité enregistrés.", "success") + return _redirect(year) + + +@planning_bp.route("/time/template", methods=["POST"]) +@login_required +def save_time_template(): + count = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).count() + if count >= 3: + flash("Vous pouvez définir au maximum trois horaires types.", "warning") + return _redirect() + start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time")) + lunch_start, lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end")) + if _minutes(start, end, lunch_start, lunch_end) <= 0: + flash("Les horaires saisis sont invalides.", "danger") + return _redirect() + template = WorkScheduleTemplate( + user_id=current_user.id, name=(request.form.get("name") or f"Horaire {count + 1}").strip(), + start_time=start, end_time=end, lunch_start=lunch_start, lunch_end=lunch_end, + ) + db.session.add(template) + db.session.commit() + flash("Horaire type ajouté.", "success") + return _redirect() + + +@planning_bp.route("/time/template//delete", methods=["POST"]) +@login_required +def delete_time_template(id): + template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id).first_or_404() + template.is_active = False + for assignment in template.assignments: + assignment.template_id = None + db.session.commit() + flash("Horaire type désactivé.", "success") + return _redirect() + + +@planning_bp.route("/time/template//edit", methods=["POST"]) +@login_required +def edit_time_template(id): + template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id, is_active=True).first_or_404() + start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time")) + lunch_start = _time(request.form.get("lunch_start")) + lunch_end = _time(request.form.get("lunch_end")) or template.lunch_end + if _minutes(start, end, lunch_start, lunch_end) <= 0: + flash("Les horaires saisis sont invalides.", "danger") + return _redirect() + template.name = (request.form.get("name") or template.name).strip() + template.start_time, template.end_time = start, end + template.lunch_start, template.lunch_end = lunch_start, lunch_end + for assignment in template.assignments: + assignment.start_time, assignment.end_time = start, end + assignment.lunch_start, assignment.lunch_end = lunch_start, lunch_end + db.session.commit() + flash("Horaire type modifié.", "success") + return _redirect() + + +@planning_bp.route("/time/assignment", methods=["POST"]) +@login_required +def assign_time_template(): + day = request.form.get("day_of_week", type=int) + template_id = request.form.get("template_id", type=int) + assignment = WorkSchedule.query.filter_by(user_id=current_user.id, day_of_week=day).first() + if not template_id: + if assignment: + db.session.delete(assignment) + else: + template = WorkScheduleTemplate.query.filter_by(id=template_id, user_id=current_user.id, is_active=True).first_or_404() + if not assignment: + assignment = WorkSchedule(user_id=current_user.id, day_of_week=day) + db.session.add(assignment) + assignment.template_id = template.id + assignment.start_time, assignment.end_time = template.start_time, template.end_time + assignment.lunch_start, assignment.lunch_end = template.lunch_start, template.lunch_end + assignment.is_active = True + db.session.commit() + return _redirect() + + +@planning_bp.route("/time/entry", methods=["POST"]) +@login_required +def save_time_entry(): + work_date = datetime.strptime(request.form["work_date"], "%Y-%m-%d").date() + entry = TimeEntry.query.filter_by(user_id=current_user.id, work_date=work_date).first() + if not entry: + entry = TimeEntry(user_id=current_user.id, work_date=work_date) + db.session.add(entry) + entry.planned_minutes = int(request.form.get("planned_minutes") or 0) + entry.actual_minutes = int(request.form.get("actual_minutes") or 0) + entry.entry_type = request.form.get("entry_type") or "travail" + entry.note = request.form.get("note") or None + db.session.commit() + flash("Suivi de la journée enregistré.", "success") + return _redirect(work_date.year) + + +@planning_bp.route("/time/import-vacations", methods=["POST"]) +@login_required +def import_school_vacations(): + from app_new.lib_ext.vacances_scolaires import import_vacances_scolaires + year = request.form.get("year", date.today().year, type=int) + added, ignored, errors = import_vacances_scolaires(request.form.get("zone", "B"), year, current_user.id) + flash(f"Vacances importées : {added} ajoutée(s), {ignored} déjà présente(s)." + (f" {len(errors)} erreur(s)." if errors else ""), "warning" if errors else "success") + return _redirect(year) + + +@planning_bp.route("/time/import-holidays", methods=["POST"]) +@login_required +def import_public_holidays(): + from app_new.lib_ext.vacances_scolaires import import_jours_feries + year = request.form.get("year", date.today().year, type=int) + added, ignored = import_jours_feries(year, current_user.id) + flash(f"Jours fériés importés : {added} ajouté(s), {ignored} déjà présent(s).", "success") + return _redirect(year) + + +@planning_bp.route("/time/permanence", methods=["POST"]) +@login_required +def add_permanence(): + work_date = datetime.strptime(request.form["work_date"], "%Y-%m-%d").date() + closure = CollegeClosure.query.filter( + CollegeClosure.start_date <= work_date, CollegeClosure.end_date >= work_date, + CollegeClosure.closure_type.in_(["vacances", "vacances_scolaires"]), + ).first() + if not closure: + flash("La date doit se situer dans une période de vacances scolaires.", "danger") + return _redirect(work_date.year) + day = ClosureWorkDay.query.filter_by(closure_id=closure.id, work_date=work_date).first() + if not day: + day = ClosureWorkDay(closure_id=closure.id, work_date=work_date) + db.session.add(day) + day.start_time, day.end_time = _time(request.form.get("start_time")), _time(request.form.get("end_time")) + day.lunch_start, day.lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end")) + day.notes = request.form.get("notes") or "Permanence" + closure.work_hours_type = "custom" + db.session.commit() + flash("Permanence enregistrée.", "success") + return _redirect(work_date.year) + + +@planning_bp.route("/time/permanence//delete", methods=["POST"]) +@login_required +def delete_permanence(id): + day = ClosureWorkDay.query.get_or_404(id) + year = day.work_date.year + db.session.delete(day) + db.session.commit() + flash("Permanence supprimée.", "success") + return _redirect(year) diff --git a/app_new/templates/base.html b/app_new/templates/base.html index 86f5070..fd55660 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -188,6 +188,7 @@
  • Compteurs
  • Consommables
  • Disponibilités
  • +
  • Horaires et suivi annuel
  • Tâches administratives
  • Règles d'accès
  • diff --git a/migrations/versions/f8b2c3d4e5f6_add_annual_time_tracking.py b/migrations/versions/f8b2c3d4e5f6_add_annual_time_tracking.py new file mode 100644 index 0000000..5a09b0a --- /dev/null +++ b/migrations/versions/f8b2c3d4e5f6_add_annual_time_tracking.py @@ -0,0 +1,65 @@ +"""Ajoute les profils horaires et le suivi annuel du temps.""" +from alembic import op +import sqlalchemy as sa + +revision = "f8b2c3d4e5f6" +down_revision = ("a4b8c2d6e0f1", "f1a5b6c7d8e9", "f7a1b2c3d4e5") +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "work_schedule_templates", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("start_time", sa.Time(), nullable=False), + sa.Column("end_time", sa.Time(), nullable=False), + sa.Column("lunch_start", sa.Time()), + sa.Column("lunch_end", sa.Time()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.create_index("ix_work_schedule_templates_user_id", "work_schedule_templates", ["user_id"]) + op.add_column("work_schedules", sa.Column("template_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_work_schedules_template_id", "work_schedules", "work_schedule_templates", ["template_id"], ["id"]) + + op.create_table( + "annual_time_configs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("target_minutes", sa.Integer(), nullable=False, server_default="96420"), + sa.Column("hardship_deduction_minutes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("notes", sa.Text()), + sa.UniqueConstraint("user_id", "year", name="uq_annual_time_user_year"), + ) + op.create_index("ix_annual_time_configs_user_id", "annual_time_configs", ["user_id"]) + + op.create_table( + "work_time_entries", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("work_date", sa.Date(), nullable=False), + sa.Column("planned_minutes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("actual_minutes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("entry_type", sa.String(30), nullable=False, server_default="travail"), + sa.Column("note", sa.Text()), + sa.Column("created_at", sa.DateTime()), + sa.Column("updated_at", sa.DateTime()), + sa.UniqueConstraint("user_id", "work_date", name="uq_work_time_user_date"), + ) + op.create_index("ix_work_time_entries_user_id", "work_time_entries", ["user_id"]) + op.create_index("ix_work_time_entries_work_date", "work_time_entries", ["work_date"]) + + +def downgrade(): + op.drop_index("ix_work_time_entries_work_date", table_name="work_time_entries") + op.drop_index("ix_work_time_entries_user_id", table_name="work_time_entries") + op.drop_table("work_time_entries") + op.drop_index("ix_annual_time_configs_user_id", table_name="annual_time_configs") + op.drop_table("annual_time_configs") + op.drop_constraint("fk_work_schedules_template_id", "work_schedules", type_="foreignkey") + op.drop_column("work_schedules", "template_id") + op.drop_index("ix_work_schedule_templates_user_id", table_name="work_schedule_templates") + op.drop_table("work_schedule_templates")