From 1275d5e24f14f8f6afba785bc4ec648685584cdc Mon Sep 17 00:00:00 2001 From: Erkan Ozgur Yilmaz Date: Tue, 10 Dec 2024 16:57:30 +0300 Subject: [PATCH 1/3] [#87] Added `ScheduleConstraint` enum. --- src/stalker/db/types.py | 5 +- src/stalker/models/mixins.py | 142 ++++++++++++++++------ src/stalker/models/task.py | 147 +++++++++++------------ tests/db/test_db.py | 11 +- tests/mixins/test_scheduleMixin.py | 46 ++++--- tests/models/test_schedule_constraint.py | 132 ++++++++++++++++++++ tests/models/test_task.py | 9 +- 7 files changed, 354 insertions(+), 138 deletions(-) create mode 100644 tests/models/test_schedule_constraint.py diff --git a/src/stalker/db/types.py b/src/stalker/db/types.py index 581c0a4c..76dd4d71 100644 --- a/src/stalker/db/types.py +++ b/src/stalker/db/types.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Stalker specific data types are situated here.""" +import datetime import json from typing import Any, Dict, TYPE_CHECKING, Union @@ -65,7 +66,7 @@ class DateTimeUTC(TypeDecorator): impl = DateTime - def process_bind_param(self, value, dialect): + def process_bind_param(self, value: Any, dialect: str) -> datetime.datetime: """Process bind param. Args: @@ -81,7 +82,7 @@ def process_bind_param(self, value, dialect): value = value.astimezone(pytz.utc) return value - def process_result_value(self, value, dialect): + def process_result_value(self, value: Any, dialect: str) -> datetime.datetime: """Process result value. Args: diff --git a/src/stalker/models/mixins.py b/src/stalker/models/mixins.py index 01ba423b..dd1b2b93 100644 --- a/src/stalker/models/mixins.py +++ b/src/stalker/models/mixins.py @@ -2,6 +2,7 @@ """Mixins are situated here.""" import datetime +from enum import IntEnum from typing import ( Any, Dict, @@ -17,7 +18,7 @@ import pytz -from sqlalchemy import Column, Enum, Float, ForeignKey, Integer, Interval, String, Table +from sqlalchemy import Column, Enum, Float, ForeignKey, Integer, Interval, String, Table, TypeDecorator from sqlalchemy.exc import OperationalError, UnboundExecutionError from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import ( @@ -173,6 +174,94 @@ def create_secondary_table( return secondary_table +class ScheduleConstraint(IntEnum): + """The schedule constraint enum.""" + NONE = 0 + Start = 1 + End = 2 + Both = 3 + + def __repr__(self) -> str: + """Return the enum name for str(). + + Returns: + str: The name as the string representation of this + ScheduleConstraint. + """ + return self.name if self.name != "NONE" else "None" + + __str__ = __repr__ + + @classmethod + def to_constraint(cls, constraint: Union[int, str, "ScheduleConstraint"]) -> Self: + """Validate and return type enum from an input int or str value. + + Args: + constraint (Union[str, ScheduleConstraint]): Input `constraint` value. + quiet (bool): To raise any exception for invalid value. + + Raises: + TypeError: Input value type is invalid. + ValueError: Input value is invalid. + + Returns: + ScheduleConstraint: ScheduleConstraint value. + """ + # Check if it's a valid str type for a constraint. + if constraint is None: + constraint = ScheduleConstraint.NONE + + if not isinstance(constraint, (int, str, ScheduleConstraint)): + raise TypeError( + "constraint should be an int, str or ScheduleConstraint, " + f"not {constraint.__class__.__name__}: '{constraint}'" + ) + + if isinstance(constraint, str): + constraint_name_lut = dict([(e.name.lower(), e.name.title() if e.name != "NONE" else "NONE") for e in cls]) + # also add int values + constraint_lower_case = constraint.lower() + if constraint_lower_case not in constraint_name_lut: + raise ValueError( + "constraint should be one of {}, not '{}'".format( + [e.name.title() for e in cls], constraint + ) + ) + + # Return the enum status for the status value. + return cls.__members__[constraint_name_lut[constraint_lower_case]] + else: + return ScheduleConstraint(constraint) + + +class ScheduleConstraintDecorator(TypeDecorator): + """Store ScheduleConstraint as an integer and restore as ScheduleConstraint.""" + + impl = Integer + + def process_bind_param(self, value, dialect) -> int: + """Return the integer value of the ScheduleConstraint. + + Args: + value (ScheduleConstraint): The ScheduleConstraint value. + dialect (str): The name of the dialect. + + Returns: + int: The value of the ScheduleConstraint. + """ + # just return the value + return value.value + + def process_result_value(self, value, dialect): + """Return a ScheduleConstraint. + + Args: + value (int): The integer value. + dialect (str): The name of the dialect. + """ + return ScheduleConstraint.to_constraint(value) + + class TargetEntityTypeMixin(object): """Adds target_entity_type attribute to mixed in class. @@ -1338,7 +1427,7 @@ def __init__( schedule_timing: Optional[float] = None, schedule_unit: Optional[str] = None, schedule_model: Optional[str] = None, - schedule_constraint: int = 0, + schedule_constraint: ScheduleConstraint = ScheduleConstraint.NONE, **kwargs: Dict[str, Any], ) -> None: self.schedule_constraint = schedule_constraint @@ -1440,7 +1529,7 @@ def schedule_model(cls) -> Mapped[str]: ) @declared_attr - def schedule_constraint(cls) -> Mapped[int]: + def schedule_constraint(cls) -> Mapped[ScheduleConstraint]: """Create the schedule_constraint attribute as a declared attribute. Returns: @@ -1448,11 +1537,11 @@ def schedule_constraint(cls) -> Mapped[int]: """ return mapped_column( f"{cls.__default_schedule_attr_name__}_constraint", - Integer, + ScheduleConstraintDecorator(), default=0, nullable=False, - doc="""An integer number showing the constraint schema for this - task. + doc="""A ScheduleConstraint value showing the constraint schema + for this task. Possible values are: @@ -1463,22 +1552,17 @@ def schedule_constraint(cls) -> Mapped[int]: 3 Constrain Both ===== =============== - For convenience use **stalker.models.task.CONSTRAIN_NONE**, - **stalker.models.task.CONSTRAIN_START**, - **stalker.models.task.CONSTRAIN_END**, - **stalker.models.task.CONSTRAIN_BOTH**. - This value is going to be used to constrain the start and end date values of this task. So if you want to pin the start of a task to a certain date. Set its :attr:`.schedule_constraint` value to - **CONSTRAIN_START**. When the task is scheduled by **TaskJuggler** - the start date will be pinned to the :attr:`start` attribute of - this task. + :attr:`.ScheduleConstraint.Start`. When the task is scheduled by + **TaskJuggler** the start date will be pinned to the :attr:`start` + attribute of this task. And if both of the date values (start and end) wanted to be pinned to certain dates (making the task effectively a ``duration`` task) set the desired :attr:`start` and :attr:`end` and then set the - :attr:`schedule_constraint` to **CONSTRAIN_BOTH**. + :attr:`schedule_constraint` to :att:`.ScheduleConstraint.Both`. """, ) @@ -1486,36 +1570,22 @@ def schedule_constraint(cls) -> Mapped[int]: def _validate_schedule_constraint( self, key: str, - schedule_constraint: Union[None, int], + schedule_constraint: Union[None, int, str], ) -> int: """Validate the given schedule_constraint value. Args: key (str): The name of the validated column. - schedule_constraint (int): The schedule_constraint value to be validated. - - Raises: - TypeError: If the schedule_constraint is not an int. + schedule_constraint (Union[None, int, str]): The value to be + validated. Returns: - int: The validated schedule_constraint value. + ScheduleConstraint: The validated schedule_constraint value. """ - if not schedule_constraint: - schedule_constraint = 0 - - if not isinstance(schedule_constraint, int): - raise TypeError( - "{cls}.{attr}_constraint should be an integer " - "between 0 and 3, not {constraint_class}: '{constraint}'".format( - cls=self.__class__.__name__, - attr=self.__default_schedule_attr_name__, - constraint_class=schedule_constraint.__class__.__name__, - constraint=schedule_constraint, - ) - ) + if schedule_constraint is None: + schedule_constraint = ScheduleConstraint.NONE - schedule_constraint = max(schedule_constraint, 0) - schedule_constraint = min(schedule_constraint, 3) + schedule_constraint = ScheduleConstraint.to_constraint(schedule_constraint) return schedule_constraint diff --git a/src/stalker/models/task.py b/src/stalker/models/task.py index 3eaacf93..3c14e4b6 100644 --- a/src/stalker/models/task.py +++ b/src/stalker/models/task.py @@ -55,6 +55,7 @@ DAGMixin, DateRangeMixin, ReferenceMixin, + ScheduleConstraint, ScheduleMixin, StatusMixin, ) @@ -70,13 +71,6 @@ logger = get_logger(__name__) -# schedule constraints -CONSTRAIN_NONE = 0 -CONSTRAIN_START = 1 -CONSTRAIN_END = 2 -CONSTRAIN_BOTH = 3 - - BINARY_STATUS_VALUES = { "WFD": 0b100000000, "RTS": 0b010000000, @@ -861,77 +855,82 @@ class Task( :class;`.Budget` s with this information. Args: - project (Project): A Task which doesn't have a parent (a root task) should be - created with a :class:`.Project` instance. If it is skipped an no - :attr:`.parent` is given then Stalker will raise a RuntimeError. If both - the ``project`` and the :attr:`.parent` argument contains data and the - project of the Task instance given with parent argument is different than - the Project instance given with the ``project`` argument then a - RuntimeWarning will be raised and the project of the parent task will be - used. + project (Project): A Task which doesn't have a parent (a root task) + should be created with a :class:`.Project` instance. If it is + skipped an no :attr:`.parent` is given then Stalker will raise a + RuntimeError. If both the ``project`` and the :attr:`.parent` + argument contains data and the project of the Task instance given + with parent argument is different than the Project instance given + with the ``project`` argument then a RuntimeWarning will be raised + and the project of the parent task will be used. parent (Task): The parent Task or Project of this Task. Every Task in - Stalker should be related with a :class:`.Project` instance. So if no - parent task is desired, at least a Project instance should be passed as - the parent of the created Task or the Task will be an orphan task and - Stalker will raise a RuntimeError. - depends_on (List[Task]): A list of :class:`.Task` s that this :class:`.Task` is - depending on. A Task cannot depend on itself or any other Task which are - already depending on this one in anyway or a CircularDependency error - will be raised. - resources (List[User]): The :class:`.User` s assigned to this :class:`.Task`. A - :class:`.Task` without any resource cannot be scheduled. - responsible (List[User]): A list of :class:`.User` instances that is responsible - of this task. - watchers (List[User]): A list of :class:`.User` those are added this Task - instance to their watch list. - start (datetime.datetime): The start date and time of this task instance. It is - only used if the :attr:`.schedule_constraint` attribute is set to - :attr:`.CONSTRAIN_START` or :attr:`.CONSTRAIN_BOTH`. The default value - is `datetime.datetime.now(pytz.utc)`. - end (datetime.datetime): The end date and time of this task instance. It is only - used if the :attr:`.schedule_constraint` attribute is set to - :attr:`.CONSTRAIN_END` or :attr:`.CONSTRAIN_BOTH`. The default value is + Stalker should be related with a :class:`.Project` instance. So if + no parent task is desired, at least a Project instance should be + passed as the parent of the created Task or the Task will be an + orphan task and Stalker will raise a RuntimeError. + depends_on (List[Task]): A list of :class:`.Task` s that this + :class:`.Task` is depending on. A Task cannot depend on itself or + any other Task which are already depending on this one in anyway or + a CircularDependency error will be raised. + resources (List[User]): The :class:`.User` s assigned to this + :class:`.Task`. A :class:`.Task` without any resource cannot be + scheduled. + responsible (List[User]): A list of :class:`.User` instances that is + responsible of this task. + watchers (List[User]): A list of :class:`.User` those are added this + Task instance to their watch list. + start (datetime.datetime): The start date and time of this task + instance. It is only used if the :attr:`.schedule_constraint` + attribute is set to :attr:`.ScheduleConstraint.Start` or + :attr:`.ScheduleConstraint.Both`. The default value is `datetime.datetime.now(pytz.utc)`. + end (datetime.datetime): The end date and time of this task instance. + It is only used if the :attr:`.schedule_constraint` attribute is + set to :attr:`.CONSTRAIN_END` or :attr:`.CONSTRAIN_BOTH`. The + default value is `datetime.datetime.now(pytz.utc)`. schedule_timing (int): The value of the schedule timing. schedule_unit (str): The unit value of the schedule timing. Should be one of 'min', 'h', 'd', 'w', 'm', 'y'. schedule_constraint (int): The schedule constraint. It is the index of the schedule constraints value in :class:`stalker.config.Config.task_schedule_constraints`. - bid_timing (int): The initial bid for this Task. It can be used in measuring how - accurate the initial guess was. It will be compared against the total amount - of effort spend doing this task. Can be set to None, which will be set to - the schedule_timing_day argument value if there is one or 0. - bid_unit (str): The unit of the bid value for this Task. Should be one of the - 'min', 'h', 'd', 'w', 'm', 'y'. - is_milestone (bool): A bool (True or False) value showing if this task is a - milestone which doesn't need any resource and effort. - priority (int): It is a number between 0 to 1000 which defines the priority of - the :class:`.Task`. The higher the value the higher its priority. The - default value is 500. Mainly used by TaskJuggler. - - Higher priority tasks will be scheduled to an early date or at least will - tried to be scheduled to an early date then a lower priority task (a task - that is using the same resources). - - In complex projects, a task with a lower priority task may steal resources - from a higher priority task, this is due to the internals of TaskJuggler, it - tries to increase the resource utilization by letting the lower priority - task to be completed earlier than the higher priority task. This is done in - that way if the lower priority task is dependent of more important tasks - (tasks in critical path or tasks with critical resources). Read TaskJuggler - documentation for more information on how TaskJuggler schedules tasks. - allocation_strategy (str): Defines the allocation strategy for resources - of a task with alternative resources. Should be one of ['minallocated', - 'maxloaded', 'minloaded', 'order', 'random'] and the default value is - 'minallocated'. For more information read the :class:`.Task` class - documentation. - persistent_allocation (bool): Specifies that once a resource is picked from the - list of alternatives this resource is used for the whole task. The default - value is True. For more information read the :class:`.Task` class - documentation. - good (stalker.models.budget.Good): It is possible to attach a good to this Task - to be able to filter and group them later on. + bid_timing (int): The initial bid for this Task. It can be used in + measuring how accurate the initial guess was. It will be compared + against the total amount of effort spend doing this task. Can be + set to None, which will be set to the schedule_timing_day argument + value if there is one or 0. + bid_unit (str): The unit of the bid value for this Task. Should be one + of the 'min', 'h', 'd', 'w', 'm', 'y'. + is_milestone (bool): A bool (True or False) value showing if this task + is a milestone which doesn't need any resource and effort. + priority (int): It is a number between 0 to 1000 which defines the + priority of the :class:`.Task`. The higher the value the higher its + priority. The default value is 500. Mainly used by TaskJuggler. + + Higher priority tasks will be scheduled to an early date or at + least will tried to be scheduled to an early date then a lower + priority task (a task that is using the same resources). + + In complex projects, a task with a lower priority task may steal + resources from a higher priority task, this is due to the internals + of TaskJuggler, it tries to increase the resource utilization by + letting the lower priority task to be completed earlier than the + higher priority task. This is done in that way if the lower + priority task is dependent of more important tasks (tasks in + critical path or tasks with critical resources). Read TaskJuggler + documentation for more information on how TaskJuggler schedules + tasks. + allocation_strategy (str): Defines the allocation strategy for + resources of a task with alternative resources. Should be one of + ['minallocated', 'maxloaded', 'minloaded', 'order', 'random'] and + the default value is 'minallocated'. For more information read the + :class:`.Task` class documentation. + persistent_allocation (bool): Specifies that once a resource is picked + from the list of alternatives this resource is used for the whole + task. The default value is True. For more information read the + :class:`.Task` class documentation. + good (stalker.models.budget.Good): It is possible to attach a good to + this Task to be able to filter and group them later on. """ from stalker import defaults @@ -1488,19 +1487,19 @@ def _reschedule( kwargs = {unit["name"]: schedule_timing * unit["multiplier"]} calculated_duration = datetime.timedelta(**kwargs) if ( - self.schedule_constraint == CONSTRAIN_NONE - or self.schedule_constraint == CONSTRAIN_START + self.schedule_constraint == ScheduleConstraint.NONE + or self.schedule_constraint == ScheduleConstraint.Start ): # get end self._start, self._end, self._duration = self._validate_dates( self.start, None, calculated_duration ) - elif self.schedule_constraint == CONSTRAIN_END: + elif self.schedule_constraint == ScheduleConstraint.End: # get start self._start, self._end, self._duration = self._validate_dates( None, self.end, calculated_duration ) - elif self.schedule_constraint == CONSTRAIN_BOTH: + elif self.schedule_constraint == ScheduleConstraint.Both: # restore duration self._start, self._end, self._duration = self._validate_dates( self.start, self.end, None diff --git a/tests/db/test_db.py b/tests/db/test_db.py index e98e538b..4dadb42b 100644 --- a/tests/db/test_db.py +++ b/tests/db/test_db.py @@ -72,6 +72,7 @@ ProgrammingError, ) +from stalker.models.mixins import ScheduleConstraint from tests.utils import create_random_db, get_admin_user, tear_down_db logger = log.get_logger(__name__) @@ -3969,7 +3970,12 @@ def test_persistence_of_task(setup_postgresql_db): project=project1, responsible=[user1], ) - task1 = Task(name="Test Task", watchers=[user3], parent=asset1) + task1 = Task( + name="Test Task", + watchers=[user3], + parent=asset1, + schedule_constraint=ScheduleConstraint.Start, + ) child_task1 = Task( name="Child Task 1", resources=[user1, user2], @@ -4073,6 +4079,7 @@ def test_persistence_of_task(setup_postgresql_db): parent = task1.parent priority = task1.priority resources = task1.resources + schedule_constraint = task1.schedule_constraint schedule_model = task1.schedule_model schedule_timing = task1.schedule_timing schedule_unit = task1.schedule_unit @@ -4120,6 +4127,8 @@ def test_persistence_of_task(setup_postgresql_db): assert task1_db.updated_by == updated_by assert task1_db.versions == versions assert task1_db.watchers == watchers + assert task1_db.schedule_constraint == schedule_constraint + assert isinstance(task1_db.schedule_constraint, ScheduleConstraint) assert task1_db.schedule_model == schedule_model assert task1_db.schedule_timing == schedule_timing assert task1_db.schedule_unit == schedule_unit diff --git a/tests/mixins/test_scheduleMixin.py b/tests/mixins/test_scheduleMixin.py index 9612b6c4..be83cfef 100644 --- a/tests/mixins/test_scheduleMixin.py +++ b/tests/mixins/test_scheduleMixin.py @@ -173,24 +173,22 @@ def test_schedule_constraint_argument_is_not_an_integer(setup_schedule_mixin_tes """TypeError is raised if the schedule_constraint argument is not an int.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_constraint"] = "not an int" - with pytest.raises(TypeError) as cm: + with pytest.raises(ValueError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( - "MixedInClass.schedule_constraint should be an integer between " - "0 and 3, not str: 'not an int'" + "constraint should be one of ['None', 'Start', 'End', 'Both'], not 'not an int'" ) def test_schedule_constraint_attribute_is_not_an_integer(setup_schedule_mixin_tests): """TypeError is raised if the schedule_constraint attribute is not an int.""" data = setup_schedule_mixin_tests - with pytest.raises(TypeError) as cm: + with pytest.raises(ValueError) as cm: data["test_obj"].schedule_constraint = "not an int" assert str(cm.value) == ( - "MixedInClass.schedule_constraint should be an integer between " - "0 and 3, not str: 'not an int'" + "constraint should be one of ['None', 'Start', 'End', 'Both'], not 'not an int'" ) @@ -215,29 +213,37 @@ def test_schedule_constraint_attribute_is_working_as_expected( assert data["test_obj"].schedule_constraint == test_value -def test_schedule_constraint_argument_value_is_out_of_range(setup_schedule_mixin_tests): +@pytest.mark.parametrize( + "test_value", [-1, 4] +) +def test_schedule_constraint_argument_value_is_out_of_range( + setup_schedule_mixin_tests, + test_value, +): """schedule_constraint is clamped to the [0-3] range if it is out of range.""" data = setup_schedule_mixin_tests - data["kwargs"]["schedule_constraint"] = -1 - new_task = MixedInClass(**data["kwargs"]) - assert new_task.schedule_constraint == 0 - - data["kwargs"]["schedule_constraint"] = 4 - new_task = MixedInClass(**data["kwargs"]) - assert new_task.schedule_constraint == 3 + data["kwargs"]["schedule_constraint"] = test_value + with pytest.raises(ValueError) as cm: + _ = MixedInClass(**data["kwargs"]) + assert str(cm.value) == ( + f"{test_value} is not a valid ScheduleConstraint" + ) +@pytest.mark.parametrize( + "test_value", [-1, 4] +) def test_schedule_constraint_attribute_value_is_out_of_range( - setup_schedule_mixin_tests, + setup_schedule_mixin_tests, test_value, ): """schedule_constraint is clamped to the [0-3] range if it is out of range.""" data = setup_schedule_mixin_tests - data["test_obj"].schedule_constraint = -1 - assert data["test_obj"].schedule_constraint == 0 - - data["test_obj"].schedule_constraint = 4 - assert data["test_obj"].schedule_constraint == 3 + with pytest.raises(ValueError) as cm: + data["test_obj"].schedule_constraint = test_value + assert str(cm.value) == ( + f"{test_value} is not a valid ScheduleConstraint" + ) def test_schedule_timing_argument_skipped(setup_schedule_mixin_tests): """schedule_timing is equal to 1h if the schedule_timing arg is skipped.""" diff --git a/tests/models/test_schedule_constraint.py b/tests/models/test_schedule_constraint.py new file mode 100644 index 00000000..69922282 --- /dev/null +++ b/tests/models/test_schedule_constraint.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""ScheduleConstraint related tests are here.""" +from enum import IntEnum +import sys + +import pytest + +from stalker.models.mixins import ScheduleConstraint + + +@pytest.mark.parametrize( + "schedule_constraint", [ + ScheduleConstraint.NONE, + ScheduleConstraint.Start, + ScheduleConstraint.End, + ScheduleConstraint.Both, + ] +) +def test_it_is_an_int_enum(schedule_constraint): + """ScheduleConstraint is an IntEnum.""" + assert isinstance(schedule_constraint, IntEnum) + + +@pytest.mark.parametrize( + "schedule_constraint,expected_value", [ + [ScheduleConstraint.NONE, 0], + [ScheduleConstraint.Start, 1], + [ScheduleConstraint.End, 2], + [ScheduleConstraint.Both, 3], + ] +) +def test_enum_values(schedule_constraint, expected_value): + """Test enum values.""" + assert schedule_constraint == expected_value + + +@pytest.mark.parametrize( + "schedule_constraint,expected_value", [ + [ScheduleConstraint.NONE, "None"], + [ScheduleConstraint.Start, "Start"], + [ScheduleConstraint.End, "End"], + [ScheduleConstraint.Both, "Both"], + ] +) +def test_enum_names(schedule_constraint, expected_value): + """Test enum names.""" + assert str(schedule_constraint) == expected_value + + +def test_to_constraint_constraint_is_skipped(): + """ScheduleConstraint.to_constraint() constraint is skipped.""" + with pytest.raises(TypeError) as cm: + _ = ScheduleConstraint.to_constraint() + + py_error_message = { + 8: "to_constraint() missing 1 required positional argument: 'constraint'", + 9: "to_constraint() missing 1 required positional argument: 'constraint'", + 10: "ScheduleConstraint.to_constraint() missing 1 required positional argument: 'constraint'", + 11: "ScheduleConstraint.to_constraint() missing 1 required positional argument: 'constraint'", + 12: "ScheduleConstraint.to_constraint() missing 1 required positional argument: 'constraint'", + 13: "ScheduleConstraint.to_constraint() missing 1 required positional argument: 'constraint'" + }[sys.version_info.minor] + assert str(cm.value) == py_error_message + + +def test_to_constraint_constraint_is_none(): + """ScheduleConstraint.to_constraint() constraint is None.""" + constraint = ScheduleConstraint.to_constraint(None) + assert constraint == ScheduleConstraint.NONE + + +def test_to_constraint_constraint_is_not_a_str(): + """ScheduleConstraint.to_constraint() constraint is not an int or str.""" + with pytest.raises(TypeError) as cm: + _ = ScheduleConstraint.to_constraint(12334.123) + + assert str(cm.value) == ( + "constraint should be an int, str or ScheduleConstraint, " + "not float: '12334.123'" + ) + + +def test_to_constraint_constraint_is_not_a_valid_str(): + """ScheduleConstraint.to_constraint() constraint is not a valid str.""" + with pytest.raises(ValueError) as cm: + _ = ScheduleConstraint.to_constraint("not a valid value") + + assert str(cm.value) == ( + "constraint should be one of ['None', 'Start', 'End', 'Both'], " + "not 'not a valid value'" + ) + + +@pytest.mark.parametrize( + "constraint_name,constraint", + [ + # None + ["None", ScheduleConstraint.NONE], + ["none", ScheduleConstraint.NONE], + ["NONE", ScheduleConstraint.NONE], + ["NoNe", ScheduleConstraint.NONE], + ["nONe", ScheduleConstraint.NONE], + [0, ScheduleConstraint.NONE], + # Start + ["Start", ScheduleConstraint.Start], + ["start", ScheduleConstraint.Start], + ["START", ScheduleConstraint.Start], + ["StaRt", ScheduleConstraint.Start], + ["STaRt", ScheduleConstraint.Start], + ["StARt", ScheduleConstraint.Start], + [1, ScheduleConstraint.Start], + # End + ["End", ScheduleConstraint.End], + ["end", ScheduleConstraint.End], + ["END", ScheduleConstraint.End], + ["eNd", ScheduleConstraint.End], + ["eND", ScheduleConstraint.End], + [2, ScheduleConstraint.End], + # Both + ["Both", ScheduleConstraint.Both], + ["both", ScheduleConstraint.Both], + ["BOTH", ScheduleConstraint.Both], + ["bOth", ScheduleConstraint.Both], + ["boTh", ScheduleConstraint.Both], + ["BotH", ScheduleConstraint.Both], + ["BOtH", ScheduleConstraint.Both], + [3, ScheduleConstraint.Both], + ], +) +def test_schedule_constraint_to_constraint_is_working_properly(constraint_name, constraint): + """ScheduleConstraint can parse schedule constraint names.""" + assert ScheduleConstraint.to_constraint(constraint_name) == constraint diff --git a/tests/models/test_task.py b/tests/models/test_task.py index 46258638..5329975c 100644 --- a/tests/models/test_task.py +++ b/tests/models/test_task.py @@ -32,8 +32,7 @@ ) from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError -from stalker.models.mixins import DateRangeMixin -from stalker.models.task import CONSTRAIN_BOTH, CONSTRAIN_END +from stalker.models.mixins import DateRangeMixin, ScheduleConstraint @pytest.fixture(scope="function") @@ -2210,7 +2209,7 @@ def test_start_and_end_attr_values_of_a_container_task_are_defined_by_its_child_ # remove effort and duration. Why? kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") - kwargs["schedule_constraint"] = CONSTRAIN_BOTH + kwargs["schedule_constraint"] = ScheduleConstraint.Both now = datetime.datetime(2013, 3, 22, 15, 0, tzinfo=pytz.utc) dt = datetime.timedelta @@ -2295,7 +2294,7 @@ def test_start_calc_with_schedule_timing_and_schedule_unit_if_schedule_constrain kwargs["start"] = datetime.datetime(2013, 4, 17, 0, 0, tzinfo=pytz.utc) kwargs["end"] = datetime.datetime(2013, 4, 18, 0, 0, tzinfo=pytz.utc) - kwargs["schedule_constraint"] = CONSTRAIN_END + kwargs["schedule_constraint"] = ScheduleConstraint.End kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = "d" @@ -2314,7 +2313,7 @@ def test_start_and_end_values_are_not_touched_if_the_schedule_constraint_is_set_ kwargs["start"] = datetime.datetime(2013, 4, 17, 0, 0, tzinfo=pytz.utc) kwargs["end"] = datetime.datetime(2013, 4, 27, 0, 0, tzinfo=pytz.utc) - kwargs["schedule_constraint"] = CONSTRAIN_BOTH + kwargs["schedule_constraint"] = ScheduleConstraint.Both kwargs["schedule_timing"] = 100 kwargs["schedule_unit"] = "d" From 43915d1297efeea81ab07b7e8856e104aa2b7768 Mon Sep 17 00:00:00 2001 From: Erkan Ozgur Yilmaz Date: Tue, 10 Dec 2024 17:47:29 +0300 Subject: [PATCH 2/3] [#87] Updated docstring of Task to reflect the change in `schedule_constraint` argument. --- src/stalker/models/task.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/stalker/models/task.py b/src/stalker/models/task.py index 3c14e4b6..18a36ebd 100644 --- a/src/stalker/models/task.py +++ b/src/stalker/models/task.py @@ -891,9 +891,9 @@ class Task( schedule_timing (int): The value of the schedule timing. schedule_unit (str): The unit value of the schedule timing. Should be one of 'min', 'h', 'd', 'w', 'm', 'y'. - schedule_constraint (int): The schedule constraint. It is the index - of the schedule constraints value in - :class:`stalker.config.Config.task_schedule_constraints`. + schedule_constraint (ScheduleConstraint): The + :class:`.ScheduleConstraint` value. The default is + `ScheduleConstraint.NONE`. bid_timing (int): The initial bid for this Task. It can be used in measuring how accurate the initial guess was. It will be compared against the total amount of effort spend doing this task. Can be @@ -1153,7 +1153,7 @@ def __init__( schedule_timing: float = 1.0, schedule_unit: str = "h", schedule_model: Optional[str] = None, - schedule_constraint: int = 0, + schedule_constraint: Optional[ScheduleConstraint] = ScheduleConstraint.NONE, bid_timing: Optional[Union[int, float]] = None, bid_unit: Optional[str] = None, is_milestone: bool = False, @@ -2256,7 +2256,7 @@ def _total_logged_seconds_getter(self) -> int: self.update_schedule_info() return self._total_logged_seconds - if self.schedule_model in "effort": + if self.schedule_model == "effort": logger.debug("effort based task detected!") try: sql = """ From 95baf79fbff54b1e81f36fdb226bf5a67a76fd31 Mon Sep 17 00:00:00 2001 From: Erkan Ozgur Yilmaz Date: Tue, 10 Dec 2024 17:47:55 +0300 Subject: [PATCH 3/3] [#87] Removed `task_schedule_constraints` config value from the `stalker.config` module. --- src/stalker/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stalker/config.py b/src/stalker/config.py index dec896b5..26708872 100644 --- a/src/stalker/config.py +++ b/src/stalker/config.py @@ -264,7 +264,6 @@ class Config(ConfigBase): review_status_codes=["NEW", "RREV", "APP"], daily_status_names=["Open", "Closed"], daily_status_codes=["OPEN", "CLS"], - task_schedule_constraints=["none", "start", "end", "both"], task_schedule_models=["effort", "length", "duration"], task_dependency_gap_models=["length", "duration"], task_dependency_targets=["onend", "onstart"],