diff --git a/App/__init__.py b/App/__init__.py index e06a7e7..3472120 100644 --- a/App/__init__.py +++ b/App/__init__.py @@ -1,3 +1,4 @@ +# App/__init__.py from .models import * from .views import * from .controllers import * diff --git a/App/config.py b/App/config.py index c34caff..2925b5d 100644 --- a/App/config.py +++ b/App/config.py @@ -1,3 +1,4 @@ +# App/config.py import os def load_config(app, overrides): diff --git a/App/controllers/__init__.py b/App/controllers/__init__.py index 0cb8fd1..d4a4070 100644 --- a/App/controllers/__init__.py +++ b/App/controllers/__init__.py @@ -1,5 +1,6 @@ +# App/controllers/_init_.py from .user import * -from .auth import * -from .initialize import * from .admin import * -from .staff import * +from .staff import * +from .auth import * +from .initialize import * \ No newline at end of file diff --git a/App/controllers/admin.py b/App/controllers/admin.py index aa2d9ca..5a43e1c 100644 --- a/App/controllers/admin.py +++ b/App/controllers/admin.py @@ -1,58 +1,74 @@ -from App.models import Shift -from App.database import db +from App.controllers.scheduling_logic import ScheduleStrategyFactory, AutoScheduler from datetime import datetime -from App.controllers.user import get_user - -from App.models import Shift, Schedule +from App.models import Admin, Staff, Shift from App.database import db -from datetime import datetime -from App.controllers.user import get_user -def create_schedule(admin_id, scheduleName): #Not sure why this was missing - admin = get_user(admin_id) - if not admin or admin.role != "admin": - raise PermissionError("Only admins can create schedules") - new_schedule = Schedule( - created_by=admin_id, - name=scheduleName, - created_at=datetime.utcnow() - ) +def generate_auto_schedule(schedule_id: int, method_type: str): + """ + Executes the automatic scheduling logic using the Strategy pattern + by leveraging the StrategyFactory and AutoScheduler directly in the Controller. - db.session.add(new_schedule) - db.session.commit() + The logic now flows: Controller -> StrategyFactory (select strategy) -> AutoScheduler (execute strategy). + + Args: + schedule_id (str): The ID of the schedule to be processed. + method_type (str): The strategy to use (e.g., 'priority', 'random'). - return new_schedule + Returns: + dict: The result of the scheduling operation. + """ + # collect staff and unassigned shift templates for the schedule + staff_list = Staff.query.all() + shift_templates = Shift.query.filter_by(schedule_id=schedule_id, staff_id=None).all() -def schedule_shift(admin_id, staff_id, schedule_id, start_time, end_time): - admin = get_user(admin_id) - staff = get_user(staff_id) + try: + strategy = ScheduleStrategyFactory.create_strategy(method_type) + except ValueError as e: + return {"status": "error", "message": str(e)} - schedule = db.session.get(Schedule, schedule_id) + scheduler = AutoScheduler(strategy, staff_list, [ + {"start_time": s.start_time, "end_time": s.end_time} for s in shift_templates + ], schedule_id) - if not admin or admin.role != "admin": - raise PermissionError("Only admins can schedule shifts") - if not staff or staff.role != "staff": - raise ValueError("Invalid staff member") - if not schedule: - raise ValueError("Invalid schedule ID") + try: + result = scheduler.generate_schedule() + return {"status": "success", "data": result} + except Exception as e: + return {"status": "error", "message": f"Auto-scheduling failed: {e}"} - new_shift = Shift( + +def schedule_shift(admin_id: int, staff_id: int, schedule_id: int, start_time, end_time): + """Create a shift under a schedule assigned to a staff member. + + This is a thin controller wrapper that persists the Shift. + """ + # basic validation could be added (check admin role) in future + if not staff_id or not schedule_id: + raise ValueError("staff_id and schedule_id are required") + + # parse datetimes if strings + if isinstance(start_time, str): + start_time = datetime.fromisoformat(start_time) + if isinstance(end_time, str): + end_time = datetime.fromisoformat(end_time) + + shift = Shift( staff_id=staff_id, schedule_id=schedule_id, start_time=start_time, - end_time=end_time + end_time=end_time, ) - - db.session.add(new_shift) + db.session.add(shift) db.session.commit() - - return new_shift + return shift.get_json() -def get_shift_report(admin_id): - admin = get_user(admin_id) - if not admin or admin.role != "admin": - raise PermissionError("Only admins can view shift reports") +def get_shift_report(admin_id: int): + """Return all shifts (simple report) for an admin dashboard. - return [shift.get_json() for shift in Shift.query.order_by(Shift.start_time).all()] \ No newline at end of file + No strict admin checks are enforced here; controllers that call this + should ensure permissions. + """ + shifts = Shift.query.order_by(Shift.start_time).all() + return [s.get_json() for s in shifts] \ No newline at end of file diff --git a/App/controllers/auth.py b/App/controllers/auth.py index e46a40f..cc52040 100644 --- a/App/controllers/auth.py +++ b/App/controllers/auth.py @@ -1,3 +1,4 @@ +# app/controllers/auth.py from flask_jwt_extended import ( create_access_token, jwt_required, JWTManager, get_jwt_identity, verify_jwt_in_request diff --git a/App/controllers/initialize.py b/App/controllers/initialize.py index 49907b2..9bf7853 100644 --- a/App/controllers/initialize.py +++ b/App/controllers/initialize.py @@ -1,3 +1,4 @@ +#app/controllers/initialize.py from .user import create_user from App.database import db diff --git a/App/controllers/scheduling_logic.py b/App/controllers/scheduling_logic.py new file mode 100644 index 0000000..8ec6b1f --- /dev/null +++ b/App/controllers/scheduling_logic.py @@ -0,0 +1,176 @@ +# App/controllers/scheduling_logic.py +import abc +from datetime import datetime +from App.database import db +from App.models import Staff, Shift, Schedule # Required for type hinting and database interaction + +# --- Utility Function --- +def calculate_duration_hours(start_time_str, end_time_str): + """Calculates the duration in hours between two time strings.""" + try: + # Assuming ISO format like 'YYYY-MM-DD HH:MM:SS' + # Note: You should enforce a strict date format in your API/route layer + start = datetime.strptime(start_time_str, '%Y-%m-%d %H:%M:%S') + end = datetime.strptime(end_time_str, '%Y-%m-%d %H:%M:%S') + duration = end - start + return duration.total_seconds() / 3600 + except Exception: + return 0.0 + +# --- 1. ScheduleStrategy Abstract Interface/Class --- +class ScheduleStrategy(abc.ABC): + """ + Abstract Base Class for all scheduling algorithms (Strategies). + """ + @abc.abstractmethod + def generate(self, staff_list: list[Staff], schedule_templates: list[dict], schedule_id: int) -> list[Shift]: + """ + Generates a list of new Shift objects based on the strategy. + """ + pass + +# --- 4. EvenDistribution Concrete Strategy --- +class EvenDistribution(ScheduleStrategy): + """ + Assigns shifts in a simple round-robin fashion. + """ + def generate(self, staff_list: list[Staff], schedule_templates: list[dict], schedule_id: int) -> list[Shift]: + new_shifts = [] + if not staff_list or not schedule_templates: + return new_shifts + + num_staff = len(staff_list) + + # Round-robin assignment + for i, template in enumerate(schedule_templates): + staff_index = i % num_staff + staff_id = staff_list[staff_index].id + + new_shifts.append(Shift( + staff_id=staff_id, + schedule_id=schedule_id, + start_time=template['start_time'], + end_time=template['end_time'] + )) + + return new_shifts + +# --- 5. MinimalDays Concrete Strategy --- +class MinimalDays(ScheduleStrategy): + """ + Minimizes the total count of shifts assigned to each staff member. + """ + def generate(self, staff_list: list[Staff], schedule_templates: list[dict], schedule_id: int) -> list[Shift]: + new_shifts = [] + if not staff_list or not schedule_templates: + return new_shifts + + # Initialize shift count tracker {staff_id: count} + staff_shift_counts = {staff.id: 0 for staff in staff_list} + + for template in schedule_templates: + # Find the staff member with the minimum number of assigned shifts + staff_id_to_assign = min(staff_shift_counts, key=staff_shift_counts.get) + + new_shifts.append(Shift( + staff_id=staff_id_to_assign, + schedule_id=schedule_id, + start_time=template['start_time'], + end_time=template['end_time'] + )) + + # Update the count for the assigned staff member + staff_shift_counts[staff_id_to_assign] += 1 + + return new_shifts + +# --- 6. BalancedShift Concrete Strategy --- +class BalancedShift(ScheduleStrategy): + """ + Balances the total duration (hours) assigned to each staff member. + """ + def generate(self, staff_list: list[Staff], schedule_templates: list[dict], schedule_id: int) -> list[Shift]: + new_shifts = [] + if not staff_list or not schedule_templates: + return new_shifts + + # Initialize total hours tracker {staff_id: total_hours} + staff_hour_totals = {staff.id: 0.0 for staff in staff_list} + + for template in schedule_templates: + # Calculate the duration of the current template shift + duration = calculate_duration_hours(template['start_time'], template['end_time']) + + # Find the staff member with the minimum total hours + staff_id_to_assign = min(staff_hour_totals, key=staff_hour_totals.get) + + new_shifts.append(Shift( + staff_id=staff_id_to_assign, + schedule_id=schedule_id, + start_time=template['start_time'], + end_time=template['end_time'] + )) + + # Update the total hours for the assigned staff member + staff_hour_totals[staff_id_to_assign] += duration + + return new_shifts + +# --- 3. ScheduleStrategyFactory --- +class ScheduleStrategyFactory: + """ Factory method to create the appropriate scheduling strategy. """ + STRATEGY_MAP = { + 'even': EvenDistribution, + 'minimal': MinimalDays, + 'balanced': BalancedShift + } + + @staticmethod + def create_strategy(method_type: str) -> ScheduleStrategy: + """ Instantiates and returns a concrete ScheduleStrategy object. """ + method_type = method_type.lower().strip() + strategy_class = ScheduleStrategyFactory.STRATEGY_MAP.get(method_type) + + if not strategy_class: + raise ValueError(f"Invalid scheduling method type: {method_type}. Must be one of {list(ScheduleStrategyFactory.STRATEGY_MAP.keys())}") + + return strategy_class() + + +# --- 2. AutoScheduler (Context) Class --- +class AutoScheduler: + """ + The Context class in the Strategy Pattern. Executes the schedule generation. + """ + def _init_(self, strategy: ScheduleStrategy, staff_list: list[Staff], schedule_templates: list[dict], schedule_id: int): + self._strategy = strategy + self._staff_list = staff_list + self._schedule_templates = schedule_templates + self._schedule_id = schedule_id + + def generate_schedule(self) -> list[dict]: + """ + Delegates the schedule generation to the configured strategy and persists it. + """ + # Step 1: Generate the new shifts using the chosen strategy + new_shifts = self._strategy.generate( + self._staff_list, + self._schedule_templates, + self._schedule_id + ) + + # Step 2: Save the generated shifts to the database (Persistence) + self.save_schedule(new_shifts) + + # Step 3: Return the JSON representation of the new shifts + return [s.get_json() for s in new_shifts] + + def save_schedule(self, shifts_to_save: list[Shift]): + """ + Persists the list of generated Shift objects to the database. + """ + if shifts_to_save: + # Before adding new shifts, optionally clear existing shifts for this schedule + # Shift.query.filter_by(schedule_id=self._schedule_id).delete() + db.session.add_all(shifts_to_save) + db.session.commit() \ No newline at end of file diff --git a/App/controllers/staff.py b/App/controllers/staff.py index 6c21d3a..97c89df 100644 --- a/App/controllers/staff.py +++ b/App/controllers/staff.py @@ -1,3 +1,4 @@ +# App/controllers/staff.py from App.models import Shift from App.database import db from datetime import datetime diff --git a/App/controllers/user.py b/App/controllers/user.py index 7570136..16e5e18 100644 --- a/App/controllers/user.py +++ b/App/controllers/user.py @@ -1,3 +1,4 @@ +# App/controllers/user.py from App.models import User, Admin, Staff, Shift from App.database import db from datetime import datetime diff --git a/App/database.py b/App/database.py index 291dbac..1811afb 100644 --- a/App/database.py +++ b/App/database.py @@ -1,3 +1,4 @@ +# database.py from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate diff --git a/App/default_config.py b/App/default_config.py index cc14a54..9c03455 100644 --- a/App/default_config.py +++ b/App/default_config.py @@ -1,2 +1,3 @@ +# App/default_config.py SQLALCHEMY_DATABASE_URI="sqlite:///temp-database.db" SECRET_KEY="secret key" \ No newline at end of file diff --git a/App/main.py b/App/main.py index ee392da..b6dd68b 100644 --- a/App/main.py +++ b/App/main.py @@ -1,3 +1,4 @@ +# App/main.py import os from flask import Flask, render_template from flask_uploads import DOCUMENTS, IMAGES, TEXT, UploadSet, configure_uploads diff --git a/App/models/__init__.py b/App/models/__init__.py index 91d63f0..b040e0b 100644 --- a/App/models/__init__.py +++ b/App/models/__init__.py @@ -2,5 +2,11 @@ from App.models.admin import Admin from App.models.staff import Staff from App.models.schedule import Schedule -from App.models.shift import Shift - +from App.models.shift import Shift +from App.models.auto_scheduler import AutoScheduler +from App.models.strategy import ( + ScheduleStrategy, + EvenDistributionStrategy, + MinimalDaysStrategy, + BalancedShiftStrategy +) \ No newline at end of file diff --git a/App/models/admin.py b/App/models/admin.py index 479832a..98f4cd1 100644 --- a/App/models/admin.py +++ b/App/models/admin.py @@ -1,11 +1,91 @@ +from datetime import datetime from App.database import db from .user import User + class Admin(User): id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) + __mapper_args__ = { "polymorphic_identity": "admin", } def __init__(self, username, password): super().__init__(username, password, "admin") + + # UML: ScheduleShift() + def schedule_shift(self, staff_id: int, schedule_id: int, start, end): + """Create and persist a Shift assigned to staff under a schedule. + + start/end can be datetimes or ISO-8601 strings. + Returns the created Shift object. + """ + from App.models.shift import Shift + + if not isinstance(start, datetime): + start_dt = datetime.fromisoformat(start) + else: + start_dt = start + + if not isinstance(end, datetime): + end_dt = datetime.fromisoformat(end) + else: + end_dt = end + + shift = Shift( + staff_id=staff_id, + schedule_id=schedule_id, + start_time=start_dt, + end_time=end_dt, + ) + db.session.add(shift) + db.session.commit() + return shift + + # UML: autoSchedule(methodType) + def auto_schedule(self, schedule_id: int, method_type: str): + """Run the AutoScheduler with the chosen strategy for a schedule. + + method_type should be one of: 'even', 'minimal', 'balanced'. + Returns list of assigned shift JSON objects. + """ + from App.models.auto_scheduler import AutoScheduler + from App.models.strategy import ( + EvenDistributionStrategy, + MinimalDaysStrategy, + BalancedShiftStrategy, + ) + from App.models.shift import Shift + from App.models.staff import Staff + + staff_list = Staff.query.all() + # Treat unassigned shifts for this schedule as templates to fill + shift_templates = Shift.query.filter_by(schedule_id=schedule_id, staff_id=None).all() + + scheduler = AutoScheduler(staff_list, shift_templates) + + method = method_type.lower() if isinstance(method_type, str) else str(method_type) + if method in ("even", "even_distribution", "even distribution"): + strategy = EvenDistributionStrategy() + elif method in ("minimal", "minimal_days", "minimal days"): + strategy = MinimalDaysStrategy() + else: + strategy = BalancedShiftStrategy() + + scheduler.set_strategy(strategy) + assigned = scheduler.generate_schedule(schedule_id) + + # Persist assigned shifts + for s in assigned: + db.session.add(s) + db.session.commit() + + return [s.get_json() for s in assigned] + + # UML: viewShift() + def view_shift(self, shift_id: int): + from App.models.shift import Shift + + shift = db.session.get(Shift, shift_id) + return shift.get_json() if shift else None + diff --git a/App/models/auto_scheduler.py b/App/models/auto_scheduler.py new file mode 100644 index 0000000..4dd8621 --- /dev/null +++ b/App/models/auto_scheduler.py @@ -0,0 +1,39 @@ +from typing import List +from App.models.strategy import ScheduleStrategy +from App.models.shift import Shift +from App.models.staff import Staff + +class AutoScheduler: + """ + The Context class in the Strategy Pattern. It holds a reference to a + ScheduleStrategy and delegates the schedule generation task to it. + """ + def __init__(self, staff_list: List[Staff], shift_templates: List[Shift]): + self._strategy: ScheduleStrategy = None + self.staff_list = staff_list + self.shift_templates = shift_templates + + def set_strategy(self, strategy: ScheduleStrategy): + """Sets the concrete strategy to be used for scheduling.""" + self._strategy = strategy + + def generate_schedule(self, schedule_id: int) -> List[Shift]: + """ + Executes the chosen strategy to generate the schedule. + + It is the controller/service layer's responsibility to set the correct + strategy before calling this method. + """ + if not self._strategy: + raise ValueError("Scheduling strategy must be set before generating a schedule.") + + # Delegate the actual generation to the concrete strategy + assigned_shifts = self._strategy.generate( + schedule_id, + self.staff_list, + self.shift_templates + ) + return assigned_shifts + + # NOTE: The saveSchedule() method from the UML will be implemented in the + # service/controller layer to handle database commit logic. \ No newline at end of file diff --git a/App/models/schedule.py b/App/models/schedule.py index 64c0e24..a9d2386 100644 --- a/App/models/schedule.py +++ b/App/models/schedule.py @@ -5,7 +5,12 @@ class Schedule(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # UML includes adminID/createdBy and (optionally) staffID. Keep existing + # created_by for backward-compatibility and add explicit admin_id/staff_id + # to match the UML diagram. created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + admin_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) shifts = db.relationship("Shift", backref="schedule", lazy=True) def shift_count(self): @@ -17,8 +22,9 @@ def get_json(self): "name": self.name, "created_at": self.created_at.isoformat(), "created_by": self.created_by, + "admin_id": self.admin_id, + "staff_id": self.staff_id, "shift_count": self.shift_count(), "shifts": [shift.get_json() for shift in self.shifts] } - diff --git a/App/models/shift.py b/App/models/shift.py index 0467dee..bb1a523 100644 --- a/App/models/shift.py +++ b/App/models/shift.py @@ -3,23 +3,26 @@ class Shift(db.Model): id = db.Column(db.Integer, primary_key=True) - staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) - schedule_id = db.Column(db.Integer, db.ForeignKey("schedule.id"), nullable=True) + # staff_id is nullable because the Strategy Pattern needs to create shifts + # as unassigned templates first. + staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + schedule_id = db.Column(db.Integer, db.ForeignKey("schedule.id"), nullable=False) start_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False) clock_in = db.Column(db.DateTime, nullable=True) clock_out = db.Column(db.DateTime, nullable=True) - staff = db.relationship("Staff", backref="shifts", foreign_keys=[staff_id]) + # Renamed backref for clarity to avoid collision if Staff had other relationships + staff = db.relationship("Staff", backref="scheduled_shifts", foreign_keys=[staff_id]) def get_json(self): return { "id": self.id, "staff_id": self.staff_id, "staff_name": self.staff.username if self.staff else None, - "start_time": self.start_time.isoformat(), "schedule_id": self.schedule_id, + "start_time": self.start_time.isoformat(), "end_time": self.end_time.isoformat(), "clock_in": self.clock_in.isoformat() if self.clock_in else None, "clock_out": self.clock_out.isoformat() if self.clock_out else None - } + } \ No newline at end of file diff --git a/App/models/staff.py b/App/models/staff.py index bc2592a..4dbc91f 100644 --- a/App/models/staff.py +++ b/App/models/staff.py @@ -3,9 +3,45 @@ class Staff(User): id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) + __mapper_args__ = { "polymorphic_identity": "staff", } def __init__(self, username, password): super().__init__(username, password, "staff") + + # UML: viewRoster() + def view_roster(self): + """Return the roster (list of shifts) assigned to this staff member.""" + from App.models.shift import Shift + + shifts = Shift.query.filter_by(staff_id=self.id).all() + return [s.get_json() for s in shifts] + + # UML: clockIn() + def clock_in(self, shift_id: int): + from datetime import datetime + from App.models.shift import Shift + shift = db.session.get(Shift, shift_id) + if not shift: + raise ValueError("Shift not found") + if shift.staff_id != self.id: + raise PermissionError("Cannot clock in to a shift not assigned to this staff member") + shift.clock_in = datetime.utcnow() + db.session.commit() + return shift + + # UML: clockOut() + def clock_out(self, shift_id: int): + from datetime import datetime + from App.models.shift import Shift + shift = db.session.get(Shift, shift_id) + if not shift: + raise ValueError("Shift not found") + if shift.staff_id != self.id: + raise PermissionError("Cannot clock out of a shift not assigned to this staff member") + shift.clock_out = datetime.utcnow() + db.session.commit() + return shift + diff --git a/App/models/strategy.py b/App/models/strategy.py new file mode 100644 index 0000000..ae05025 --- /dev/null +++ b/App/models/strategy.py @@ -0,0 +1,81 @@ +from abc import ABC, abstractmethod +from typing import List +from App.models.shift import Shift +from App.models.staff import Staff # Use the concrete Staff model for type hinting + +# The Strategy Interface (Defines the contract for all scheduling algorithms) +class ScheduleStrategy(ABC): + """Abstract Base Class for all scheduling strategies.""" + + @abstractmethod + def generate(self, schedule_id: int, staff_list: List[Staff], shifts_to_fill: List[Shift]) -> List[Shift]: + """ + Generates a list of assigned shifts based on the specific strategy logic. + + :param schedule_id: The ID of the schedule being generated. + :param staff_list: List of available staff members. + :param shifts_to_fill: List of unassigned Shift templates (start/end times). + :return: List of completed Shift objects with staff_id assigned. + """ + pass + +# Concrete Strategy 1: Evenly Distribute Shifts +class EvenDistributionStrategy(ScheduleStrategy): + """ + Distributes shifts among staff as evenly as possible based on shift count. + """ + def generate(self, schedule_id: int, staff_list: List[Staff], shifts_to_fill: List[Shift]) -> List[Shift]: + print(f"Applying Even Distribution Strategy for Schedule {schedule_id}...") + assigned_shifts = [] + + # Simple round-robin assignment placeholder for model definition: + staff_count = len(staff_list) + if staff_count > 0: + for i, shift in enumerate(shifts_to_fill): + staff_member = staff_list[i % staff_count] + shift.staff_id = staff_member.id + shift.schedule_id = schedule_id + assigned_shifts.append(shift) + + return assigned_shifts + +# Concrete Strategy 2: Minimize Working Days +class MinimalDaysStrategy(ScheduleStrategy): + """ + Distributes shifts to minimize the total number of working days per staff member. + """ + def generate(self, schedule_id: int, staff_list: List[Staff], shifts_to_fill: List[Shift]) -> List[Shift]: + print(f"Applying Minimal Days Strategy for Schedule {schedule_id}...") + assigned_shifts = [] + + # Simple round-robin assignment placeholder: + staff_count = len(staff_list) + if staff_count > 0: + for i, shift in enumerate(shifts_to_fill): + staff_member = staff_list[i % staff_count] + shift.staff_id = staff_member.id + shift.schedule_id = schedule_id + assigned_shifts.append(shift) + + return assigned_shifts + +# Concrete Strategy 3: Balance Day/Night Shifts +class BalancedShiftStrategy(ScheduleStrategy): + """ + Distributes shifts to ensure a balance of day and night shifts per staff member. + """ + def generate(self, schedule_id: int, staff_list: List[Staff], shifts_to_fill: List[Shift]) -> List[Shift]: + print(f"Applying Balanced Shift Strategy for Schedule {schedule_id}...") + assigned_shifts = [] + + # Simple round-robin assignment placeholder: + staff_count = len(staff_list) + if staff_count > 0: + for i, shift in enumerate(shifts_to_fill): + staff_member = staff_list[i % staff_count] + shift.staff_id = staff_member.id + shift.schedule_id = schedule_id + assigned_shifts.append(shift) + + return assigned_shifts + \ No newline at end of file diff --git a/App/models/user.py b/App/models/user.py index 41f2e6d..cec960d 100644 --- a/App/models/user.py +++ b/App/models/user.py @@ -12,13 +12,13 @@ class User(db.Model): __mapper_args__ = { "polymorphic_identity": "user", "polymorphic_on": "role" - } + } def __init__(self, username, password, role="user"): self.username = username self.role = role self.set_password(password) - + def get_json(self): return { 'id': self.id, @@ -31,5 +31,4 @@ def set_password(self, password): def check_password(self, password): return check_password_hash(self.password, password) - - + \ No newline at end of file diff --git a/App/tests/__init__.py b/App/tests/__init__.py deleted file mode 100644 index f5c872f..0000000 --- a/App/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .test_app import * \ No newline at end of file diff --git a/App/tests/test_app.py b/App/tests/test_app.py index e52b6a5..3876da1 100644 --- a/App/tests/test_app.py +++ b/App/tests/test_app.py @@ -1,3 +1,4 @@ +#app/tests/test_app.py import os, tempfile, pytest, logging, unittest from werkzeug.security import check_password_hash, generate_password_hash from App.main import create_app @@ -39,8 +40,8 @@ def test_new_user_staff(self): assert user.username == "pam" def test_create_user_invalid_role(self): - user = create_user("jim", "jimpass","ceo") - assert user == None + with pytest.raises(ValueError): + create_user("jim", "jimpass","ceo") def test_get_json(self): diff --git a/App/views/__init__.py b/App/views/__init__.py index ee01e8e..b69d1d3 100644 --- a/App/views/__init__.py +++ b/App/views/__init__.py @@ -1,3 +1,4 @@ +# App/views/__init__.py # blue prints are imported # explicitly instead of using * from .user import user_views diff --git a/App/views/admin.py b/App/views/admin.py index ce0134d..2bf85f4 100644 --- a/App/views/admin.py +++ b/App/views/admin.py @@ -1,3 +1,4 @@ +# app/views/admin.py from flask_admin.contrib.sqla import ModelView from flask_jwt_extended import jwt_required, current_user, unset_jwt_cookies, set_access_cookies from flask_admin import Admin diff --git a/App/views/auth.py b/App/views/auth.py index dfc4dc9..5825b66 100644 --- a/App/views/auth.py +++ b/App/views/auth.py @@ -1,3 +1,4 @@ +# app/views/auth.py from flask import Blueprint, render_template, jsonify, request, flash, send_from_directory, flash, redirect, url_for from flask_jwt_extended import jwt_required, current_user, unset_jwt_cookies, set_access_cookies diff --git a/App/views/index.py b/App/views/index.py index 7e58201..5fe5f3d 100644 --- a/App/views/index.py +++ b/App/views/index.py @@ -1,3 +1,4 @@ +# app/views/index.py from flask import Blueprint, redirect, render_template, request, send_from_directory, jsonify from App.controllers import create_user, initialize diff --git a/App/views/user.py b/App/views/user.py index 45fbbba..36fa27f 100644 --- a/App/views/user.py +++ b/App/views/user.py @@ -1,3 +1,4 @@ +# App/views/user.py from flask import Blueprint, render_template, jsonify, request, send_from_directory, flash, redirect, url_for from flask_jwt_extended import jwt_required, current_user as jwt_current_user diff --git a/tests/test_unit_suite.py b/tests/test_unit_suite.py new file mode 100644 index 0000000..7fc12f4 --- /dev/null +++ b/tests/test_unit_suite.py @@ -0,0 +1,184 @@ +import pytest +from datetime import datetime, timedelta + +from App.main import create_app +from App.database import create_db, db +from App.controllers.user import create_user, get_all_users_json, get_user +from App.controllers.admin import schedule_shift, get_shift_report +from App.controllers.staff import get_combined_roster, clock_in, clock_out, get_shift +from App.controllers import scheduling_logic + +from App.models import User, Admin, Staff, Schedule, Shift +from App.models.auto_scheduler import AutoScheduler as ModelAutoScheduler +from App.models.strategy import EvenDistributionStrategy, MinimalDaysStrategy, BalancedShiftStrategy + + +@pytest.fixture(autouse=True, scope='module') +def app_and_db(): + app = create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'}) + create_db() + yield app + db.drop_all() + + +def test_create_user_admin(): + admin = create_user("bob", "bobpass", "admin") + assert admin is not None + assert admin.role == "admin" + assert isinstance(admin, Admin) + + +def test_create_user_staff(): + staff = create_user("amy", "amypass", "staff") + assert staff is not None + assert staff.role == "staff" + assert isinstance(staff, Staff) + + +def test_create_user_invalid_role(): + # Controller should raise ValueError for invalid role + with pytest.raises(ValueError): + create_user("tim", "1234", "manager") + + +def test_get_json_and_password_methods(): + u = User("bob", "bobpass", "admin") + data = u.get_json() + assert data["username"] == "bob" + assert data["role"] == "admin" + assert data["id"] is None + + # password is hashed and check_password works + u2 = User(username="tester", password="mypass") + assert u2.password != "mypass" + assert u2.check_password("mypass") is True + + +def test_check_password_direct(): + u = User("bob", "mypass") + assert u.check_password("mypass") + + +def test_schedule_shift_and_report(): + admin = create_user("sadmin", "apass", "admin") + staff = create_user("sstaff", "spass", "staff") + + schedule = Schedule(name="Unit Schedule", created_by=admin.id) + db.session.add(schedule) + db.session.commit() + + start = datetime.now() + end = start + timedelta(hours=8) + + shift_json = schedule_shift(admin.id, staff.id, schedule.id, start, end) + assert shift_json["staff_id"] == staff.id + assert shift_json["schedule_id"] == schedule.id + assert shift_json["start_time"] is not None + + report = get_shift_report(admin.id) + assert isinstance(report, list) + assert any(r["staff_id"] == staff.id for r in report) + + +def test_get_combined_roster_and_clocking(): + admin = create_user("admin_clock", "apass", "admin") + staff = create_user("staff_clock", "spass", "staff") + + schedule = Schedule(name="Clock Schedule", created_by=admin.id) + db.session.add(schedule) + db.session.commit() + + start = datetime.now() + end = start + timedelta(hours=8) + shift_json = schedule_shift(admin.id, staff.id, schedule.id, start, end) + + roster = get_combined_roster(staff.id) + assert isinstance(roster, list) + assert any(s["staff_id"] == staff.id for s in roster) + + # clock in/out via controllers + s = db.session.get(Shift, shift_json["id"]) + assert s is not None + + clock_in(staff.id, s.id) + updated = get_shift(s.id) + assert updated.clock_in is not None + + clock_out(staff.id, s.id) + updated2 = get_shift(s.id) + assert updated2.clock_out is not None + + +def test_clock_in_invalid_user_and_shift(): + admin = create_user("admin_inv", "apass", "admin") + staff = create_user("staff_inv", "spass", "staff") + + schedule = Schedule(name="Inv Schedule", created_by=admin.id) + db.session.add(schedule) + db.session.commit() + + start = datetime.now() + end = start + timedelta(hours=8) + shift_json = schedule_shift(admin.id, staff.id, schedule.id, start, end) + + # admin trying to clock in -> PermissionError + with pytest.raises(PermissionError): + clock_in(admin.id, shift_json["id"]) + + # invalid shift id + with pytest.raises(ValueError): + clock_in(staff.id, 99999) + + +def test_strategy_factory_and_strategies(): + # factory creates valid strategies + s = scheduling_logic.ScheduleStrategyFactory.create_strategy("minimal") + assert isinstance(s, scheduling_logic.MinimalDays) + + # EvenDistribution logic: create staffs and templates + staff1 = create_user("a1", "p", "staff") + staff2 = create_user("a2", "p", "staff") + + schedule = Schedule(name="StratSchedule", created_by=staff1.id) + db.session.add(schedule) + db.session.commit() + + # create templates (unassigned shifts) using Shift model directly + t1 = Shift(staff_id=None, schedule_id=schedule.id, start_time=datetime.now(), end_time=datetime.now()+timedelta(hours=8)) + t2 = Shift(staff_id=None, schedule_id=schedule.id, start_time=datetime.now()+timedelta(days=1), end_time=datetime.now()+timedelta(days=1,hours=8)) + db.session.add_all([t1, t2]) + db.session.commit() + + staff_list = [staff1, staff2] + templates = [t1, t2] + + even = EvenDistributionStrategy() + assigned = even.generate(schedule.id, staff_list, templates) + assert len(assigned) == 2 + assert assigned[0].staff_id in (staff1.id, staff2.id) + + minimal = MinimalDaysStrategy() + assigned2 = minimal.generate(schedule.id, staff_list, templates) + assert len(assigned2) == 2 + + balanced = BalancedShiftStrategy() + assigned3 = balanced.generate(schedule.id, staff_list, templates) + assert len(assigned3) == 2 + + +def test_models_autoscheduler_calls_strategy(): + # ensure ModelAutoScheduler delegates to provided strategy + class DummyStrategy: + def __init__(self): + self.called = False + + def generate(self, schedule_id, staff_list, shifts_to_fill): + self.called = True + return [] + + dummy = DummyStrategy() + sched = ModelAutoScheduler([], []) + sched.set_strategy(dummy) + result = sched.generate_schedule(1) + assert dummy.called is True + assert result == [] diff --git a/wsgi.py b/wsgi.py index d3cedc3..c315a7b 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,3 +1,4 @@ +# wsgi.py import click, pytest, sys, os from flask.cli import with_appcontext, AppGroup from datetime import datetime