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"], 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..18a36ebd 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. + 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 + 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 @@ -1154,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, @@ -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 @@ -2257,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 = """ 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"