Repository: eoyilmaz/stalker Branch: develop Commit: 7de585b84de8 Files: 264 Total size: 3.1 MB Directory structure: gitextract_k2ghu6v_/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── pytest.yml ├── .gitignore ├── CHANGELOG.rst ├── CHANGELOG_OLD.rst ├── COPYING ├── COPYING.LESSER ├── Dockerfile-py3.5 ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── TODO.rst ├── alembic/ │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions/ │ ├── 0063f547dc2e_updated_version_inputs_table.py │ ├── 019378697b5b_rename_depends_to_to_depends_on.py │ ├── 101a789e38ad_created_task_responsible.py │ ├── 1181305d3001_added_client_id_column_to_goods_table.py │ ├── 130a7697cd79_vacation_user_can_now_be_nullable.py │ ├── 174567b9c159_note_content.py │ ├── 182f44ce5f07_added_users_company_and_projects_client.py │ ├── 1875136a2bfc_removed_version_variant_name_attribute.py │ ├── 1c9c9c28c102_price_lists_and_goods.py │ ├── 21b88ed3da95_added_referencemixin.py │ ├── 2252e51506de_multiple_repositories.py │ ├── 23dff41c95ff_removed_tasks_is_complete_column.py │ ├── 255ee1f9c7b3_added_payments_table.py │ ├── 258985128aff_create_entitygroups_table.py │ ├── 25b3eba6ffe7_derive_version_from.py │ ├── 275bdc106fd5_added_ticket_summary.py │ ├── 2aeab8b376dc_fg_color_bg_color.py │ ├── 2e4a3813ae76_created_daily_class.py │ ├── 2f55dc4f199f_wiki_page.py │ ├── 30c576f3691_budget_and_budget_entry.py │ ├── 31b1e22b455e_added_exclude_and_check_constraints_to_.py │ ├── 39d3c16ff005_budget_entries_good_id.py │ ├── 3be540ad3a93_added_version_revision_number_attribute.py │ ├── 409d2d73ca30_user_rate.py │ ├── 433d9caaafab_task_review_status_workflow.py │ ├── 4400871fa852_scene_is_now_deriving_from_task.py │ ├── 4664d72ce1e1_renamed_link_path_to_full_path.py │ ├── 46775e4a3d96_create_enum_types.py │ ├── 4a836cf73bcf_create_entitytype_accepts_references.py │ ├── 5078390e5527_shot_scene_relation_is_now_many_to_one.py │ ├── 5168cc8552a3_html_style_html_class.py │ ├── 5355b569237b_version_version_of_r.py │ ├── 53d8127d8560_parent_child_relatio.py │ ├── 57a5949c7f29_cache_for_total_logged_seconds.py │ ├── 5814290f49c7_added_shot_source_in_shot_source_out_record_in.py │ ├── 583875229230_good_task_relation.py │ ├── 59092d41175c_added_version_created_with.py │ ├── 5999269aad30_added_generic_text_attribute.py │ ├── 59bfe820c369_resource_efficiency.py │ ├── 6297277da38_added_vacation_class.py │ ├── 644f5251fc0d_remove_project_active_attribute.py │ ├── 745b210e6907_fix_non_existing_thumbnails.py │ ├── 856e70016b2_roles.py │ ├── 91ed52b72b82_created_variant_class.py │ ├── 92257ba439e1_budget_is_now_statusable.py │ ├── 9f9b88fef376_link_renamed_to_file.py │ ├── a2007ad7f535_added_review_version_id_column.py │ ├── a6598cde6b_versions_are_not_mix.py │ ├── a9319b19f7be_added_shot_fps.py │ ├── af869ddfdf9_entity_to_note_relation_is_now_many_to_many.py │ ├── bf67e6a234b4_added_revision_code_attribute.py │ ├── c5607b4cfb0a_added_support_for_time_zones.py │ ├── d8421de6a206_added_project_users_rate_column.py │ ├── e25ec9930632_shot_sequence_relation_is_now_many_to_.py │ ├── ea28a39ba3f5_added_invoices_table.py │ ├── eaed49db6d9_added_position_column_to_Project_Repositories.py │ ├── ec1eb2151bb9_rename_version_take_name_to_version_.py │ ├── ed0167fff399_added_workinghours_table.py │ ├── f16651477e64_added_authenticationlog_class.py │ ├── f2005d1fbadc_added_projectclients.py │ └── feca9bac7d5a_renamed_osx_to_macos.py ├── alembic.ini ├── docs/ │ ├── Makefile │ ├── make.bat │ ├── make_html.bat │ └── source/ │ ├── _static/ │ │ └── images/ │ │ ├── Task_Status_Workflow.vue │ │ └── stalker_design.vue │ ├── _templates/ │ │ └── autosummary/ │ │ ├── base.rst │ │ ├── class.rst │ │ └── module.rst │ ├── about.rst │ ├── changelog.rst │ ├── conf.py │ ├── configure.rst │ ├── contents.rst │ ├── contribute.rst │ ├── design.rst │ ├── index.rst │ ├── inheritance_diagram.rst │ ├── installation.rst │ ├── roadmap.rst │ ├── status_and_status_lists.rst │ ├── summary.rst │ ├── task_review_workflow.rst │ ├── todo.rst │ ├── tutorial/ │ │ ├── asset_management.rst │ │ ├── basics.rst │ │ ├── collaboration.rst │ │ ├── conclusion.rst │ │ ├── creating_simple_data.rst │ │ ├── extending_som.rst │ │ ├── pipeline.rst │ │ ├── query_update_delete_data.rst │ │ ├── scheduling.rst │ │ ├── task_and_resource_management.rst │ │ └── tutorial_files/ │ │ └── tutorial.py │ ├── tutorial.rst │ └── upgrade_db.rst ├── examples/ │ ├── __init__.py │ ├── extending/ │ │ ├── __init__.py │ │ ├── camera_lens.py │ │ ├── great_entity.py │ │ └── statused_entity.py │ └── flat_project_example.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── src/ │ └── stalker/ │ ├── VERSION │ ├── __init__.py │ ├── config.py │ ├── db/ │ │ ├── __init__.py │ │ ├── declarative.py │ │ ├── session.py │ │ ├── setup.py │ │ └── types.py │ ├── exceptions.py │ ├── log.py │ ├── models/ │ │ ├── __init__.py │ │ ├── asset.py │ │ ├── auth.py │ │ ├── budget.py │ │ ├── client.py │ │ ├── department.py │ │ ├── entity.py │ │ ├── enum.py │ │ ├── file.py │ │ ├── format.py │ │ ├── message.py │ │ ├── mixins.py │ │ ├── note.py │ │ ├── project.py │ │ ├── repository.py │ │ ├── review.py │ │ ├── scene.py │ │ ├── schedulers.py │ │ ├── sequence.py │ │ ├── shot.py │ │ ├── status.py │ │ ├── structure.py │ │ ├── studio.py │ │ ├── tag.py │ │ ├── task.py │ │ ├── template.py │ │ ├── ticket.py │ │ ├── type.py │ │ ├── variant.py │ │ ├── version.py │ │ └── wiki.py │ ├── py.typed │ ├── utils.py │ └── version.py ├── tests/ │ ├── __init__.py │ ├── benchmarks/ │ │ ├── __init__.py │ │ └── task_total_logged_seonds.py │ ├── config/ │ │ ├── __init__.py │ │ └── test_config.py │ ├── conftest.py │ ├── data/ │ │ ├── project_to_tjp_output.jinja2 │ │ ├── project_to_tjp_output_formatted │ │ └── project_to_tjp_output_rendered │ ├── db/ │ │ ├── __init__.py │ │ ├── test_db.py │ │ ├── test_dbsession.py │ │ └── test_types.py │ ├── mixins/ │ │ ├── __init__.py │ │ ├── test_acl_mixin.py │ │ ├── test_amount_mixin.py │ │ ├── test_code_mixin.py │ │ ├── test_create_secondary_table.py │ │ ├── test_dag_mixin.py │ │ ├── test_date_range_mixin.py │ │ ├── test_declarative_project_mixin.py │ │ ├── test_declarative_reference_mixin.py │ │ ├── test_declarative_schedule_mixin.py │ │ ├── test_declarative_status_mixin.py │ │ ├── test_project_mixin.py │ │ ├── test_reference_mixin.py │ │ ├── test_schedule_mixin.py │ │ ├── test_status_mixin.py │ │ ├── test_target_entity_type_mixin.py │ │ └── test_unit_mixin.py │ ├── models/ │ │ ├── __init__.py │ │ ├── test_asset.py │ │ ├── test_authentication_log.py │ │ ├── test_budget.py │ │ ├── test_client.py │ │ ├── test_client_user.py │ │ ├── test_daily.py │ │ ├── test_department.py │ │ ├── test_department_user.py │ │ ├── test_dependency_target.py │ │ ├── test_entity.py │ │ ├── test_entity_group.py │ │ ├── test_file.py │ │ ├── test_filename_template.py │ │ ├── test_generic.py │ │ ├── test_good.py │ │ ├── test_group.py │ │ ├── test_image_format.py │ │ ├── test_invoice.py │ │ ├── test_local_session.py │ │ ├── test_message.py │ │ ├── test_note.py │ │ ├── test_payment.py │ │ ├── test_permission.py │ │ ├── test_price_list.py │ │ ├── test_project.py │ │ ├── test_project_client.py │ │ ├── test_project_user.py │ │ ├── test_repository.py │ │ ├── test_review.py │ │ ├── test_role.py │ │ ├── test_scene.py │ │ ├── test_schedule_constraint.py │ │ ├── test_schedule_model.py │ │ ├── test_schedulers.py │ │ ├── test_sequence.py │ │ ├── test_shot.py │ │ ├── test_simple_entity.py │ │ ├── test_status.py │ │ ├── test_status_list.py │ │ ├── test_structure.py │ │ ├── test_studio.py │ │ ├── test_tag.py │ │ ├── test_task.py │ │ ├── test_task_dependency.py │ │ ├── test_task_juggler_scheduler.py │ │ ├── test_task_status_workflow.py │ │ ├── test_ticket.py │ │ ├── test_time_log.py │ │ ├── test_time_unit.py │ │ ├── test_traversal_direction.py │ │ ├── test_type.py │ │ ├── test_user.py │ │ ├── test_vacation.py │ │ ├── test_variant.py │ │ ├── test_version.py │ │ ├── test_wiki.py │ │ └── test_working_hours.py │ ├── test_exceptions.py │ ├── test_logging.py │ ├── test_readme_tutorial.py │ ├── test_testing.py │ ├── test_version.py │ └── utils.py └── whitelist.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.yml] indent_size = 2 [*.md] insert_final_newline = false trim_trailing_whitespace = false [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab ================================================ FILE: .github/workflows/pytest.yml ================================================ name: Unit Tests on: pull_request: types: [opened, synchronize, reopened, ready_for_review, unlabeled] branches: - develop push: branches: - develop jobs: build: name: Python ${{ matrix.python-version }} & PostgreSQL ${{ matrix.postgresql-version }} env: PGPASSWORD: postgres runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] postgresql-version: ["14", "15", "16", "17"] steps: - uses: actions/checkout@v4 - name: Set Environment Variables run: | echo "py_version=$(echo ${{ matrix.python-version }} | tr -d .)" >> $GITHUB_ENV if [ "${{ matrix.python-version }}" == "3.8" ]; then echo "add_dir_str=${{ matrix.python-version }}" >> $GITHUB_ENV elif [ "${{ matrix.python-version }}" == "3.9" ]; then echo "add_dir_str=${{ matrix.python-version }}" >> $GITHUB_ENV elif [ "${{ matrix.python-version }}" == "3.10" ]; then echo "add_dir_str=cpython-310" >> $GITHUB_ENV elif [ "${{ matrix.python-version }}" == "3.11" ]; then echo "add_dir_str=cpython-311" >> $GITHUB_ENV elif [ "${{ matrix.python-version }}" == "3.12" ]; then echo "add_dir_str=cpython-312" >> $GITHUB_ENV elif [ "${{ matrix.python-version }}" == "3.13" ]; then echo "add_dir_str=cpython-313" >> $GITHUB_ENV fi - name: Setup PostgreSQL for Linux/macOS/Windows uses: ikalnytskyi/action-setup-postgres@v7 with: # The username of the user to setup. username: postgres # The password of the user to setup. password: postgres # The database name to setup and grant permissions to created user. database: postgres # The server port to listen on. port: 5432 # The PostgreSQL major version to install. Either "14", "15", "16" or "17". postgres-version: ${{ matrix.postgresql-version }} # When "true", encrypt connections using SSL (TLS). ssl: false - name: Set up TaskJuggler run: | sudo gem install taskjuggler - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Update pip run: | sudo apt-get install -y $(grep -o ^[^#][[:alnum:]-]*.* "packages.list") python3 -m pip install --upgrade pip pip install wheel - name: Install Python dependencies run: | pip install -r requirements.txt -r requirements-dev.txt - name: Build Stalker run: | python3 -m build ls -l dist/ wheel_file=$(ls dist/stalker-*.whl) pip install $wheel_file - name: Test with pytest run: | PYTHONPATH=src python -m pytest - name: Archive code coverage results uses: actions/upload-artifact@v4 with: name: code-coverage-report-py${{ env.py_version }}-psql${{ matrix.postgresql-version }} path: htmlcov retention-days: 10 # windows: # name: Test with Python ${{ matrix.python-version }} on Windows # runs-on: windows-latest # strategy: # fail-fast: false # matrix: # python-version: # - "3.8" # - "3.9" # - "3.10" # - "3.11" # steps: # - uses: actions/checkout@v4 # - name: Set Environment Variables # run: | # $py_version = "${{ matrix.python-version }}" -replace '\.', '' # echo "py_version=$py_version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # if ("${{ matrix.python-version }}" -eq "3.8") { # echo "add_dir_str=${{ matrix.python-version }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # } elseif ("${{ matrix.python-version }}" -eq "3.9") { # echo "add_dir_str=${{ matrix.python-version }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # } elseif ("${{ matrix.python-version }}" -eq "3.10") { # echo "add_dir_str=cpython-310" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # } elseif ("${{ matrix.python-version }}" -eq "3.11") { # echo "add_dir_str=cpython-311" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # } elseif ("${{ matrix.python-version }}" -eq "3.12") { # echo "add_dir_str=cpython-312" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # } # - name: Set up Python ${{ matrix.python-version }} # uses: actions/setup-python@v5 # with: # python-version: ${{ matrix.python-version }} # - name: Update pip # run: | # python -m pip install --upgrade pip # pip install wheel # - name: Install Python dependencies # run: | # pip install -r requirements-tests.txt -r requirements-dev.txt # - name: Test with pytest # run: | # python -m pytest --verbose -n auto -W ignore --color=yes --cov=. --cov-report html # - name: Archive code coverage results # uses: actions/upload-artifact@v4 # with: # name: code-coverage-report-${{ env.py_version }}-windows # path: htmlcov # retention-days: 10 ================================================ FILE: .gitignore ================================================ .cache/* .coverage* .DS_Store .env .mypy_cache/ .pytest_cache .tox/ .venv/ .vscode/ *.pyc *.swp *~* build/* dist/ dist/* docs/build/* docs/doctrees/* docs/html/* docs/latex/* docs/source/generated/* docs/source/static/design docs/source/static/stalker_design*.vue htmlcov include/* local stalker.db* stalker.egg-info ================================================ FILE: CHANGELOG.rst ================================================ =============== Stalker Changes =============== 1.0.0 ===== * `Version.take_name` has been renamed to `Version.variant_name` to follow the industry standard (and then removed it completely as we now have `Variant` class for this). * `Task.depends` renamed to `Task.depends_on`. * `TaskDependency.task_depends_to` renamed to `TaskDependency.task_depends_on`. * Modernized Stalker as a Python project. It is now fully PEP 517 compliant. * Stalker now supports Python versions from 3.8 to 3.13. * Stalker is now SQLAlchemy 2.x compliant. * Stalker is now fully type hinted. * Added GitHub actions for CI/CD practices. * Updated validation messages to make them more consistently displaying the current type and the value of the validated attribute. * Added Makefile workflow to help creating a virtualenv, building, installing, releasing etc. actions much more easier. * Added `tox` config to run the test with Python 3.8 to 3.13. * Increased test coverage to 99.76%. * Updated documentation theme to `furo`. * Renamed `OSX` to `macOS` where ever it is mentioned. * `Scene` is now deriving from `Task`. * `Shot.sequences` is now `Shot.sequence` and it is many-to-one. * `Shot.scenes` is now `Shot.scene` and it is many-to-one. * Added the `Variant` class to allow variants to be approved and managed individually. * Added `Review.version` attribute to relate a `Version` instance to the review. * Removed the `Version.variant_name` attribute. The migration alembic script will create `Variant` instances for each `Version.variant_name` under the container `Task` to hold the information. * `Version._template_variables()` now finds the related `Asset`, `Shot` and `Sequence` values and passes them in the returned dictionary. * All the enum values handled with arbitrary string lists or integer values are now proper enum classes. As a result we now have `ScheduleConstraint`, `TimeUnit`, `ScheduleModel`, `DependencyTarget`, `TraversalDirection` enum classes which are removing the need of using fiddly strings as enum values. * `StatusList`s that are created for super classes can now be used with the derived classes, i.e. a status list created specifically for `Task` can now be used with `Asset`, `Shot`, `Sequence` and `Scenes` and any future `Task` derivatives. 0.2.27 ====== * Fixed a bug in ``Task.responsible`` attribute. This change has also slightly changed how the ``Task.responsible`` attribute works. It still comes from the parent if the ``Task.responsible`` is empty or None, but when queried it causes the attribute to be filled with parent data. This is a slight change, but may break some workflows. * Added ``ScheduleMixin.to_unit`` that converts the given ``seconds`` to the given ``unit`` in consideration of the given ``schedule_model``. 0.2.26 ====== * ``Task.percent_complete`` value is now properly calculated for a parent Task that contains a mixed type of "effort", "duration" and "length" based tasks. 0.2.25.1 ======== * **Update:** Updated the ``.travis.yml`` file to use PostgreSQL 13.3 and Ubuntu 20.04 Focal Fossa. * **Update:** Updated the ``upload_to_pypi`` command to follow the current Python packaging guide. * **Update:** Migrated from ``TravisCI.org`` to ``TravisCI.com``. * **Update:** Re-enabled concurrent testing in ``.travis.yml``. 0.2.25 ====== * **Update:** Stalker is now compatible with SQLAlchemy 1.4, psycopg2-binary 2.86 and Python 3.9+. But more work still needs to be done to make it SQLAlchemy 2.0 compatible. 0.2.24.3 ======== This release is again mainly related to fixing failing tests. 0.2.24.2 ======== This release is mainly related to cleaning up some complains that arose while testing the library. * **Fix:** Fixed two tests which are testing the ``stalker.db`` module to check the system against the correct Alembic revision id. * **Update:** Removed the unnecessary ``pytest.skip`` commands in the ``Repository`` class tests which were shipping the tests if the OS is not Windows. But they should work fine under all OSes. * **Update:** Updated all class documentation and removed the cancellation character (which was apparently not good for PEP8) * **Fix:** Fixed some warnings about some regular expressions. 0.2.24.1 ======== * **Fix:** Fixed ``stalker.db`` module to check for the correct Alembic revision id. 0.2.24 ====== * **New:** ``Repository`` instances now have a ``code`` attribute which is used for generating the environment variables where in previous versions the ``id`` attribute has been used which caused difficulties in transferring the data to a different installation of Stalker. Also to make the system backwards compatible, Stalker will still set the old ``id`` based environment variables. But when asked for an environment variable it will return the ``code`` based one. The ``code`` argument as usual has to be initialized on ``Repository`` instance creation. That's why this version is slightly backwards incompatible and needs the database to be updated with Alembic (with the command ``alembic update head``). * **Fix:** ``Repository`` methods ``is_in_repo`` and ``find_repo`` are now case insensitive for Windows paths. * **Update:** Updated ``Project`` class documentation and included information about what is going to be deleted or how the delete operation will be cascaded when a ``Project`` instance is deleted. 0.2.23 ====== * **Update:** Updated the ``setup.py`` to require ``psycopg2-binary`` instead of ``psycopg2``. Also updated the configuration files for Docker and Travis. This changes the requirement of psycopg2 to psycopg2-binary, which will make it easier to get the installation to complete on e.g. CentOS 7 without requiring pg_config. 0.2.22 ====== * **Fix:** Fixed ``TaskJugglerScheduler.schedule()`` method to correctly decode byte data from ``sys.stderr`` to string for Python 3.x. * **Fix:** Fixed a couple of tests for TaskJuggler. * **Update:** Updated Classifiers information in ``setup.py``, removed Python versions 2.6, 3.0, 3.1 and 3.2 from supported Python versions. * **Fix:** Removed Python 3.3 from TravisCI build which is not supported by ``pytest`` apparently. * **Update:** Updated TravisCI config and removed Python 2.6 and added Python 3.6. * **Update:** Added a test case for an edge usage of FilenameTemplate. * **Update:** Updated .gitignore file to ignore PyTest cache folder. * **Update:** Updated the License file to correctly reflect the project license of LGPLv3. * **Update:** Update copyright information. * **New:** Created ``make_html.bat`` for Windows. * **New:** Added support for Python wheel. 0.2.21 ====== * **New:** Switched from ``nose`` + ``unittest`` to ``pytest`` as the main testing framework (with ``pytest-xdist`` tests complete 4x faster). * **New:** Added ``DBSession.save()`` shortcut method for convenience which does an ``add`` or ``add_all`` (depending to the input) followed by a ``commit`` at once. * **Update:** Updated the about page for a more appealing introduction to the library. * **New:** Stalker now creates default ``StatusList`` for ``Project`` instances on database initialization. * **Update:** SQLite3 support is back. In fact it was newer gone. For simplicity of first time users the default database is again SQLite3. It was dropped for the sake of adding more PostgreSQL oriented features. But then it is recognized that the system can handle both. Though a two new Variant had to be created for JSON and Datetime columns. * **Update:** With the reintroduction of SQLite3, the new JSON type column in ``WorkingHours`` class has been upgraded to support SQLite3. So with SQLite3 the column stores the data as TEXT but seamlessly convert them to JSON when ORM loads or commits the data. * **New:** Added ``ConfigBase`` as a base class for ``Config`` to let it be used in other config classes. * **Fix:** Fixed ``testing.create_db()`` and ``testing.drop_db()`` to fallback to ``subprocess.check_call`` method for Python 2.6. * **Fix:** Fixed ``stalker.models.auth.User._validate_password()`` method to work with Python 2.6. * **Update:** Updated all of the tests to use ``pytest`` style assertions to support Python 2.6 along with 2.7 and 3.0+. * **Fix:** Fixed ``stalker.db.check_alembic_version()`` function to invalidate the connection, so it is not possible to continue with the current session, preventing users to ignore the raised ``ValueError`` when the ``alembic_version`` of the database is not matching the ``alembic_version`` of Stalker's current version. 0.2.20 ====== * **New:** Added ``goods`` attribute to the ``Client`` class. To allow special priced ``Goods`` to be created for individual clients. * **Fix:** The ``WorkingHours`` class is now derived from ``Entity`` thus it is not stored in a ``PickleType`` column in ``Studio`` anymore. (issue: #44) * **Update:** Updated ``appveyor.yml`` to match ``travis.yml``. 0.2.19 ====== * **Update:** Updated the ``stalker.config.Config.database_engine_settings`` to point the test database. * **Fix:** Fixed a bug in ``stalker.testing.UnitTestDBBase.setUp()`` where it was not considering the existence of the ``STALKER_PATH`` environment variable while doing the tests. * **Update:** Removed debug message from ``db.setup()`` which was revealing the database password. * **Update:** Updated the ``UnitTestDBBase``, it now creates its own test database, which allows all the tests to run in an individual database. Thus, the tests can now be run in ``multiprocess`` mode which speeds things a lot. * **Fix:** Removed any module level imports of ``stalker.defaults`` variable, which can be changed by a Studio (or by tests) and should always be refreshed. * **Update:** Removed the module level import of the ``stalker.db.session.DBSession`` in ``stalker.db``, so it is not possible to use ``db.DBSession`` anymore. * **Update:** The import statements that imports ``stalker.defaults`` moved to local scopes to allow runtime changes to the ``defaults`` to be reflected correctly. * **Update:** Added Python fall back mode to ``stalker.shot.Shot._check_code_availability()`` which runs when there is no database. * **Update:** ``stalker.models.task.TimeLog._validate_task()`` is now getting the ``Status`` instances from the ``StatusList`` that is attached to the ``Task`` instance instead of doing a database query. * **Update:** ``stalker.models.task.TimeLog._validate_resource()`` is now falling back to a Python implementation if there is no database connection. * **Update:** ``stalker.models.task.Task._total_logged_seconds_getter()`` is now hundreds of times faster when there is a lot of ``TimeLog`` instances attached to the ``Task``. * **Update:** In ``stalker.models.task.Task`` class, methods those were doing a database query to get the required ``Status`` instances are now using the attached ``StatusList`` instance to get them. * **Fix:** A possible ``auto_flush`` is prevented in ``Ticket`` class. * **Update:** ``Version.latest_version`` property is now able to fall back to a pure Python implementation when there is no database connection. * **Update:** The default log level has been increased from ``DEBUG`` to ``INFO``. * **Update:** In an attempt to speed up tests, a lot of tests that doesn't need an active Database has been updated to use the regular ``unittest.TestCase`` instead of ``stalker.testing.TestBase`` and as a result running all of the tests are now 2x faster. * **Fix:** ``TimeLogs`` are now correctly reflected in UTC in a tj3 file. * **Fix:** Fixed a lot of tests which were raising Warnings and surprisingly considered as Errors in TravisCI. * **Fix:** ``to_tjp`` methods of SOM classes that is printing a Datetime object are now printing the dates in UTC. * **Fix:** Fixed ``stalker.models.auth.Permission`` to be hashable for Python 3. * **Fix:** Fixed ``stalker.models.auth.AuthenticationLog`` to be sortable for Python 3. * **Fix:** Fixed ``stalker.models.version.Version.latest_version`` property for Python 3. * **Fix:** Fixed tests of ``Permission`` class to check for correct exception messages in Python 3. * **Update:** Replaced the ``assertEquals`` and ``assertNotEquals`` calls which are deprecated in Python 3 with ``assertEqual`` and ``assertNotEquals`` calls respectively. * **Fix:** Fixed tests for ``User`` and ``Version`` classes to not to cause the ``id column is None`` warnings of SQLAlchemy to be emitted. 0.2.18 ====== * **Update:** Support for DB backends other than Postgresql has been dropped. This is done to greatly benefit from a code that is highly optimized only for one DB backend. With This all of the tests should be inherited from the ``stalker.tests.UnitTestDBBase`` class. * **New:** All the DateTime fields in Stalker are now TimeZone aware and Stalker stores the DateTime values in UTC. Naive datetime values are not supported anymore. You should use a library like ``pytz`` to supply timezone information as shown below:: import datetime import pytz from stalker import db, SimpleEntity new_simple_entity = SimpleEntity( name='New Simple Entity', date_created = datetime.datetime.now(tzinfo=pytz.utc) ) * **Fix:** The default values for ``date_created`` and ``date_updated`` has now been properly set to a partial function that returns the current time. * **Fix:** Previously it was possible to enter two TimeLogs for the same resource in the same datetime range by committing the data from two different sessions simultaneously. Thus the database was not aware that it should prevent that. Now with the new PostgreSQL only implementation and the ``ExcludeConstraint`` of PostgreSQL an ``IntegrityError`` is raised by the database backend when something like that happens. * **Update:** All the tests those are checking the system against an Exception is being raised or not are now checking also the exception message. * **Update:** In the ``TimeLog`` class, the raised ``OverBookedException`` message has now been made clear by adding the start and end date values of the clashing TimeLog instance. * **Update:** Removed the unnecessary ``computed_start`` and ``computed_end`` columns from ``Task`` class, which are already defined in the ``DateRangeMixin`` which is a super for the Task class. 0.2.17.6 ======== * **Fix:** Fixed a bug in ``ProjectMixin`` where a proper cascade was not defined and the ``Delete`` operations to the ``Projects`` table were not cascaded to the mixed-in classes properly. 0.2.17.5 ======== * **Fix:** Fixed the ``image_format`` attribute implementation in ``Shot`` class. Now it will not copy the value of ``Project.image_format`` directly on ``__init__`` but instead will only store the value if the ``image_format`` argument in ``__init__`` or ``Shot.image_format`` attribute is set to something. 0.2.17.4 ======== * **Update:** Updated the comment sections of all of the source files to correctly show that Stalker is LGPL v3 (not v2.1). 0.2.17.3 ======== * **New:** Added ``Shot.fps`` attribute to hold the fps information per shot. * **Update:** Added the necessary alembic revision to reflect the changes in the ``Version_Inputs`` table. 0.2.17.2 ======== * **Fix:** Fixed ``Version_Inputs`` table to correctly take care of ``DELETE``s on the ``Versions`` table. So now it is possible to delete a ``Version`` instance without first cleaning the ``Link`` instances that is related to that ``Version`` instance. * **Update:** Changed the ``id`` attribute name from ``info_id`` to ``log_id`` in ``AuthenticationLog`` class. * **Update:** Started moving towards PostgreSQL only implementation. Merged the ``DatabaseModelTester`` class and ``DatabaseModelsPostgreSQLTester`` class. * **Fix:** Fixed an autoflush issue in ``stalker.models.review.Review.finalize_review_set()``. 0.2.17.1 ======== * **Fix:** Fixed alembic revision 0.2.17 ====== * **New:** Added ``AuthenticationLog`` class to hold user login/logout info. * **New:** Added ``stalker.testing`` module to simplify testing setup. 0.2.16.4 ======== * **Fix:** Fixed alembic revision. 0.2.16.3 ======== * **New:** ``ProjectUser`` now also holds a new field called ``rate``. The default value is equal to the ``ProjectUser.user.rate``. It is a way to hold the rate of a user on a specific project. * **New:** Added the ``Invoice`` class. * **New:** Added the ``Payment`` class. * **New:** Added two simple mixins ``AmountMixin`` and ``UnitMixin``. * **Update:** ``Good`` class is now mixed in with the new ``UnitMixin`` class. * **Update:** ``BudgetEntry`` class is now mixed in with the new ``AmountMixin`` and ``UnitMixin`` classes. 0.2.16.2 ======== * **New:** ``Group`` permissions can now be set on ``__init__()`` with the ``permissions`` argument. 0.2.16.1 ======== * **Fix:** As usual after a new release that changes database schema, fixed the corresponding Alembic revision (92257ba439e1). 0.2.16 ====== * **New:** ``Budget`` instances are now statusable. * **Update:** Updated documentation to include database migration instructions with Alembic. 0.2.15.2 ======== * **Fix:** Fixed a typo in the error message in ``User._validate_email_format()`` method. * **Fix:** Fixed a query-invoked auto-flush problem in ``Task.update_parent_statuses()`` method. 0.2.15.1 ======== * **Fix:** Fixed alembic revision (f2005d1fbadc), it will now drop any existing constraints before re-creating them. And the downgrade function will not remove the constraints. 0.2.15 ====== * **New:** ``db.setup()`` now checks for ``alembic_version`` before setting up a connection to the database and raises a ``ValueError`` if the database alembic version is not matching the current implementation of Stalker. * **Fix:** ``db.init()`` sets the ``created_by`` and ``updated_by`` attributes to ``admin`` user if there is one while creating entity statuses. * **New:** Created ``create_sdist.cmd`` and ``upload_to_pypi.cmd`` for Windows. * **New:** ``Project`` to ``Client`` relation is now a many-to-many relation, thus it is possible to set multiple Clients for each project with each client having their own roles in a specific project. * **Update:** ``ScheduleMixin.schedule_timing`` attribute is now Nullable. * **Update:** ``ScheduleMixin.schedule_unit`` attribute is now Nullable. 0.2.14 ====== * **Fix:** Fixed ``Task.path`` to always return a path with forward slashes. * **New:** Introducing ``EntityGroups`` that lets one to group a bunch of ``SimpleEntity`` instances together, it can be used in grouping tasks even if they are in different places on the project task hierarchy or even in different projects. * **Update:** ``Task.percent_complete`` is now correctly calculated for a ``Duration`` based task by using the ``Task.start`` and ``Task.end`` attribute values. * **Fix:** Fixed ``stalker.models.task.update_time_log_task_parents_for_end()`` event to work with SQLAlchemy v1.0. * **New:** Added an option called ``__dag_cascade__`` to the ``DAGMixin`` to control cascades on mixed in class. The default value is "all, delete". Change it to "save-update, merge" if you don't want the children also be deleted when the parent is deleted. * **Fix:** Fixed a bug in ``Version`` class that occurs when a version instance that is a parent of other version instances is deleted, the child versions are also deleted (fixed through DAGMixin class). 0.2.13.3 ======== * **Fix:** Fixed a bug in ``Review.finalize_review_set()`` for tasks that are sent to review and still have some extra time were not clamped to their total logged seconds when the review set is all approved. 0.2.13.2 ======== * **New:** Removed ``msrp``, ``cost`` and ``unit`` arguments from ``BudgetEntry.__init__()`` and added a new ``good`` argument to get all of the data from the related ``Good`` instance. But the ``msrp``, ``cost`` and ``unit`` attributes of ``BudgetEntry`` class are still there to store the values that may not correlate with the related ``Good`` in future. 0.2.13.1 ======== * **Fix:** Fixed a bug in ``Review.finalize_review_set()`` which causes Task instances to not to get any status update if the revised task is a second degree dependee to that particular task. 0.2.13 ====== * **New:** ``Project`` instances can now have multiple repositories. Thus the ``repository`` attribute is renamed to ``repositories``. And the order of the items in the ``repositories`` attribute is restored correctly. * **New:** ``stalker.db.init()`` now automatically creates environment variables for each repository in the database. * **New:** Added a new ``after_insert`` which listens ``Repository`` instance ``insert`` instances to automatically add environment variables for the newly inserted repositories. * **Update:** ``Repository.make_relative()`` now handles paths with environment variables. * **Fix:** Fixed ``TaskJugglerScheduler`` to correctly generate task absolute paths for PostgreSQL DB. * **New:** ``Repository.path`` is now writable and sets the correct path (``linux_path``, ``windows_path``, or ``osx_path``) according to the current system. * **New:** Setting either of the ``Repository.path``, ``Repository.linux_path``, ``Repository.windows_path``, ``Repository.osx_path`` attributes will update the related environment variable if the system and attribute are matching to each other, setting the ``linux_path`` on Linux or setting the ``windows_path`` on Windows or setting the ``osx_path`` on OSX will update the environment variable. * **New:** Added ``Task.good`` attribute to easily connect tasks to ``Good`` instances. * **New:** Added new methods to ``Repository`` to help managing paths: * ``Repository.find_repo()`` to find a repo from a given path. This is a class method so it can be directly used with the Repository class. * ``Repository.to_os_independent_path()`` to convert the given path to a OS independent path which uses environment variables. Again this is a class method too so it can be directly used with the Repository class. * ``Repository.env_var`` a new property that returns the related environment variable name of a repo instance. This is an instance property:: .. code=block:: python # with default settings repo = Repository(...) repo.env_var # should print something like "REPO131" which will be used # in paths as "$REPO131" * **Fix:** Fixed ``User.company_role`` attribute which is a relationship to the ``ClienUser`` to cascade ``all, delete-orphan`` to prevent AssertionErrors when a Client instance is removed from the ``User.companies`` collection. 0.2.12.1 ======== * **Update:** ``Version`` class is now mixed with the ``DAGMixin``, so all the parent/child relation is coming from the DAGMixin. * **Update:** ``DAGMixin.walk_hierarchy()`` is updated to walk the hierarchy in ``Depth First`` mode by default (method=0) instead of ``Breadth First`` mode (method=1). * **Fix:** Fixed ``alembic_revision`` on database initialization. 0.2.12 ====== * **Fix:** Fixed importing of ``ProjectUser`` directly from ``stalker`` namespace. * **Fix:** Fixed importing of ``ClientUser`` directly from ``stalker`` namespace. * **New:** Added two new columns to the ``BudgetEntry`` class to allow more detailed info to be hold. * **New:** Added a new Mixin called ``DAGMixin`` to create parent/child relation between mixed in class. * **Update:** The ``Task`` class is now mixed with the ``DAGMixin``, so all the parent/child relation is coming from the DAGMixin. * **New:** Added a new class called ``Good`` to hold details about the commercial items/services sold in the Studio. * **New:** Added a new class called ``PriceList`` to create price lists from Goods. 0.2.11 ====== * **New:** User instances now have a new attribute called ``rate`` to track their cost as a resource. * **New:** Added two new classes called ``Budget`` and ``BudgetEntry`` to record Project budgets in a simple way. * **New:** Added a new class called **Role** to manage user roles in different Departments, Clients and Projects. * **New:** User and Department relation is updated to include the role of the user in that department in a more flexible way by using the newly introduced Role class and some association proxy tricks. * **New:** Also updated the User to Project relation to include the role of the user in that Project by using an associated Role class. * **Update:** Department.members attribute is renamed to **users** (and removed the *synonym* property). * **Update:** Removed ``Project.lead`` attribute use ``Role`` instead. * **Update:** Removed ``Department.lead`` attribute use ``Role`` instead. * **Update:** Because the ``Project.lead`` attribute is removed, it is now possible to have tasks with no responsible. * **Update:** Client to User relation is updated to use an association proxy which makes it possible to set a Role for each User for each Client it is assigned to. * **Update:** Renamed User.company to User.companies as the relation is now able to handle more than one Client instances for the User company. * **Update:** Task Status Workflow has been updated to convert the status of a DREV task to HREV instead of WIP when the dependent tasks has been set to CMPL. Also the timing of the task is expanded by the value of ``stalker.defaults.timing_resolution`` if it doesn't have any effort left (generally true for CMPL tasks) to allow the resource to review and decide if he/she needs more time to do any update on the task and also give a chance of setting the Task status to WIP by creating a time log. * **New:** It is now possible to schedule only a desired set of projects by passing a **projects** argument to the TaskJugglerScheduler. * **New:** Task.request_review() and Review.finalize() will not cap the timing of the task until it is approved and also Review.finalize() will extend the timing of the task if the total timing of the given revisions are not fitting in to the left timing. 0.2.10.5 ======== * **Update:** TaskJuggler output is now written to debug output once per line. 0.2.10.4 ======== * **New:** '@' character is now allowed in Entity nice name. 0.2.10.3 ======== * **New:** '@' character is now allowed in Version take names. 0.2.10.2 ======== * **Fix:** Fixed a bug in ``stalker.models.schedulers.TaskJugglerScheduler._create_tjp_file_content()`` caused by non-ascii task names. * **Fix:** Removed the residual ``RootFactory`` class reference from documentation. * **New:** Added to new functions called ``utc_to_local`` and ``local_to_utc`` for UTC to Local time and vice versa conversion. 0.2.10.1 ======== * **Fix:** Fixed a bug where for a WIP Task with no time logs (apparently something went wrong) and no dependencies using ``Task.update_status_with_dependent_statuses()`` will convert the status to RTS. 0.2.10 ====== * **New:** It is now possible to track the Edit information per Shot using the newly introduced ``source_in``, ``source_out`` and ``record_in`` along with existent ``cut_in`` and ``cut_out`` attributes. 0.2.9.2 ======= * **Fix:** Fixed MySQL initialization problem in ``stalker.db.init()``. 0.2.9.1 ======= * **New:** As usual, after a new release, fixed a bug in ``stalker.db.create_entity_statuses()`` caused by the behavioral change of the ``map`` built-in function in Python 3. 0.2.9 ===== * **New:** Added a new class called ``Daily`` which will help managing ``Version`` outputs (Link instances including Versions itself) as a group. * **New:** Added a new status list for ``Daily`` class which contains two statuses called "Open" and "Closed". * **Update:** Setting the ``Version.take_name`` to a value other than a string will now raise a ``TypeError``. 0.2.8.4 ======= * **Fix:** Fixed ``SimpleEntity._validate_name()`` method for unicode strings. 0.2.8.3 ======= * **Fix:** Fixed str/unicode errors due to the code written for Python3 compatibility. * **Update:** Removed ``Task.is_complete`` attribute. Use the status "CMPL" instead of this attribute. 0.2.8.2 ======= * **Fix:** Fixed ``stalker.db.create_alembic_table()`` again to prevent extra row insertion. 0.2.8.1.1 ========= * **Fix:** Fixed ``stalker.db.create_alembic_table()`` function to handle the situation where the table is already created. 0.2.8.1 ======= * **Fix:** Fixed ``stalker.db.create_alembic_table()`` function, it is not using the ``alembic`` library anymore to create the ``alembic_version`` table, which was the proper way of doing it but it created a lot of problems when Stalker is installed as a package. 0.2.8 ===== * **Update:** Stalker is now Python3 compatible. * **New:** Added a new class called ``Client`` which can be used to track down information about the clients of ``Projects``. Also added ``Project.client`` and ``User.company`` attributes which are referencing a Client instance allowing to add clients as normal users. * **New:** ``db.init()`` now creates ``alembic_version`` table and stamps the most recent version number to that table allowing newly initialized databases to be considered in head revision. * **Fix:** Fixed ``Version._format_take_name()`` method. It is now possible to use multiple underscore characters in ``Version.take_name`` attribute. 0.2.7.6 ======= * **Update:** Removed ``TimeLog._expand_task_schedule_timing()`` method which was automatically adjusting the ``schedule_timing`` and ``schedule_unit`` of a Task to total duration of the TimeLogs of that particular task, thus increasing the schedule info with the entered time logs. But it was setting the ``schedule_timing`` to 0 in some certain cases and it was unnecessary because the main purpose of this method was to prevent TaskJuggler to raise any errors related to the inconsistencies between the schedule values and the duration of TimeLogs and TaskJuggler has never given a real error about that situation. 0.2.7.5 ======= * **Fix:** Fixed Task parent/child relationship, previously setting the parent of a task to None was cascading a delete operation due to the "all, delete-orphan" setting of the Task parent/child relationship, this is updated to be "all, delete" and it is now safe to set the parent to None without causing the task to be deleted. 0.2.7.4 ======= * **Fix:** Fixed the following columns column type from String to Text: * Permissions.class_name * SimpleEntities.description * Links.full_path * Structures.custom_template * FilenameTemplates.path * FilenameTemplates.filename * Tickets.summary * Wiki.title * Wiki.content and specified a size for the following columns: * SimpleEntities.html_class -> String(32) * SimpleEntities.html_style -> String(32) * FilenameTemplates.target_entity_type -> String(32) to be compatible with MySQL. * **Update:** It is now possible to create TimeLog instances for a Task with PREV status. 0.2.7.3 ======= * **Fix:** Fixed ``Task.update_status_with_dependent_statuses()`` method for a Task where there is no dependency but the status is DREV. Now calling ``Task.update_status_with_dependent_statuses()`` will set the status to RTS if there is no ``TimeLog`` for that task and will set the status to WIP if the task has time logs. 0.2.7.2 ======= * **Update:** ``TaskJugglerScheduler`` is now 466x faster when dumping all the data to TJP file. So with this new update it is taking only 1.5 seconds to dump ~20k tasks to a valid TJP file where it was around ~10 minutes in previous implementation. The speed enhancements is available only to PostgreSQL dialect for now. 0.2.7.1 ======= * **Fix:** Fixed TimeLog output in one line per task in ``Task.to_tjp()``. * **New:** Added ``TaskJugglerScheduler`` now accepts a new argument called ``compute_resources`` which when set to True will also consider `Task.alternative_resources` attribute and will fill ``Task.computed_resources`` attribute for each Task. With ``TaskJugglerScheduler`` when the total number of Task is around 15k it will take around 7 minutes to generate this data, so by default it is set to False. 0.2.7 ===== * **New:** Added ``efficiency`` attribute to ``User`` class. See User documentation for more info. 0.2.6.14 ======== * **Fix:** Fixed an **autoflush** problem in ``Studio.schedule()`` method. 0.2.6.13 ======== * **New:** Added ``Repository.make_relative()`` method, which makes the given path to relative to the repository root. It considers that the path is already in the repository. So for now, be careful about not to pass a path outside of the repository. 0.2.6.12 ======== * **Update:** ``TaskJugglerScheduler.schedule()`` method now uses the ``Studio.start`` and ``Studio.end`` values for the scheduling range instead of the hardcoded dates. 0.2.6.11 ======== * **Update:** ``Task.create_time_log()`` method now returns the created ``TimeLog`` instance. 0.2.6.10 ======== * **Fix:** Fixed an ``autoflush`` issue in ``Task.update_status_with_children_statuses()`` method. 0.2.6.9 ======= * **Update:** ``Studio.is_scheduling`` and ``Studio.is_scheduling_by`` attributes will not be updated or checked at the beginning of the ``Studio.schedule()`` method. It is the duty of the user to check those attributes before calling ``Studio.schedule()``. This is done in this way because without being able to do a db commit inside ``Studio.schedule()`` method (which is the case with transaction managers which may be used in web applications like **Stalker Pyramid**) it is not possible to persist and thus use those variables. So, to be able to use those attributes meaningfully the user should set them. Those variables will be set to False and None accordingly by the ``Studio.schedule()`` method after the scheduling is done. 0.2.6.8 ======= * **Fix:** Fixed a deadlock in ``TaskJugglerScheduler.schedule()`` method related with the ``Popen.stderr.readlines()`` blocking the TaskJuggler process without being able to read the output buffer. 0.2.6.7 ======= * **Update:** ``TaskJugglerScheduler.schedule()`` is now using bulk inserts and updates which is way faster than doing it with pure Python. Use ``parsing_method`` (0: SQL, 1: Python) to choose between SQL or Pure Python implementation. Also updated ``Studio.schedule()`` to take in a ``parsing_method`` parameter. 0.2.6.6 ======= * **Update:** The ``cut_in``, ``cut_out`` and ``cut_duration`` attribute behaviour and the attribute order is updated in ``Shot`` class. So, if three of the values are given, then the ``cut_duration`` attribute value will be calculated from ``cut_in`` and ``cut_out`` attribute values. In any case ``cut_out`` precedes ``cut_duration``, and if none of them given ``cut_in`` and ``cut_duration`` values will default to 1 and ``cut_out`` will be calculated by using ``cut_in`` and ``cut_duration``. 0.2.6.5 ======= * **New:** Entity to Note relation is now Many-to-Many. So one Note can now be assigned more than one Entity. * **New:** Added alembic revision for ``Entity_Notes`` table creation and data migration from ``Notes`` table to ``Entity_Notes`` table. So all notes are preserved. * **Fix:** Fixed ``Shot.cut_duration`` attribute initialization on ``Shot`` instances restored from database. * **Fix:** Fixed ``Studios.is_scheduling_by`` relationship configuration, which was wrongly referencing the ``Studios.last_scheduled_by_id`` column instead of ``Studios.is_scheduled_by_id`` column. 0.2.6.4 ======= * **New:** Added a ``Task.review_set(review_number)`` method to get the desired set of reviews. It will return the latest set of reviews if ``review_number`` is skipped or it is None. * **Update:** Removed ``Task.approve()`` it was making things complex than it should be. 0.2.6.3 ======= * **Fix:** Added ``Page`` to ``class_names`` in ``db.init()``. * **Fix:** Fixed ``TimeLog`` tjp representation to use bot the ``start`` and ``end`` date values instead of the ``start`` and ``duration``. This is much better because it is independent from the timing resolution settings. 0.2.6.2 ======= * **Fix:** Fixed ``stalker.models.studio.schedule()`` method, and prevented it to call ``DBSession.commit()`` which causes errors if there is a transaction manager. * **Fix:** Fixed ``stalker.models._parse_csv_file()`` method for empty computed resources list. 0.2.6.1 ======= * **New:** ``stalker.models.task.TimeLog`` instances are now checking if the dependency relation between the task that receives the time log and the tasks that the task depends on will be violated in terms of the start and end dates and raises a ``DependencyViolationError`` if it is the case. 0.2.6 ===== * **New:** Added ``stalker.models.wiki.Page`` class, for holding a per Project wiki. 0.2.5.5 ======= * **Fix:** ``Review.task`` attribute now accepts None but this is mainly done to allow its relation to the ``Task`` instance can be broken when it needs to be deleted without issuing a database commit. 0.2.5.4 ======= * **Update:** The following column names are updated: * ``Tasks._review_number`` to ``Tasks.review_number`` * ``Tasks._schedule_seconds`` to ``Tasks.schedule_seconds`` * ``Tasks._total_logged_seconds`` to ``Tasks.total_logged_seconds`` * ``Reviews._review_number`` to ``Reviews.review_number`` * ``Shots._cut_in`` to ``Shots.cut_in`` * ``Shots._cut_out`` to ``Shots.cut_out`` Also updated alembic migration to create columns with those names. * **Update:** Updated Alembic revision ``433d9caaafab`` (the one related with stalker 2.5 update) to also include following updates: * Create StatusLists for Tasks, Asset, Shot and Sequences and add all the Statuses in the Task Status Workflow. * Remove ``NEW`` from all of the status lists of Task, Asset, Shot and Sequence. * Update all the ``PREV`` tasks to ``WIP`` to let them use the new Review Workflow. * Update the ``Tasks.review_number`` to 0 for all tasks. * Create StatusLists and Statuses (``NEW``, ``RREV``, ``APP``) for Reviews. * Remove any other status then defined in the Task Status Workflow from Task, Asset, Shot and Sequence status list. 0.2.5.3 ======= * **Fix:** Fixed a bug in ``Task`` class where trying to remove the dependencies will raise an ``AttributeError`` caused by the ``Task._previously_removed_dependent_tasks`` attribute. 0.2.5.2 ======= * **New:** Task instances now have two new properties called ``path`` and ``absolute_path``. As in Version instances, these are the rendered version of the related FilenameTemplate object in the related Project. The ``path`` attribute is Repository root relative and ``absolute_path`` is the absolute path including the OS dependent Repository path. * **Update:** Updated alembic revision with revision number "433d9caaafab" to also create Statuses introduced with Stalker v0.2.5. 0.2.5.1 ======= * **Update:** ``Version.__repr__`` results with a more readable string. * **New:** Added a generalized generator called ``stalker.models.walk_hierarchy()`` that walks and yields the entities over the given attribute in DFS or BFS fashion. * **New:** Added ``Task.walk_hierarchy()`` which iterates over the hierarchy of the task. It walks in a breadth first fashion. Use ``method=0`` to walk in depth first. * **New:** Added ``Task.walk_dependencies()`` which iterates over the dependencies of the task. It walks in a breadth first fashion. Use ``method=0`` to walk in depth first. * **New:** Added ``Version.walk_hierarchy()`` which iterates over the hierarchy of the version. It walks in a depth first fashion. Use ``method=1`` to walk in breadth first. * **New:** Added ``Version.walk_inputs()`` which iterates over the inputs of the version. It walks in a depth first fashion. Use ``method=1`` to walk in breath first. * **Update:** ``stalker.models.check_circular_dependency()`` function is now using ``stalker.models.walk_hierarchy()`` instead of recursion over itself, which makes it more robust in deep hierarchies. * **Fix:** ``db.init()`` now updates the statuses of already created status lists for ``Task``, ``Asset``, ``Shot`` and ``Sequence`` classes. 0.2.5 ===== * **Update:** ``Revision`` class is renamed to ``Review`` and introduced a couple of new attributes. * **New:** Added a new workflow called "Task Review Workflow". Please see the documentation about the new workflow. * **Update:** ``Task.responsible`` attribute is now a list which allows multiple responsible to be set for a ``Task``. * **New:** Because of the new "Task Review Workflow" task statuses which are normally created in Stalker Pyramid are now automatically created in Stalker database initialization. The new statuses are **Waiting For Dependency (WFD)**, **Ready To Start (RTS)**, **Work In Progress (WIP)**, **Pending Review (PREV)**, **Has Revision (HREV)**, **On Hold (OH)**, **Stopped (STOP)** and **Completed (CMPL)** are all used in ``Task``, ``Asset``, ``Shot`` and ``Sequence`` status lists by default. * **New:** Because of the new "Task Review Workflow" also a status list for ``Review`` class is created by default. It contains the statuses of **New (NEW)**, **Requested Revision (RREV)** and **Approved (APP)**. * **Fix:** ``Users.login`` column is now unique. * **Update:** Ticket workflow in config is now using the proper status names instead of the lower case names of the statuses. * **New:** Added a new exception called **StatusError** which states the entity status is not suitable for the action it is applied to. * **New:** ``Studio`` instance now stores the scheduling state to the database to prevent two scheduling process to override each other. It also stores the last schedule message and the last schedule date and the id of the user who has done the scheduling. * **New:** The **Task Dependency** relation is now using an **Association Object** instead of just a **Secondary Table**. The ``Task.depends`` and ``Task.dependent_of`` attributes are now *association_proxies*. Also added extra parameters like ``dependency_target``, ``gap_timing``, ``gap_unit`` and ``gap_model`` to the dependency relation. So all of the dependency relations are now able to hold those extra information. Updated the ``task_tjp_template`` to reflect the details of the dependencies that a task has. * **New:** ``ScheduleMixin`` class now has some default class attributes that will allow customizations in inherited classes. This is mainly done for ``TaskDependency`` class and for ``the gap_timing``, ``gap_unit``, ``gap_model`` attributes which are in fact synonyms of ``schedule_timing``, ``schedule_unit`` and ``schedule_model`` attributes coming from the ``ScheduleMixin`` class. So by using the ``__default_schedule_attr_name__`` Stalker is able to display error messages complaining about ``gap_timing`` attribute instead of ``schedule_timing`` etc. * **New:** Updating a task by calling ``Task.request_revision()`` will now set the ``TaskDependency.dependency_target`` to **'onstart'** for tasks those are depending to the revised task and updated to have a status of **DREV**, **OH** or **STOP**. Thus, TaskJuggler will be able to continue scheduling these tasks even if the tasks are now working together. * **Update:** Updated the TaskJuggler templates to make the tjp output a little bit more readable. * **New:** ``ScheduleMixin`` now creates more localized (to the mixed in class) column and enum type names in the mixed in classes. For example, it creates the ``TaskScheduleModel`` enum type for ``Task`` class and for ``TaskDependency`` it creates ``TaskDependencyGapModel`` with the same setup following the ``{{class_name}}{{attr_name}}Model`` template. Also it creates ``schedule_model`` column for ``Task``, and ``gap_model`` for ``TaskDependency`` class. * **Update:** Renamed the ``TaskScheduleUnit`` enum type name to ``TimeUnit`` in ``ScheduleMixin``. 0.2.4 ===== * **New:** Added new class called ``Revision`` to hold info about Task revisions. * **Update:** Renamed ``ScheduleMixin`` to ``DateRangeMixin``. * **New:** Added a new mixin called ``ScheduleMixin`` (replacing the old one) which adds attributes like ``schedule_timing``, ``schedule_unit``, ``schedule_model`` and ``schedule_constraint``. * **New:** Added ``Task.tickets`` and ``Task.open_tickets`` properties. * **Update:** Removed unnecessary arguments (``project_lead``, ``tasks``, ``watching``, ``last_login``) from User class. * **Update:** The ``timing_resolution`` attribute is moved from the ``DateRangeMixin`` to ``Studio`` class. So instances of classes like ``Project`` or ``Task`` will not have their own timing resolution anymore. * **New:** The ``Studio`` instance now overrides the values on ``stalker.defaults`` on creation and on load, and also the ``db.setup()`` function lets the first ``Studio`` instance that it finds to update the defaults. So it is now possible to use ``stalker.defaults`` all the time without worrying about the Studio settings. * **Update:** The ``Studio.yearly_working_days`` value is now always an integer. * **New:** Added a new method ``ScheduleMixin.least_meaningful_time_unit()`` to calculate the most appropriate timing unit and the value of the given seconds which represents an interval of time. So it will convert 3600 seconds to 1 hours, and 8424000 seconds to 1 years if it represents working time (``as_working_time=True``) or 2340 hours if it is representing the calendar time. * **New:** Added a new method to ``ScheduleMixin`` called ``to_seconds()``. The ``to_seconds()`` method converts the given schedule info values (``schedule_timing``, ``schedule_unit``, ``schedule_model``) to seconds considering if the given ``schedule_model`` is work time based ('effort' or 'length') or calendar time based ('duration'). * **New:** Added a new method to ``ScheduleMixin`` called ``schedule_seconds`` which you may recognise from ``Task`` class. What it does is pretty much the same as in the ``Task`` class, it converts the given schedule info values to seconds. * **Update:** In ``DateRangeMixin``, when the ``start``, ``end`` or ``duration`` arguments given so that the duration is smaller then the ``defaults.timing_resolution`` the ``defaults.timing_resolution`` will be used as the ``duration`` and the ``end`` will be recalculated by anchoring the ``start`` value. * **New:** Adding a ``TimeLog`` to a ``Task`` and extending its schedule info values now will always use the least meaningful timing unit. So expanding a task from 16 hours to 18 hours will result a task with 2 days of schedule (considering the ``daily_working_hours = 9``). * **Update:** Moved the ``daily_working_hours`` attribute from ``Studio`` class to ``WorkingHours`` class as it was much related to this one then ``Studio`` class. Left a property with the same name in the ``Studio`` class, so it will still function as it was before but there will be no column in the database for that attribute anymore. 0.2.3.5 ======= * **Fix:** Fixed a bug in ``stalker.models.auth.LocalSession`` where stalker was complaining about "copy_reg" module, it seems that it is related to `this bug`_. .. _this bug: http://www.archivum.info/python-bugs-list@python.org/2007-04/msg00222.html 0.2.3.4 ======= * **Update:** Fixed a little bug in Link.extension property setter. * **New:** Moved the stalker.models.env.EnvironmentBase class to "Anima Tools" python module. * **Fix:** Fixed a bug in stalker.models.task.Task._responsible_getter() where it was always returning the greatest parents responsible as the responsible for the child task when the responsible is set to None for the child. * **New:** Added ``stalker.models.version.Version.naming_parents`` which returns a list of parents starting from the closest parent Asset, Shot or Sequence. * **New:** ``stalker.models.version.Version.nice_name`` now generates a name starting from the closest Asset, Shot or Sequence parent. 0.2.3.3 ======= * **New:** ``Ticket`` action methods (``resolve``, ``accept``, ``reassign``, ``reopen``) now return the created ``TicketLog`` instance. 0.2.3.2 ======= * **Update:** Added tests for negative or zero fps value in Project class. * **Fix:** Minor fix to ``schedule_timing`` argument in Task class, where IDEs where assuming that the value passed to the ``schedule_timing`` should be integer where as it accepts floats also. * **Update:** Removed ``bg_color`` and ``fg_color`` attributes (and columns) from Status class. Use SimpleEntity.html_class and SimpleEntity.html_style attributes instead. * **New:** Added ``Project.open_tickets`` property. 0.2.3.1 ======= * **Fix:** Fixed an inconvenience in SimpleEntity.__init__() when a date_created argument with a value is later than datetime.datetime.now() is supplied and the date_updated argument is skipped or given as None, then the date_updated attribute value was generated from datetime.datetime.now() this was causing an unnecessary ValueError. This is fixed by directly copying the date_created value to date_updated value when it is skipped or None. 0.2.3 ===== * **New:** SimpleEntity now have two new attributes called ``html_style`` and ``html_class`` which can be used in storing cosmetic html values. 0.2.2.3 ======= * **Update:** Note.content attribute is now a synonym of the Note.description attribute. 0.2.2.2 ======= * **Update:** Studio.schedule() now returns information about how much did it take to schedule the tasks. * **Update:** Studio.to_tjp() now returns information about how much did it take to complete the conversion. 0.2.2.1 ======= * **Fix:** Task.percent_complete() now calculates the percent complete correctly. 0.2.2 ===== * **Update:** Added cascade attributes to all necessary relations for all the classes. * **Update:** The Version class is not mixed with the StatusMixin anymore. So the versions are not going to be statusable anymore. Also created alembic revision (a6598cde6b) for that update. 0.2.1.2 ======= * **Update:** TaskJugglerScheduler and the Studio classes are now returning the stderr message out of their ``schedule()`` methods. 0.2.1.1 ======= * **Fix:** Disabled some deep debug messages on TaskJugglerScheduler._parse_csv_file(). * **Fix:** Fixed a flush issue related to the Task.parent attribute which is lazily loaded in Task._schedule_seconds_setter(). 0.2.1 ===== * **Fix:** As usual distutil thinks ``0.2.0`` is a lower version number than ``0.2.0.rc5`` (I should have read the documentation again and used ``0.2.0.c5`` instead of ``0.2.0.rc5``) so this is a dummy update to just to fix the version number. 0.2.0 ===== * **Update:** Vacation tjp template now includes the time values of the start and end dates of the Vacation instance. 0.2.0.rc5 ========= * **Update:** For a container task, ``Task.total_logged_seconds`` and ``Task.schedule_seconds`` attributes are now using the info of the child tasks. Also these attributes are cached to database, so instead of querying the child tasks all the time, the calculated data is cached and whenever a TimeLog is created or updated for a child task (which changes the ``total_logged_seconds`` for the child task) or the ``schedule_timing`` or ``schedule_unit`` attributes are updated, the cached values are updated on the parents. Allowing Stalker to display percent_complete info of a container task without loading any of its children. * **New:** Added ``Task.percent_complete`` attribute, which calculates the percent of completeness of the task based on the ``Task.total_logged_seconds`` and ``Task.schedule_seconds`` attributes. * **Fix:** Added ``TimeLog.__eq__()`` operator to more robustly check if the time logs are overlapping. * **New:** Added ``Project.percent_complete``, ``Percent.total_logged_seconds`` and ``Project.schedule_seconds`` attributes. * **Update:** ``ScheduleMixin._validate_dates()`` does not set the date values anymore, it just return the calculated and validated ``start``, ``end`` and ``duration`` values. * **Update:** ``Vacation`` now can be created without a ``User`` instance, effectively making the ``Vacation`` a ``Studio`` wide vacation, which applies to all users. * **Update:** ``Vacation.__strictly_typed__`` is updated to ``False``, so there is no need to create a ``Type`` instance to be able to create a ``Vacation``. * **New:** ``Studio.vacations`` property now returns the ``Vacation`` instances which has no *user*. * **Update:** ``Task.start`` and ``Task.end`` values are no more read from children Tasks for a container task over and over again but calculated whenever the start and end values of a child task are changed or a new child is appended or removed. * **Update:** ``SimpleEntity.description`` validation routine doesn't convert the input to string anymore, but checks the given description value against being a string or unicode instance. * **New:** Added ``Ticket.summary`` field. * **Fix:** Fixed ``Link.extension``, it is now accepting unicode. 0.2.0.rc4 ========= * **New:** Added a new attribute to ``Version`` class called ``latest_version`` which holds the latest version in the version queue. * **New:** To optimize the database connection times, ``stalker.db.setup()`` will not try to initialize the database every time it is called anymore. This leads a ~4x speed up in database connection setup. To initialize a newly created database please use:: # for a newly created database from stalker import db db.setup() # connects to database db.init() # fills some default values to be used with Stalker # for any subsequent access just use (don't need to call db.init()) db.setup() * **Update:** Removed all ``__init_on_load()`` methods from all of the classes. It was causing SQLAlchemy to eagerly load relations, thus slowing down queries in certain cases (especially in ``Task.parent`` -> ``Task.children`` relation). * **Fix:** Fixed ``Vacation`` class tj3 format. * **Fix:** ``Studio.now`` attribute was not properly working when the ``Studio`` instance has been restored from database. 0.2.0.rc3 ========= * **New:** Added a new attribute to ``Task`` class called ``responsible``. * **Update:** Removed ``Sequence.lead_id`` use ``Task.reponsible`` instead. * **Update:** Updated documentation to include documentation about Configuring Stalker with ``config.py``. * **Update:** The ``duration`` argument in ``Task`` class is removed. It is somehow against the idea of having ``schedule_model`` and ``schedule_timing`` arguments (``schedule_model='duration'`` is kind of the same). * **Update:** Updated ``Task`` class documentation. 0.2.0.rc2 ========= * **New:** Added ``Version.created_with`` attribute to track the environment or host program name that a particular ``Version`` instance is created with. 0.2.0.rc1 ========= * **Update:** Moved the Pyramid part of the system to another package called ``stalker_pyramid``. * **Fix:** Fixed ``setup.py`` where importing ``stalker`` to get the ``__version__`` variable causing problems. 0.2.0.b9 ======== * **New:** Added ``Version.latest_published_version`` and ``Version.is_latest_published_version()``. * **Fix:** Fixed ``Version.__eq__()``, now Stalker correctly distinguishes different Version instances. * **New:** Added ``Repository.to_linux_path()``, ``Repository.to_windows_path()``, ``Repository.to_osx_path()`` and ``Repository.to_native_path()`` to the ``Repository`` class. * **New:** Added ``Repository.is_in_repo(path)`` which checks if the given path is in this repo. 0.2.0.b8 ======== * **Update:** Renamed **Version.version_of** attribute to **Version.task**. * **Fix:** Fixed **Version.version_number** where it was not possible to have a version number bigger than 2. * **Fix:** In **db.setup()** Ticket statuses are only created if there aren't any. * **Fix:** Added **Vacation** class to the registered class list in stalker.db. 0.2.0.b7 ======== * **Update:** **Task.schedule_constraint** is now reflected to the tjp file correctly. * **Fix:** **check_circular_dependency()** now checks if the **entity** and the **other_entity** are the same. * **Fix:** **Task.to_tjp()** now correctly add the dependent tasks of a container task. * **Fix:** **Task.__eq__()** now correctly considers the parent, depends, resources, start and end dates. * **Update:** **Task.priority** is now reflected in tjp file if it is different than the default value (500). * **New::** Added a new class called **Vacation** to hold user vacations. * **Update:** Removed dependencies to ``pyramid.security.Allow`` and ``pyramid.security.Deny`` in couple of packages. * **Update:** Changed the way the ``stalker.defaults`` is created. * **Fix:** **EnvironmentBase.get_version_from_full_path()**, **EnvironmentBase.get_versions_from_path()**, **EnvironmentBase.trim_repo_path()**, **EnvironmentBase.find_repo** methods are now working properly. * **Update:** Added **Version.absolute_full_path** property which renders the absolute full path which also includes the repository path. * **Update:** Added **Version.absolute_path** property which renders the absolute path which also includes the repository path. 0.2.0.b6 ======== * **Fix:** Fixed **LocalSession._write_data()**, previously it was not creating the local session folder. * **New:** Added a new method called **LocalSession.delete()** to remove the local session file. * **Update:** **Link.full_path** can now be set to an empty string. This is updated in this way for **Version** class. * **Update:** Updated the formatting of **SimpleEntity.nice_name**, it is now possible to have uppercase letters and camel case format will be preserved. * **Update**: **Version.take_name** formatting is enhanced. * **New**: **Task** class is now mixed in with **ReferenceMixin** making it unnecessary to have **Asset**, **Shot** and **Sequence** classes all mixed in individually. Thus removed the **ReferenceMixin** from **Asset**, **Shot** and **Sequence** classes. * **Update**: Added **Task.schedule_model** validation and its tests. * **New**: Added **ScheduleMixin.total_seconds** and **ScheduleMixin.computed_total_seconds**. 0.2.0.b5 ======== * **New:** **Version** class now has two new attributes called ``parent`` and ``children`` which will be used in tracking of the history of Version instances and track which Versions are derived from which Version. * **New:** **Versions** instances are now derived from **Link** class and not **Entity**. * **Update:** Added new revisions to **alembic** to reflect the change in **Versions** table. * **Update:** **Links.path** is renamed to **Links.full_path** and added three new attributes called **path**, **filename** and **extension**. * **Update:** Added new revisions to alembic to reflect the change in **Links** table. * **New:** Added a new class called **LocalSession** to store session data in users local filesystem. It is going to be replaced with some other system like **Beaker**. * **Fix:** Database part of Stalker can now be imported without depending to **Pyramid**. * **Fix:** Fixed documentation errors that **Sphinx** complained about. 0.2.0.b4 ======== * No changes in SOM. 0.2.0.b3 ======== * **Update:** FilenameTemplate's are not ``strictly typed`` anymore. * **Update:** Removed the FilenameTemplate type initialization, FilenameTemplates do not depend on Types anymore. * **Update:** Added back the ``plural_class_name`` (previously ``plural_name``) property to the ORMClass class, so all the classes in SOM now have this new property. * **Update:** Added ``accepts_references`` attribute to the EntityType class. * **New:** The Link class has a new attribute called ``original_filename`` to store the original file names of link files. * **New:** Added **alembic** to the project requirements. * **New:** Added alembic migrations which adds the ``accepts_references`` column to ``EntityTypes`` table and ``original_name`` to the ``Links`` table. 0.2.0.b2 ======== * Stalker is now compatible with Python 2.6. * Task: * **Update:** Tasks now have a new attribute called ``watchers`` which holds a list of User instances watching the particular Task. * **Update:** Users now have a new attribute called ``watching`` which is a list of Task instances that this user is watching. * TimeLog: * **Update:** TimeLog instances will expand Task.schedule_timing value automatically if the total amount of logged time is more than the schedule_timing value. * **Update:** TimeLogs are now considered while scheduling the task. * **Fix:** TimeLogs raises OverBookedError when appending the same TimeLog instance to the same resource. * Auth: * **Fix:** The default ACLs for determining the permissions are now working properly. 0.2.0.b1 ======== * WorkingHours.is_working_hour() is working now. * WorkingHours class is moved from stalker.models.project to stalker.models.studio module. * ``daily_working_hours`` attribute is moved from stalker.models.project.Project to stalker.models.studio.Studio class. * Repository path variables now ends with a forward slash even if it is not given. * Updated Project classes validation messages to correlate with Stalker standard. * Implementation of the Studio class is finished. The scheduling works like a charm. * It is now possible to use any characters in SimpleEntity.name and the derived classes. * Booking class is renamed to TimeLog. 0.2.0.a10 ========= * Added new attribute to WorkingHours class called ``weekly_working_hours``, which calculates the weekly working hours based on the working hours defined in the instance. * Task class now has a new attribute called ``schedule_timing`` which is replacing the ``effort``, ``length`` and ``duration`` attributes. Together with the ``schedule_model`` attribute it will be used in scheduling the Task. * Updated the config system to the one used in oyProjectManager (based on Sphinx config system). Now to reach the defaults:: # instead of doing the following from stalker.conf import defaults # not valid anymore # use this from stalker import defaults If the above idiom is used, the old ``defaults`` module behaviour is retained, so no code change is required other than the new lower case config variable names. 0.2.0.a9 ======== * A new property called ``to_tjp`` added to the SimpleEntity class which needs to be implemented in the child and is going to be used in TaskJuggler integration. * A new attribute called ``is_scheduled`` added to Task class and it is going to be used in Gantt charts. Where it will lock the class and will not try to snap it to anywhere if it is scheduled. * Changed the ``resolution`` attribute name to ``timing_resolution`` to comply with TaskJuggler. * ScheduleMixin: * Updated ScheduleMixin class documentation. * There are two new read-only attributes called ``computed_start`` and ``computed_end``. These attributes will be used in storing of the values calculated by TaskJuggler, and will be used in Gantt Charts if available. * Added ``computed_duration``. * Task: * Arranged the TaskJuggler workflow. * The task will use the effort > length > duration attributes in `to_tjp` property. * Changed the license of Stalker from BSD-2 to LGPL 2.1. Any version previous to 0.2.0.a9 will be still BSD-2 and any version from and including 0.2.0.a9 will be distributed under LGPL 2.1 license. * Added new types of classes called Schedulers which are going to be used in scheduling the tasks. * Added TaskJugglerScheduler, it uses the given project and schedules its tasks. 0.2.0.a8 ======== * TagSelect now can be filled by setting its ``value`` attribute (Ex: TagSelect.set('value', data)) * Added a new method called ``is_root`` to Task class. It is true for tasks where there are no parents. * Added a new attribute called ``users`` to the Department class which is a synonym for the ``members`` attribute. * Task: * Task class is now preventing one of the dependents to be set as the parent of a task. * Task class is now preventing one of the parents to be set as the one of the dependents of a task. * Fixed ``autoflush`` bugs in Task class. * Fixed `admin` users department initialization. * Added ``thumbnail`` attribute to the SimpleEntity class which is a reference to a Link instance, showing the path of the thumbnail. * Fixed Circular Dependency bug in Task class, where a parent of a newly created task is depending to another task which is set as the dependee for this newly created task (T1 -> T3 -> T2 -> T1 (parent relation) -> T3 -> T2 etc.). 0.2.0.a7 ======== * Changed these default setting value names to corresponding new names: * ``DEFAULT_TASK_DURATION`` -> ``TASK_DURATION`` * ``DEFAULT_TASK_PRIORITY`` -> ``TASK_PRIORITY`` * ``DEFAULT_VERSION_TAKE_NAME`` -> ``VERSION_TAKE_NAME`` * ``DEFAULT_TICKET_LABEL`` -> ``TICKET_LABEL`` * ``DEFAULT_ACTIONS`` -> ``ACTIONS`` * ``DEFAULT_BG_COLOR`` -> ``BG_COLOR`` * ``DEFAULT_FG_COLOR`` -> ``FG_COLOR`` * stalker.conf.defaults: * Added default settings for project working hours (``WORKING_HOURS``, ``DAY_ORDER``, ``DAILY_WORKING_HOURS``) * Added a new variable for setting the task time resolution called ``TIME_RESOLUTION``. * stalker.models.project.Project: * Removed Project.project_tasks attribute, use Project.tasks directly to get all the Tasks in that project. For root task you can do a quick query:: Task.query.filter(Task.project==proj_id).filter(Task.parent==None).all() This will also return the Assets, Sequences and Shots in that project, which are also Tasks. * Users are now assigned to Projects by appending them to the Project.users list. This is done in this way to allow a reduced list of resources to be shown in the Task creation dialogs. * Added a new helper class for Project working hour management, called WorkingHours. * Added a new attribute to Project class called ``working_hours`` which holds stalker.models.project.WorkingHours instances to manage the Project working hours. It will directly be passed to TaskJuggler. * stalker.models.task.Task: * Removed the Task.task_of attribute, use Task.parent to get the owner of this Task. * Task now has two new attributes called Task.parent and Task.children which allow more complex Task-to-Task relation. * Secondary table name for holding Task to Task dependency relation is renamed from ``Task_Tasks`` to ``Task_Dependencies``. * check_circular_dependency function is now accepting a third argument which is the name of the attribute to be investigated for circular relationship. It is done in that way to be able to use the same function in searching for circular relations both in parent/child and depender/dependee relations. * ScheduleMixin: * Added a new attribute to ScheduleMixin for time resolution adjustment. Default value is 1 hour and can be set with stalker.conf.defaults.TIME_RESOLUTION. Any finer time than the resolution is rounded to the closest multiply of the resolution. It is possible to set it from microseconds to years. Although 1 hour is a very reasonable resolution which is also the default resolution for TaskJuggler. * ScheduleMixin now uses datetime.datetime for the start and end attributes. * Renamed the ``start_date`` attribute to ``start``. * Renamed the ``end_date`` attribute to ``end`` * Removed the TaskableEntity. * Asset, Sequence and Shot classes are now derived from Task class allowing more complex Task relation combined with the new parent/child relation of Tasks. Use Asset.children or Asset.tasks to reach the child tasks of that asset (same with Sequence and Shot classes). * stalker.models.shot.Shot: * Removed the sequence and introduced sequences attribute in Shot class. Now one shot can be in more than one Sequence. Allowing more complex Shot/Sequence relations.. * Shots can now be created without a Sequence instance. The sequence attribute is just used to group the Shots. * Shots now have a new attribute called ``scenes``, holding Scene instances. It is created to group same shots occurring in the same scenes. * In tests all the Warnings are now properly handled as Warnings. * stalker.models.ticket.Ticket: * Ticket instances are now tied to Projects and it is now possible to create Tickets without supplying a Version. They are free now. * It is now possible to link any SimpleEntity to a Ticket. * The Ticket Workflow is now fully customizable. Use stalker.conf.defaults.TICKET_WORKFLOW dictionary to define the workflow and stalker.conf.defaults.TICKET_STATUS_ORDER for the order of the ticket statuses. * Added a new class called ``Scene`` to manage Shots with another property. * Removed the ``output_path`` attribute in FilenameTemplate class. * Grouped the templates for each entity under a directory with the entity name. 0.2.0.a6 ======== * Users now can have more than one Department. * User instances now have two new properties for getting the user tickets (User.tickets) and the open tickets (User.open_tickets). * New shortcut Task.project returns the Task.task_of.project value. * Shot and Asset creation dialogs now automatically updated with the given Project instance info. * User overview page is now reflection the new design. 0.2.0.a5 ======== * The ``code`` attribute of the SimpleEntity is now introduced as a separate mixin. To let it be used by the classes it is really needed. * The ``query`` method is now converted to a property so it is now possible to use it like a property as in the SQLAlchemy.orm.Session as shown below:: from stalker import Project Project.query.all() # instead of Project.query().all() * ScheduleMixin.due_date is renamed to ScheduleMixin.end_date. * Added a new class attribute to SimpleEntity called ``__auto_name__`` which controls the naming of the instances and instances derived from SimpleEntity. If ``__auto_name__`` is set to True the ``name`` attribute of the instance will be automatically generated and it will have the following format:: {{ClassName}}_{{UUID4}} Here are a couple of naming examples:: Ticket_74bb46b0-29de-4f3e-b4e6-8bcf6aed352d Version_2fa5749e-8cdb-4887-aef2-6d8cec6a4faa * Fixed an autoflush issue with SQLAlchemy in StatusList class. Now the status column is again not nullable in StatusMixin. 0.2.0.a4 ======== * Added a new class called EntityType to hold all the available class names and capabilities. * Version class now has a new attribute called ``inputs`` to hold the inputs of the current Version instance. It is a list of Link instances. * FilenameTemplate classes ``path`` and ``filename`` attributes are no more converted to string, so given a non string value will raise TypeError. * Structure.custom_template now only accepts strings and None, setting it to anything else will raise a TypeError. * Two Type's for FilenameTemplate's are created by default when initializing the database, first is called "Version" and it is used to define FilenameTemplates which are used for placing Version source files. The second one is called "Reference" and it is used when injecting references to a given class. Along with the FilenameTemplate.target_entity_type this will allow one to create two different FilenameTemplates for one class:: # first get the Types vers_type = Type.query()\ .filter_by(target_entity_type="FilenameTemplate")\ .filter_by(type="Version")\ .first() ref_type = Type.query()\ .filter_by(target_entity_type="FilenameTemplate")\ .filter_by(type="Reference")\ .first() # lets create a FilenameTemplate for placing Asset Version files. f_ver = FilenameTemplate( target_entity_type="Asset", type=vers_type, path="Assets/{{asset.type.code}}/{{asset.code}}/{{task.type.code}}", filename="{{asset.code}}_{{version.take_name}}_{{task.type.code}}_v{{'%03d'|version.version_number}}{{link.extension}}" output_path="{{version.path}}/Outputs/{{version.take_name}}" ) # and now define a FilenameTemplate for placing Asset Reference files. # no need to have an output_path here... f_ref = FilenameTemplate( target_entity_type="Asset", type=ref_type, path="Assets/{{asset.type.code}}/{{asset.code}}/References", filename="{{link.type.code}}/{{link.id}}{{link.extension}}" ) * stalker.db.register() now accepts only real classes instead of class names. This way it can store more information about classes. * Status.bg_color and Status.fg_color attributes are now simple integers. And the Color class is removed. * StatusMixin.status is now a ForeignKey to a the Statuses table, thus it is a real Status instance instead of an integer showing the index of the Status in the related StatusList. This way the Status of the object will not change if the content of the StatusList is changed. * Added new attribute Project.project_tasks which holds all the direct or indirect Tasks created for that project. * User.login_name is renamed to User.login. * Removed the ``first_name``, ``last_name`` and ``initials`` attributes from User class. Now the ``name`` and ``code`` attributes are going to be used, thus the ``name`` attribute is no more the equivalent of ``login`` and the ``code`` attribute is doing what was ``initials`` doing previously. 0.2.0.a3 ======== * Status class now has two new attributes ``bg_color`` and ``fg_color`` to hold the UI colors of the Status instance. The colors are Color instances. 0.2.0.a2 ======== * SimpleEntity now has an attribute called ``generic_data`` which can hold any kind of ``SOM`` object inside and it is a list. * Changed the formatting rules for the ``name`` in SimpleEntity class, now it can start with a number, and it is not allowed to have multiple whitespace characters following each other. * The ``source`` attribute in Version is renamed to ``source_file``. * The ``version`` attribute in Version is renamed to ``version_number``. * The ``take`` attribute in Version is renamed to ``take_name``. * The ``version_number`` in Version is now generated automatically if it is skipped or given as None or it is too low where there is already a version number for the same Version series (means attached to the same Task and has the same ``take_name``. * Moved the User class to ``stalker.models.auth module``. * Removed the ``stalker.ext.auth`` module because it is not necessary anymore. Thus the User now handles all the password conversions by itself. * ``PermissionGroup`` is renamed back to Group again to match with the general naming of the authorization concept. * Created two new classes for the Authorization system, first one is called Permission and the second one is a Mixin which is called ACLMixin which adds ACLs to the mixed in class. For now, only the User and Group classes are mixed with this mixin by default. * The declarative Base class of SQLAlchemy is now created by binding it to a ORMClass (a random name) which lets all the derived class to have a method called ``query`` which will bypass the need of calling ``DBSession.query(class_)`` but instead just call ``class_.query()``:: from stalker.models.auth import User user_1 = User.query().filter_by(name='a user name').first() 0.2.0.a1 ======== * Changed the ``db.setup`` arguments. It is now accepting a dictionary instead of just a string to comply with the SQLAlchemy scaffold and this dictionary should contain keys for the SQLAlchemy engine setup. There is another utility that comes with Pyramid to setup the database under the `scripts` folder, it is also working without any problem with stalker.db. * The ``session`` variable is renamed to ``DBSession`` and is now a scopped session, so there is no need to use ``DBSession.commit`` it will be handled by the system it self. * Even though the ``DBSession`` is using the Zope Transaction Manager extension normally, in the database tests no extension is used because the transaction manager was swallowing all errors and it was a little weird to try to catch this errors out of the ``with`` block. * Refactored the code, all the models are now in separate python files, but can be directly imported from the main stalker module as shown:: from stalker import User, Department, Task By using this kind of organization, both development and usage will be eased out. * ``task_of`` now only accepts TaskableEntity instances. * Updated the examples. It is now showing how to extend SOM correctly. * Updated the references to the SOM classes in docstrings and rst files. * Removed the ``Review`` class. And introduced the much handier Ticket class. Now reviewing a data is the process of creating Ticket's to that data. * The database is now initialized with a StatusList and a couple of Statuses appropriate for Ticket instances. * The database is now initialized with two Type instances ('Enhancement' and 'Defect') suitable for Ticket instances. * StatusMixin now stores the status attribute as an Integer showing the index of the Status in the ``status_list`` attribute but when asked for the value of ``StatusMixin.status`` attribute it will return a proper Status instance and the attribute can be set with an integer or with a proper Status instance. ================================================ FILE: CHANGELOG_OLD.rst ================================================ 0.1.2.a5 ======== * :class:`~stalker.core.models.SimpleEntity`.\ :attr:`~stalker.core.models.SimpleEntity.name` attribute doesn't accept anything other than a string or unicode anymore. * All the error messages are now showing both the class and attribute names, and for TypeErrors it also shows the current given data type which raised the error and the desired type. * Fixed a bug that causes recursion in queries to :class:`~stalker.core.models.StatusList` instances. * Removed the ``FilenameTemplate.output_file_code`` attribute cause it is not needed. * Removed the ``FilenameTemplate.output_is_relative`` attribute, now all the path values are :class:`~stalker.models.repository.Repository` relative. * Renamed the ``path_code`` to ``path``, ``file_code`` to ``filename`` and ``output_path_code`` to ``output_path`` in :class:`~stalker.models.template.FilenameTemplate``\ . 0.1.2.a4 ======== * Added database tests for: * :class:`~stalker.core.models.Review` * :class:`~stalker.core.models.Task` * :class:`~stalker.core.models.Version` * The ``published`` attribute in :class:`~stalker.core.models.Version` is renamed to :attr:`~stalker.core.models.Version.is_published`. * :class:`~stalker.core.models.Structure` is not ``strictly_typed`` anymore. * The initialization of ``status_list`` attribute in classes which are mixed with :class:`~stalker.core.models.StatusMixin` is now automatically done if there is a database connection (stalker.db.session is not None) and there is a suitable :class:`~stalker.core.models.StatusList` instance in the database whom :attr:`~stalker.core.models.StatusList.target_entity_type` attribute is set to the mixed-in class name. * Finished the tests for :class:`~stalker.core.models.Booking` class. * The :class:`~stalker.core.models.Booking` class now checks if there is more than one booking is creating with overlapping time interval and issue a :class:`~stalker.core.errors.OverBookedWarning`. * Cleaned up the code style. * Moved the ``target_entity_type`` functionality to a new mixin class called :class:`~stalker.core.models.TargetEntityTypeMixin`\ . :class:`~stalker.core.models.StatusList`, :class:`~stalker.core.models.FilenameTemplate`, and :class:`~stalker.core.models.Type` classes are mixin with this new class. * Included the ``pyseq`` library to the dependency list. 0.1.2.a3 ======== * stalker.__version__ is fixed for PyPI 0.1.2.a2 ======== * All the models are now converted to SQLAlchemy's Declarative. * Because of the move to the SQLAlchemy Declarative extension the stalker.etx.ValidatedList is deprecated. SQLAlchemy is doing (in most of the aspects) a much better job. * The ``stalker.core.mixins`` module is merged with :mod:`~stalker.core.models` module. * Becase all the models are declaratively defined and thus they have their mappers and tables inside, the ``stalker.db.mapper``, ``stalker.db.tables`` and the ``stalker.db.mixins`` modules are removed. * Added the :class:`~stalker.core.models.Version` class with all its tests. * Fixed :attr:`~stalker.core.models.Project.assets` and :attr:`~stalker.core.models.Project.sequences` attributes in :class:`~stalker.core.models.Project` class. It is now using the ``db.query`` to get the info out of the database, which is much easier than setting up a complex relation. * Fixed the tests for the database. It is now working properly with the SOM which is using SQLAlchemy declarative. There are still missing tests though. * The :class:`~stalker.core.models.Project` and :class:`~stalker.core.models.Structure` classes are not ``__strictly_typed__`` anymore. It was a bump on the road to make them strictly typed. 0.1.2.a1 ======== * Started to switch to SQLAlchemy ORM Declarative for SOM, implemented these classes successfully: SimpleEntity, Type, Tag, Note, ImageFormat, Status, StatusList, Repository, Structure, FilenameTemplate, Department, Link, ReferenceMixin, StatusMixin, ScheduleMixin, Project, Sequence, Shot, Asset, Review. * Empty :class:`~stalker.core.models.StatusList`\ s are now allowed. The validation overhead is left to the SOM user. * Removed the ``TaskMixin`` on the way of moving to declarative. It was not possible to add a one-to-many relation to the :class:`~stalker.core.model.Task`\ s :attr:`~stalker.core.models.Task.task_of` attribute from all the mixed-in classes. So the solution was to introduce a new :class:`~stalker.core.models.TaskableEntity` (yaykh!) inheriting from :class:`~stalker.core.models.Entity`. * The :attr:`~stalker.core.models.SimpleEntity.name` attribute in :attr:`~stalker.core.models.SimpleEntity` is no more forced to be unique. This releases the :attr:`~stalker.core.models.Shot.name` attribute in the :class:`~stalker.core.models.Shot` class to be anything it wants (not just uuid4 sequences to get unique names). * From now on, the :attr:`~stalker.core.models.SimpleEntity.code` attribute in :class:`~stalker.core.models.SimpleEntity` class is not going to change when the :attr:`~stalker.core.models.SimpleEntity.name` attribute is changed. * The :attr:`~stalker.core.models.SimpleEntity.name` attribute in :class:`~stalker.core.models.SimpleEntity` is going to be copied from the :attr:`~stalker.core.models.SimpleEntity.code` attribute if the ``name`` argument is skipped, None or empty string. * The ``ReviewMixin`` is removed. * The :class:`~stalker.core.models.Review` class is now inheriting from the :class:`~stalker.core.models.SimpleEntity`. * The :class:`~stalker.core.models.Entity` now has a new attribute called :attr:`~stalker.core.models.Entity.reviews` to store a list of :class:`~stalker.core.models.Review` instances. 0.1.1.a10 ========= * :class:`~stalker.core.mixins.TaskMixin` from now on doesn't have a ``tasks`` argument in its ``__init__`` method. * Each of the mixin classes now has their own test modules. * In :class:`~stalker.core.models.Shot`, now the :attr:`~stalker.core.models.Shot.cut_out` attribute is mapped to the database instead of the :attr:`~stalker.core.models.Shot.cut_duration`. * In :class:`~stalker.core.models.Task` the ``part_of`` attribute is renamed to :attr:`~stalker.core.models.Task.task_of` to reflect its duty clearly. * Removed the ``ProjectMixin``. The ``project`` attribute has been moved to the :class:`~stalker.core.mixins.TaskMixin`. Now anything mixed with the :class:`~stalker.core.mixins.TaskMixin` also has a :attr:`~stalker.core.mixins.TaskMixin.project` attribute. * :attr:`~stalker.core.models.Task.task_of` attribute in :class:`~stalker.core.models.Task` class now accepts anything that has been derived from :class:`~stalker.core.mixins.TaskMixin` or anything that has both a ``tasks`` attribute and a ``project`` attribute but use the :class:`~stalker.core.mixins.TaskMixin` preferably. * :class:`~stalker.core.models.Sequence` now doesn't accept any ``shots`` argument. There is no way to create a :class:`~stalker.core.models.Shot` without passing a :class:`~stalker.core.models.Sequence` instance. * All the classes that needs to be initialized properly now has a method called __init_on_load__ which is called by SQLAlchemy on load. * Fixed the :attr:`~stalker.core.models.Task.task_of` attribute in :class:`~stalker.core.models.Task` and :attr:`~stalker.core.mixins.TaskMixin.tasks` attribute in :class:`~stalker.core.mixins.TaskMixin`, they are now updating each other correctly. * Added :attr:`~stalker.core.models.Shot.assets` to the :class:`~stalker.core.models.Shot` class to track down which asset is used in this shot. * Merged the ``ProjectMixinDB`` with ``TaskMixinDB`` and removed the ``ProjectMixinDB`` from the database part of the mixins. * The :class:`~stalker.core.models.Project` doesn't accept an ``assets`` nor a ``sequences`` arguments anymore. Which was meaningless previously, cause it is not possible to create an :class:`~stalker.core.models.Asset` or a :class:`~stalker.core.models.Sequence` without specifying the :class:`~stalker.core.models.Project` first. * From now on it is not possible to create a :class:`~stalker.core.models.Project` instance without passing a :class:`~stalker.core.models.Repository` instance to it. * The :class:`~stalker.core.models.Asset` now updates the :attr:`~stalker.core.models.Project.assets` attribute in the :class:`~stalker.core.models.Project` class. * From now on none of the tests are using the Mocker library. Thus all the little changes to any of the classes are present in all the tests which are using those classes. This makes the tests more robust and current. * Fixed latex PDF output of the documentation, now the formatting is nice and correct. * :class:`~stalker.core.models.Repository` now replaces backward slashes with forward slashes in the given path arguments and attributes. * The ``filename`` attribute has been removed from the :class:`~stalker.core.models.Link` class. And it doesn't need an ``filename`` argument anymore. The :attr:`~stalker.core.models.Link.path` is enough to hold the necessary data. * The :class:`~stalker.core.models.Link` is not strictly typed anymore. So you can skip the ``type`` argument while creating a :class:`~stalker.core.models.Link` instance. * Fixed the ``Mutable Default`` problem in the following classes: * :class:`~stalker.core.models.Department` classes ``members`` argument. * :class:`~stalker.core.models.Entity` classes ``tags`` and ``notes`` argument. * :class:`~stalker.core.models.StatusList` classes ``statuses`` argument * :class:`~stalker.core.models.Project` classes ``assets`` argument * :class:`~stalker.core.models.Assets` classes ``shots`` argument * :class:`~stalker.core.models.User` classes ``permission_groups``, ``projects_lead``, ``sequences_lead`` and tasks attributes. * The ``milestone`` attribute is renamed to :attr:`~stalker.core.models.Task.is_milestone` in :class:`~stalker.core.models.Task` class. * The ``complete`` attribute is renamed to :attr:`~stalker.core.models.Task.is_complete`` in :class:`~stalker.core.models.Task` class. * Replaced the python property idiom which uses a function which contains an fget, an fset functions and a doc string variable and returns the result of locals() with the property idiom that uses @property and @x.setter. Thus dropped the support for python versions <= 2.5. This is done to increase the PyLint rate. And with its final state, the PyLint rate of Stalker increased from around 1 to around 9. * Reintroduced the :class:`~stalker.core.mixins.ProjectMixin` and the :class:`~stalker.core.mixins.TaskMixin` is now inherited from :class:`~stalker.core.mixins.ProjectMixin`. It is done in that way to allow other types to have relation with a :class:`~stalker.core.models.Project` instance. Without the :class:`~stalker.core.mixins.ProjectMixin` it was going to introduce some code repetition. Also updated the database part of the TaskMixin and created a helper class for the ProjectMixin. * Added an attribute called "__stalker_version__" to the :class:`~stalker.core.models.SimpleEntity` to track down in which version of Stalker that data is created. This is mostly related with the database part. * Renamed ``stalker.db.mixin`` module to :mod:`stalker.db.mixins`. * Renamed ``stalker.core.models.Comment`` class to :class:`~stalker.core.models.Review`. * The :attr:`~stalker.core.models.Review.to` attribute in :class:`~stalker.core.models.Review` class now accepts anything which has a list-like attribute called "reviews". * :class:`~stalker.ext.validatedList.ValidatedList` now works uniquely. Means the list of items are always unique. * The :attr:`~stalker.core.mixins.TaskMixin.tasks` attribute in :class:`~stalker.core.mixins.TaskMixin` is not read-only anymore. But will produce RuntimeError if removing items will produce orphan children. * Optimized the back reference update procedure in :class:`~stalker.core.models.Task` and :class:`~stalker.core.mixins.TaskMixin` classes. They are not touching their internal variables anymore. * Fixed backreference updates of :class:`~stalker.core.models.Task` classes :attr:`~stalker.core.models.Task.resources` attribute. * Fixed ``__setslice__`` method in :class:`~stalker.ext.validatedList.ValidatedList`. It is now correctly passing the added and removed elements to the given ``validator`` function. 0.1.1.a9 ======== * Introduced :class:`~stalker.core.models.Type`. A new class to define **types**. With this introduction, all the classes deriving from ``TypeEntity`` (and the TypeEntity it self) are removed from Stalker. * Added an attribute called ``type`` to the :class:`~stalker.core.models.SimpleEntity`. Which will be used to create new types to the derived classes. * Introduced a new attribute called ``__strictly_typed__`` to all the classes (by the means of the EntityMeta), which will force the class to have a proper (not None) ``type``. * :class:`~stalker.core.models.SimpleEntity` now has its own test module. * :class:`~stalker.core.models.TypeTemplate` is renamed to :class:`~stalker.core.models.FilenameTemplate` to reflect its duty more clearly. * fixed the tests for the :mod:`~stalker.db`. Previously each of the tests were creating an instance of a specific class then storing it in the database, retrieved it back and then comparing the instances, one just created and one queried from the database. The problem was that, SA was returning the same instance (can be checked with id(instance)) so in any case they were equal, it was not possible to compare them and get a meaningful difference to see if the database part worked properly. Now, all the attributes of the original instance are stored in new variables and then the original instance is deleted, and the a new one is retrieved back from the database, and all the attributes are compared with the stored ones. (probably there are other good ways) * fixed :attr:`~stalker.core.models.SimpleEntity.nice_name` in the :class:`~stalker.core.models.SimpleEntity`, if the instance is created by using ``__new__`` (like in SA) then the :attr:`~stalker.core.models.SimpleEntity.nice_name` attribute was not initialized correctly. * fixed mapping of :attr:`~stalker.core.models.Department.lead` attribute in :class:`~stalker.core.models.Department` class. * fixed :attr:`~stalker.core.models.ImageFormat.device_aspect` attribute in :class:`~stalker.core.models.ImageFormat`, it is now correctly calculated when the instance is created with ``__new__`` instead of ``__init__`` * fixed mapping of :attr:`~stalker.core.models.Project.users` attribute in :class:`~stalker.core.models.Project` class. * fixed mapping of :attr:`~stalker.core.models.Project.duration` attribute in :class:`~stalker.core.models.Project` class (also possibly fixed all the classes mixed with :class:`~stalker.core.mixins.ScheduleMixin`) * fixed mapping of :attr:`~stalker.core.models.Sequence.lead` attribute in :class:`~stalker.core.models.Sequence` class * updated the behavior of :attr:`~stalker.core.models.Project.users` attribute in :class:`~stalker.core.models.Project` class. Now the list of :class:`~stalker.core.models.User`\ s are gathered from the :class:`~stalker.core.models.Task`\ s of the :class:`~stalker.core.models.Project` and from the :class:`~stalker.core.models.Sequence`\ s, the :class:`~stalker.core.models.Shot`\ s and the :class:`~stalker.core.models.Asset`\ s of the same :class:`~stalker.core.models.Project`. * updated the behavior of :attr:`~stalker.core.models.User.project` attribute in class :class:`~stalker.core.models.User` class. Now the list of :class:`~stalker.core.models.Project`\ s are gathered from all the :class:`~stalker.core.models.Task`\ s assigned to the current :class:`~stalker.core.models.User`. * The ``Group`` class is renamed to ``PermissionGroup``. * The default duration for the :class:`~stalker.core.mixins.ScheduleMixin` is now defined by the :attr:`stalker.conf.defaults.DEFAULT_TASK_DURATION` attribute. * The :class:`~stalker.core.mixins.ScheduleMixin` class now accepts a third argument called ``duration``. * The :attr:`~stalker.core.mixins.ScheduleMixin.duration` attribute in the :class:`~stalker.core.mixins.ScheduleMixin` is now a settable. See the :class:`~stalker.core.mixins.ScheduleMixin` class documentation for details. * The :attr:`~stalker.core.mixins.ScheduleMixin.due_date` in :class:`~stalker.core.mixins.ScheduleMixin` doesn't accept ``datetime.timedelta`` objects anymore. * The behavior of :attr:`~stalker.core.mixins.ScheduleMixin.start`, :attr:`~stalker.core.mixins.ScheduleMixin.due_date` and :attr:`~stalker.core.mixins.ScheduleMixin.duration` in :class:`~stalker.core.mixins.ScheduleMixin` is updated. * Added :class:`~stalker.core.errors.CircularDependencyError`. * All the ``ValueError``\ s are converted to ``TypeError``\ s in the :class:`~stalker.ext.validatedList.ValidatedList`. * Finished the implementation of :class:`~stalker.core.models.Task` class. * Updated the formatting of the :attr:`~stalker.core.models.SimpleEntity.code` attribute in :class:`~stalker.core.models.SimpleEntity` class. See the documentation of the :class:`~stalker.core.models.SimpleEntity` class for details. * Updated the :attr:`~stalker.core.models.User.code` attribute in :class:`~stalker.core.models.User`. * Updated all the exceptions raised by the SOM classes. Now they are correctly raising ``TypeError`` and ValueError``\ s. * added a new mixin class called :class:`~stalker.core.mixins.ProjectMixin`. * :class:`~stalker.core.models.Sequence`, :class:`~stalker.core.models.Asset` and :class:`~stalker.core.models.Task` classes are now using the :class:`~stalker.core.mixins.ProjectMixin` mixin class instead of implementing this common feature by them self. * added :class:`~stalker.db.mixin.ProjectMixinDB` for classes which are mixed with :class:`~stalker.core.mixins.ProjectMixin`. * :class:`~stalker.ext.validatedList.ValidatedList` now accepts a third argument called the ``validator`` which should be a callable, which is called when any of the methods of the :class:`~stalker.ext.validatedList.ValidatedList` is called and the list of elements are modified. The list of elements modified will be passed to the validator function where the first argument is a list containing the elements added and the last argument is a list contatining the elements removed. * :attr:`~stalker.core.models.Task.resources` in :class:`~stalker.core.models.Task` is now updating the :attr:`~stalker.core.models.User.tasks` attribute in the :class:`~stalker.core.models.User` class. * ``tasks`` is not an argument for the :class:`~stalker.core.models.User` anymore. It was meaningles to have the :class:`~stalker.core.models.Task`\ s in the initialization of the :class:`~stalker.core.models.User` instances. * :class:`~stalker.core.models.User` classes :attr:`~stalker.core.models.User.projects` attribute is now gathered by looking at the :attr:`~stalker.core.models.Task.project` attribute of the :class:`~stalker.core.models.Task`\ s in the :attr:`~stalker.core.models.User.tasks` attribute. * :class:`~stalker.core.models.StatusList` now accepts classes for the ``target_entity_type`` argument. * :class:`~stalker.core.mixins.ReferenceMixin` now accepts anything derived from the :class:`~stalker.core.models.Entity`. * :class:`~stalker.core.models.Task` class now has a :attr:`~stalker.core.models.Task.part_of` attribute which accepts :class:`~stalker.core.models.SimpleEntity` instances and shows which entity is this task a part of. 0.1.1.a8 ======== * From now on an :class:`~stalker.core.models.Asset` instance can not be created without a :class:`~stalker.core.models.AssetType` object defining the type of the current :class:`~stalker.core.models.Asset`. This is done to prevent creation of :class:`~stalker.core.models.Asset`\ s without a certain type. * Fixed :class:`~stalker.core.models.Project` where it was not raising a ValueError properly for :attr:`~stalker.core.models.Project.sequence`, :attr:`~stalker.core.models.Project.assets` and :attr:`~stalker.core.models.Project.users` attributes when the assigned value is not iterable. * Fixed :class:`~stalker.core.models.Department` where it was not raising a ValueError properly for :attr:`~stalker.core.models.Department.members` attribute when the assigned value is not iterable. * Changed the representaion string of the :class:`~stalker.core.models.Shot` to because the :attr:`~stalker.core.models.Shot.name` is not meaningful. * Changed the way :class:`~stalker.core.models.EntityMeta` metaclass working. It is now using the ``__new__`` method and the ``dict_`` of the class to set the attributes. * The :class:`~stalker.core.models.EntityMeta` now adds another attribute called ``plural_name`` to the classes, which shows the plural form of the class name. By default it tries to set it to a good name using plural form rules of English but if the name has an irregular plural name (or it is not in English) you can override this attribute by adding ``plural_name`` to the class attributes:: from stalker.core.models import SimpleEntity class MyEntity(SimpleEntity): plural_name = "MyEntities" pass * From now on the table names are in the following format: * The plural name of the class if the table belongs to one class * The class1.__name__ + "_" + class2.plural_name if the table is a join table * Updated the table names in the :mod:`stalker.db.mixin` module 0.1.1.a7 ======== * Updated the :ref:`roadmap_toplevel` to reflect the current development history and cycle * Merged all the model classes which were previously in separate files in to :mod:`stalker.core.models` module, to make it easy to use (and possibly hard to develop) * All the references to modules or classes or anything in the source codes are now represented by an absolute path in the docs ( :class:`stalker.core.models.User` instead of :class:`~stalker.core.models.User`) * moved the :mod:`stalker.db.auth` to :mod:`stalker.ext.auth` * :class:`stalker.core.models.User` class now uses the :func:`stalker.ext.auth.set_password` and :func:`stalker.ext.auth.check_password` utility functions to handle passwords. The user passwords are now always hidden, but not strongly encrypted. * The :func:`stalker.ext.auth.session` renamed to :func:`stalker.ext.auth.create_session` to reflect its functionality properly. And removed the return value from the function. Now it doesn't return any bool value but None. To check if the user is already logged in use :const:`stalker.ext.auth.SESSION` dictionary as follows:: from stalker.ext import auth # initialize the session auth.create_session() # check if there is a user if auth.SESSION_KEY in auth.SESSION: print "There is a logged in user" else: print "There is no logged in user" * :func:`stalker.ext.auth.authenticate` updated to use :func:`stalker.ext.auth.check_password` * Fixed the :attr:`~stalker.core.models.User.last_login` attribute in the database mapper, it was set as a *synonym* for it self. * Removed the ``tearDown`` methods in :mod:`tests.db.test_db`, there are problems with cleaning the mappers and disposing the engine, so instead of killing them the db.setup is called over and over again with different in memory databases. * From now on the :attr:`~stalker.core.models.SimpleEntity.code` attribute doesn't format the given string value too heavily, to allow more individual naming conventions to work with Stalker. * Updated :ref:`contribute_toplevel` * Renamed the :class:`~stalker.core.models.PipelineStep` to :class:`~stalker.core.models.TaskType` and changed the idea behind the relation between :class:`~stalker.core.models.AssetType` and :class:`~stalker.core.models.Task` * :class:`stalker.core.models.AssetType` classes :attr:`~stalker.core.models.AssetType.pipeline_steps` attribute has been renamed to :attr:`~stalker.core.models.AssetType.task_types` * Fixed a little error in the mapper of :class:`stalker.core.models.Structure` * Re-implemented the :func:`stalker.ext.auth.login` function and updated the tests accordingly. * All the error classes in :mod:`stalker.core.models` moved to :mod:`stalker.core.errors` * Added a new error class called :class:`stalker.core.errors.DBError` * Fixed a bug in :const:`stalker.db.__mappers__`, it is now possible to add new mappers without deleting the previous :const:`stalker.conf.defaults.MAPPERS` list. * Removed the :class:`~stalker.core.models.AssetType` and derived the :class:`~stalker.core.models.Shot` and :class:`~stalker.core.models.Asset` classes from :class:`~stalker.core.models.Entity`. * Moved the mixin classes from :mod:`stalker.core.models` to :mod:`stalker.core.mixins` * Introduced the :class:`~stalker.core.mixins.TaskMixin` which gives the ability to connect a list of tasks to the mixed in class. Also added the mapper setup for this mixin. * :class:`~stalker.ext.validatedList.ValidatedList` now accepts string values for the ``type_`` argument. * Added :class:`~stalker.core.models.Shot` class and test for it. * Updated the :class:`~stalker.core.models.Sequence` database tests according to new rules introduced with the :class:`~stalker.core.models.Shot` class. * :class:`~stalker.ext.validatedList.ValidatedList` now imports the given types lazily when the type is given as a string path. * :class:`~stalker.core.models.Sequence` now needs a :class:`~stalker.core.models.Project` instance to be created. * It is now possible to assign :class:`~stalker.core.models.Task`\ s to :class:`~stalker.core.models.Project` and :class:`~stalker.core.models.Sequence`\ s. Also updated the tests for this change. * Removed the ``shots`` argument from the :class:`~stalker.core.models.Sequence` class initialization. Because there is no way to create a :class:`~stalker.core.models.Shot` without a :class:`~stalker.core.models.Sequence` instance. * Added tests for mixin initialization for :class:`~stalker.core.models.Project`, :class:`~stalker.core.models.Sequence`, :class:`~stalker.core.models.Shot` and :class:`~stalker.core.models.Asset` classes. * Fixed a bug in :class:`~stalker.core.models.Project` where it was always initializing the references with an empty list no matter what is given. * Fixed a bug in :class:`~stalker.core.models.Project` where it was always initializing the :attr:`~stalker.core.models.Project.start` to **datetime.date.today** and the :attr:`~stalker.core.models.project.due_date` to 10 days later then the :attr:`~stalker.core.models.project.start` no matter what are given. * Fixed a bug in :class:`~stalker.core.mixins.ReferenceMixin` where it was not initializing the reference attribute correctly. * Fixed a bug in :class:`~stalker.core.models.Asset` where the :attr:`~stalker.core.models.Asset.project` attribute was not correctly getting the given :class:`~stalker.core.models.Project` instance. * Added the mappers and tables for :class:`~stalker.core.models.Shot` class. * Updated database model tests to test all the attributes of the models. 0.1.1.a6 ======== * updated/fixed tests for :class:`stalker.ext.validatedList.ValidatedList` * updated a couple of tests to increase tests coverage * :class:`stalker.core.models.status.Status` class instances now can be compared to string or unicode values * A :class:`stalker.core.models.status.Status` object in a :class:`stalker.core.models.status.StatusList` can now be accessed by its name as the index in :class:`stalker.core.models.status.StatusList` only while getting the item. * Added :class:`stalker.core.models.mixin.ScheduleMixin` which introduces date variables like, start, due_date and duration to the mixed in class. * Removed some parts of the :class:`stalker.core.models.project.Project` class which are now satisfied by the :class:`stalker.core.models.mixin.ScheduleMixin` * Improved the implementation of the :mod:`stalker.db.auth` module * removed the ``stalker.db.__setup__`` module which were helping to reference the variables in :mod:`stalker.db` module but it is not needed any more * It is now possible to initialize a :class:`stalker.core.models.project.Project` object without a :class:`stalker.core.models.repository.Repository`, :class:`stalker.core.models.structure.Structure` or an :class:`stalker.core.models.imageFormat.ImageFormat` or a :class:`stalker.core.models.types.ProjectType` * Updated the :ref:`tutorial_toplevel` * From now on, in a :class:`stalker.core.models.entity.SimpleEntity`, setting the code attribute to None or empty string will not raise any ``ValueError``\ s but will re-initialize the ``code`` value from the ``nice_name`` attribute. * Implemented :class:`stalker.core.models.sequence.Sequence` class along with its tests. * added :class:`stalker.core.models.sequence.Sequence` equality tests. * improved :class:`stalker.core.models.project.Project` equality tests. * Implemented :class:`stalker.core.models.assetBase.AssetBase` class along with its tests. * The **index.rst** of the documentation now references the **README** from the project root. * added the basic implementation of :class:`stalker.core.models.task.Task` and :class:`stalker.core.models.shot.Shot` and mapped them very basically to be able to test the dependent classes like :class:`stalker.core.models.assetBase.AssetBase` and :class:`stalker.core.models.sequence.Sequence` * Added mappers and tables for :class:`stalker.core.models.assetBase.AssetBase` * Now all the mixin classes have proper :func:`__init__` methods, and in a mixed class, the mixin classes' :func:`__init__` method can be called directly by giving the current object instance (*self*) like shown below:: class ANewEntity(entity.SimpleEntity, mixin.StatusMixin): def __init__(self, **kwargs): super(ANewEntity, self).__init__(**kwargs) mixin.StatusMixin.__init__(self, **kwargs) and it can be repeated for any number of mixins in class inheritance path. * Added the **CHANGELOG** to the documentation, and updated all formating of the mentioned references inside the file. 0.1.1.a5 ======== * removed the :class:`stalker.core.models.entity.StatusedEntity` and its tests, with the introduction of :class:`stalker.core.models.mixin.StatusMixin`, it is not necessary any more * added camera_lens.py to the examples, which shows how to extend SOM in its very basic form, also added tests testing this example * changed the database uri for the **DatabaseTester**, it now uses an in memory SQLite database instead a file based one. * Updated the version numbers in the roadmap * Added ``last_login`` attribute to :class:`stalker.core.models.user.User` class tables and mapped it * because it was taking too much space in the diffs the VUE file which shows the design sketches has been removed from the trunk * added the :class:`stalker.ext.validatedList.ValidatedList` class which is a list derivative that accepts only one type of object. * these SOM classes listed below uses :class:`stalker.ext.validatedList.ValidatedList` in their list attributes requiring specific types of objects to be assigned: * :class:`stalker.core.models.entity.Entity` * :class:`stalker.core.models.status.StatusList` * :class:`stalker.core.models.structure.Structure` * :class:`stalker.core.models.types.AssetType` * :class:`stalker.core.models.department.Department` * :class:`stalker.core.models.user.User` * :class:`stalker.core.models.mixin.ReferenceMixin` * added tests of the :class:`stalker.core.models.project.Project` class * completed the first implementation of the :class:`stalker.core.models.project.Project` class * to be able to use *assertIsInstance* method of :class:`mocker.MockerTestCase` all the :class:`unittest.TestCase` test classes are converted to :class:`mocker.MockerTestCase` * changed the design of the **stalker.db.mixins.ReferenceMixin.setup** and **stalker.db.mixins.StatusMixin.setup** to organize the mixin classes' database setup helper functions, now they are converted to classes with a classmethod called :meth:`stalker.db.mixin.ReferenceMixinDB.setup` doing all the functionality of the previous setup function and placed them under the :mod:`stalker.db.mixin` module. * added persistence tests for :class:`stalker.core.models.project.Project` * fixed secondary table generation for :class:`stalker.core.models.mixin.ReferenceMixin`, the table is now created only if it doesn't exists already, and it is retrieved from :attr:`stalker.db.metadata` if it exists 0.1.1.a4 ======== * changed the arguments of the :func:`stalker.db.mixins.ReferenceMixin.setup` function, to allow carrying the data from one to the next mixin (this part still needs a lot of attention) * removed the unnecessary ``statusedEntity_statuses`` secondary table, because one :class:`stalker.core.models.entity.StatusedEntity` owns just one :class:`stalker.core.models.status.StatusList` its a **many2one** relation, so no need to have a secondary table * introduced the :class:`stalker.core.models.mixin.StatusMixin` (will replace StatusedEntity soon) * Added a new example for the usage of :class:`stalker.core.models.mixin.StatusMixin` * Updated the :func:`stalker.db.mixins.ReferenceMixin.setup` function, now it takes three arguments, the *class*, the *table* and the *mapper_options* dictionary. 0.1.1.a3 ======== * Removed the included *tests* from the egg build * Added/fixed equality and inequality operators for classes: * :class:`stalker.core.models.department.Department` * :class:`stalker.core.models.entity.StatusedEntity` * :class:`stalker.core.models.entity.SimpleEntity` now has a \*\*kwargs in the :func:`__init__` so it doesn't give ``TypeError`` for extra keywords * added :class:`stalker.core.models.entity.EntityMeta` metaclass which adds ``entity_type`` attribute and sets its value to the unicode version of the name of the class * the :class:`stalker.core.models.entity.SimpleEntity` uses the :class:`stalker.core.models.entity.EntityMeta` metaclass to automatically add all the ``entity_type`` attribute to all the derived classes * all the mappers now uses the ``ClassName.entity_type`` class attribute as the polymorphic discriminator (polymorphic identity) * instead of *LBYL* moving toward *EAFP* idiom for all the models in the :mod:`stalker.core` * :class:`stalker.core.models.status.StatusList` now supports indexing * :class:`stalker.core.models.status.StatusList` now has an ``target_entity_type`` attribute which accepts strings with the class name and shows the compatible class of this :class:`stalker.core.models.status.StatusList` * :meth:`stakler.core.models.status.StatusList.__eq__` now checks for the ``target_entity_type`` also * :class:`stalker.core.models.status.StatusedEntity` now checks for the given :attr:`stalker.core.models.StatusList.target_entity_type` for compatibility with the current class * All the validation methods in the :mod:`stalker.core.models` now has the **validate** word in their name instead of **check** * Little fixes: * the mapper of :class:`stalker.core.models.types.TypeTemplate` was trying to setup a synonym to a parameter with the same name (file_code) * :class:`stalker.core.models.user.User` classes ``_sequence_lead`` attribute renamed to ``_sequences_lead`` * Added persistence tests for :class:`stalker.core.models.entity.StatusedEntity` * Added :func:`stalker.utils.path_to_exec` which converts the given module full paths to an executable python code which imports the given python object to the current namespace * Added ``entity_types`` table to hold the possible entity types in Stalker. The content of the table comes from the :const:`stalker.conf.defaults.CORE_MODEL_CLASSES` list. And possibly going to be extended by the users. * Added :func:`stalker.db.__setup__.__fill_entity_types_table__` which fills the ``entity_types`` table with default values. * :class:`stalker.core.models.user.User` class now has ``initials`` attribute, which is automatically calculated from the first and last name if there is no one given. * Started developing the :class:`stalker.core.models.message.Message` class * Added the :mod:`stalker.core.models.mixin` module which holds the common mixins. * Added the :class:`stalker.core.models.mixin.ReferenceMixin` class which gives reference abilities to mixed in classes. * Added the database part of the :class:`stalker.core.models.mixin.ReferenceMixin`. Now it is possible to create a new type of entity and mix it with ReferenceMixin and also persist it in the database. But it needs a lot of effort before to have something usable. * Added **examples** module, which holds usage examples and recipes * Added an example about how to create a new mixed in entity type for SOM. 0.1.1.a2 ======== * Updated the Tutorial * Added *code* attribute to :class:`stalker.core.models.entity.SimpleEntity` * Updated the :class:`stalker.core.models.user.User` class for the new *code* attribute, and also updated the tests to add tests for *code* attribute (simply copied the test code from ``SimpleEntityTester``, bad code repetition, need to change it later, by may be inheriting the test case from the other one) * Updated the database tables and mappers for the new *code* attribute * Removed the clashing *code* attribute from :class:`stalker.core.models.pipelineStep.PipelineStep` class and the tables and mappers. * Added :class:`stalker.core.models.note.Note` class * Added ``notes`` table and a mapper for :class:`stalker.core.models.note.Note` class * Added *note* attribute to :class:`stalker.core.models.entity.Entity` class * Fixed ``EntityTester`` in tests * Added ``__repr__`` to entity classes * Added tests for persistence of :class:`stalker.core.models.note.Note`` class * Added equality (__eq__) and inequality (__ne__) operators for classes: * :class:`stalker.core.models.user.User` * :class:`stalker.core.models.tag.Tag` * :class:`stalker.core.models.status.Status` * :class:`stalker.core.models.status.StatusList` * :class:`stalker.core.models.imageFormat.ImageFormat` * :class:`stalker.core.models.repository.Repository` * :class:`stalker.core.models.pipelineStep.PipelineStep` * :class:`stalker.core.models.structure.Structure` * :class:`stalker.core.models.types.AssetType` * :class:`stalker.core.models.types.LinkType` * :class:`stalker.core.models.entity.TypeEntity` * :class:`stalker.core.models.types.ProjectType` * :class:`stalker.core.models.Status` classes' short_name attribute has been removed, from now on the ``code`` attribute will be used, also updated the database tables and mappers * The :attr:`stalker.core.models.user.User.login_name` is now superior to the :attr:`stalker.core.models.user.User.name` attribute, giving both of them as arguments will lead the ``login_name`` to be used as both the ``login_name`` and the ``name`` 0.1.1.a1 ======== * Fixed a couple of documentation errors like: * :ref:`inheritance_diagram_toplevel` had references to modules * A couple of docstring documentation errors in :class:`stalker.core.models.structure.Structure`, :class:`stalker.core.models.user.User` and :class:`stalker.core.models.types.TypeTemplate` classes * Updated :ref:`installation_toplevel` * Added :ref:`tutorial_toplevel` page to the documentation * All the classes, functions from **SQLAlchemy** are now imported to the ``sqlalchemy`` namespace, this will let the **Sphinx** to correctly include classes, functions from **Stalker** only * Removed the ``db.meta module``, now all the functionalities supplied by ``stalker.db.meta`` are supplied by ``db`` itself (``db.meta.session`` --> ``db.session`` etc.) * Added ``query`` variable to :mod:`stalker.db` module so instead of ``db.session.query`` now ``db.query`` can be used * Updated :func:`stalker.db.auth.login_required` decorator function, it now accepts a ``view`` function * Added :func:`stalker.db.auth.permission_required` decorator function * ``name`` attribute of :class:`stalker.core.models.entity.SimpleEntity` is not any more forced to start with an upper case letter * From now on ``login_name`` is now a *synonym* for ``name`` in :class:`stalker.core.models.user.User` class and just the ``name`` attribute is going to be stored in the database * To make things simple all the properties with name **type_** is now using the name **type** even though it is a Python keyword, Python is clever enough to understand what is meant 0.1.1.a0 ======== * Changed the version number scheme a little bit to follow the setuptools guide 0.1.0.20110111.1 ================ * Persistence tests for Link is now fixed * Now every table correctly has a ``primary_key`` 0.1.0.20110110.1 ================ * Added :ref:`installation_toplevel` to the documentation * Updated **README** file for **PyPI** * Added the package to **PyPI** * Fixed ``StatusedEntityTester`` test suit, now it properly creates mock :class:`satlker.coer.models.status.StatusList` object for the ``__eq__`` and ``__ne__`` tests * Updated tables and mappers for :class:`stalker.core.models.typeEntity.TypeTemplate` * Updated mappers for :class:`stalker.core.models.typeEntity.AssetType` * :class:`stalker.core.models.entity.TypeEntity` class is moved to ``entity.py``, right beside the other entity classes * ``typeEntity.py`` renamed to ``types.py`` * Created tables and mappers for: * :class:`stalker.core.models.structure.Structure` * :class:`stalker.core.models.entity.TypeEntity` * :class:`stalker.core.models.types.TypeTemplate` * :class:`stalker.core.models.types.AssetType` * :class:`stalker.core.models.types.LinkType` * :class:`stalker.core.models.types.ProjectType` * Updated ``simpleEntities`` table, now the ``name`` by itself is not a *unique constraint*, but added an explicit ``UniqueConstraint`` on ``name`` and ``entity_type`` columns to allow entities with different types to have the same name, also added test for that. * Fixed all the errors in ``test_db.py``, there are only failures left. * Added tests for :class:`stalker.core.models.link.Link`, all the test are green for :class:`stalker.core.models.link.Link` except the persistence tests. 0.1.0.20110108.1 ================ * ``Template`` class is renamed to ``TypeTemplate`` and moved inside ``stalker.core.models.typeEntity`` to prevent the name clashing with **Jinja2** Template class * added ``__eq__`` to :class:`stalker.core.models.entity.SimpleEntity` and still trying to add it to the derived classes * organized the project structure to conform setup tools for **PyPI** 0.1.0.20110107.2 ================ * updating the db tests * stalker.core.models.user.User class is now allowed to have its department to be set None 0.1.0.20110107.1 ================ * organized the existent tests 0.1.0.20110106.2 ================ * added nice_name property to the stalker.core.models.entity.SimpleEntity class * added tests for stalker.core.models.structure.Structure class * implemented the stalker.core.models.structure.Structure class * added last_login attribute to the stalker.core.models.user.User class and added all the tests 0.1.0.20110106.1 ================ * re-introduced the link.Link, which has a general meaning than reference.Reference (I bet it will be reference again tomorrow) * stalker.models moved to stalker.core.models * renamed tests/z_db to tests/db, because the sqlalchemy/mocker problem is solved by moving the models to core/models 0.1.0.20110105 ============== * improved the stalker.models.template.Template class documentation, and added an example showing the usage of it. 0.1.0.20110104 ============== * removed the link.Link and introduced reference.Reference and typeEntity.ReferenceType classes, which I think are more organized then the previous design. * reorganized the AssetType and ReferenceType objects by introducing the new TypeEntity class and deriving the AssetType and ReferenceType from this class * added ProjectType class to hold different project types (like Commercial, Film, Still etc., it is different than having a Commercial Structure object) * removed AssetTemplate and ReferenceTemplate concepts and generalized the Template class by adding a `type` parameter to it, which accepts TypeEntity and classes derived from TypeEntity. 0.1.0.20110103.2 ================ * added login_required decorator to the stalker.db.auth module, but the implementation is not done yet 0.1.0.20110103 ============== * user.User._password is now scrambled, but the password property uses the raw password * added stalker.db.auth for authentication, removed the db.login function. 0.1.0.20110102 ============== * added the error.LoginError exception for login errors * started to add tests for db.login function 0.1.0.20101231 ============== * moved the login function to the db.__init__ to let it used just after setting up the database without importing any new module * updated the example in the docstring of the template.AssetTemplate 0.1.0.20101229.3 ================ * generalized the Template class. Now every Entity can be assigned to a template, it is not limited with Assets or References. 0.1.0.20101229.2 ================ * entity.SimpleEntity.name now can have white spaces, but not at the beginning or end, just in the middle * done mapping template.Template class 0.1.0.20101229.1 ================ * trying to create a session system with Beaker, to hold user login information * done mapping assetType.AssetType class * done mapping pipelineStep class 0.1.0.20101228.1 ================ * added repositories table and mapper for the repository.Repository class * added imageFormats table and mapper for the imageFormat.ImageFormat class * renamed extensions module to ext * added roadmap to docs 0.1.0.20101228 ============== * created the block of database tests * added stalker.db.meta.__mappers__ list to hold the mappers and use it to check if anything is already mapped * added tests for db initialization * removed the whole stalker.models.unit module from SOM, only TimeUnit was usable in some cases, but in fact it is also not important, the only object using TimeUnit was the Project class and it can go well without it. Don't need to make things more complex than it needs to be. * increased the version number to 0.1.0 to follow the stalker roadmap 0.0.1.20101227 ============== * the test_db is converted to a proper unittest which is testing all the models one by one * test/db renamed to test/z_db to let nose run it latest to solve the problem about mocker and sqlalchemy fighting each other. * Mapping syntax is changed a little bit, now to do the mapping, the .setup() function needs to be called to be able to do the mapping any time * started adding tests for every class in SOM 0.0.1.20101226 ============== * in user.User the last_name attribute could be an empty string * removed SimpleEntity, TaggedEntity and introduced StatusedEntity to make the inheritance clear and let users to find somebody to blame by moving all the audit information to the the SimpleEntity class in which everything is inherited from. Now even a Tag has audit information. 0.0.1.20101225 ============== * entity.AuditEntity.created_by can now be None (for now) * user.User.last_name can now be None, to let users like admin have no last name * creating tables for catch the general inheritance of the entity classes * entitiy.SimpleEntity.name's first letter is not capitalized any more * department.Department class now accepts Null for lead attribute (for now again) 0.0.1.20101224 ============== * started playing with the SQLAlchemy side of the system 0.0.1.20101223 ============== * updating the documentation * AuditEntity now accepts None for updated_by attribute when it an object is created, but sets it to the same value with created_by attribute 0.0.1.20101219 ============== * started to implement: * a database entry point * a customizable object model and database tables * an automatic mapper to map the objects and tables together according to user settings things can change a lot in this phase, I'm just trying to figure out the best possible way to do it. * added a new entity type called TaggedEntity which derives from SimpleEntity, and moved all the tag related attributes of SimpleEntity to TaggedEntity, and all the child classes deriving from SimpleEntity now derives from TaggedEntity, also moved the tests related with tag in SimpleEntity to TaggedEntity. * tag.Tag now derives from the SimpleEntity and doesn't add any other attribute to its super. * updated tests for tag.Tag * updated docs for TaggedEntity * finished implementing the Department object and its tests * removed the notes attribute from the Entity class 0.0.1.20101209 ============== * added the inheritance diagram as an rst page to reference it anywhere needed * added the empty classes for: * Asset * AssetBase * Booking * Shot * Structure * Template * Version * added the Department class * added inheritance diagrams to the autosummary pages of the classes ================================================ FILE: COPYING ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: COPYING.LESSER ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ================================================ FILE: Dockerfile-py3.5 ================================================ # This Dockerfile is based on: https://docs.docker.com/examples/postgresql_service/ FROM ubuntu:16.04 MAINTAINER fredrik@averpil.com # Add the PostgreSQL PGP key to verify their Debian packages. # It should be the same key as https://www.postgresql.org/media/keys/ACCC4CF8.asc RUN apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 # Add PostgreSQL's repository. It contains the most recent stable release # of PostgreSQL, ``9.3``. RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list # Install everything in one enormous RUN command # There are some warnings (in red) that show up during the build. You can hide # them by prefixing each apt-get statement with DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y \ python3-software-properties python3-pip \ software-properties-common \ postgresql-9.3 postgresql-client-9.3 postgresql-contrib-9.3 postgresql-server-dev-9.3 \ rubygems && \ gem install taskjuggler && \ pip3 install -U pip && \ pip3 install sqlalchemy psycopg2-binary jinja2 alembic mako markupsafe python-editor nose coverage # Note: The official Debian and Ubuntu images automatically ``apt-get clean`` # after each ``apt-get`` # Run commands as the ``postgres`` user created by the ``postgres-9.3`` package when it was ``apt-get installed`` USER postgres RUN /etc/init.d/postgresql start && \ psql -c "CREATE DATABASE stalker_test;" -U postgres && \ psql -c "CREATE USER stalker_admin WITH PASSWORD 'stalker';" -U postgres && \ /etc/init.d/postgresql stop # Adjust PostgreSQL configuration so that remote connections to the # database are possible. # RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/9.3/main/pg_hba.conf # And add ``listen_addresses`` to ``/etc/postgresql/9.3/main/postgresql.conf`` # RUN echo "listen_addresses='*'" >> /etc/postgresql/9.3/main/postgresql.conf # Expose the PostgreSQL port # EXPOSE 5432 # Add VOLUMEs to allow backup of config, logs and databases # VOLUME ["/etc/postgresql", "/var/log/postgresql", "/var/lib/postgresql"] USER root # Create symlink to TaskJuggler # RUN ln -s $(which tj3) /usr/local/bin/tj3 # Set working directory WORKDIR /workspace # Embed wait-for-postgres.sh script into Dockerfile RUN echo '\n\ # wait-for-postgres\n\ \n\ set -e\n\ \n\ cmd="$@"\n\ timer="5"\n\ \n\ until runuser -l postgres -c 'pg_isready' 2>/dev/null; do\n\ >&2 echo "Postgres is unavailable - sleeping for $timer seconds"\n\ sleep $timer\n\ done\n\ \n\ >&2 echo "Postgres is up - executing command"\n\ exec $cmd\n'\ >> /workspace/wait-for-postgres.sh # Make script executable RUN chmod +x /workspace/wait-for-postgres.sh # Execute this when running container ENTRYPOINT \ # Copy stalker into container's /workspace' cp -r /stalker /workspace && \ # Remove execution permissions within Stalker chmod -R -x /workspace/stalker && \ # Start PostgreSQL runuser -l postgres -c '/usr/lib/postgresql/9.3/bin/postgres -D /var/lib/postgresql/9.3/main -c config_file=/etc/postgresql/9.3/main/postgresql.conf & ' && \ # Wait for PostgresSQL ./wait-for-postgres.sh nosetests /workspace/stalker --verbosity=1 --cover-erase --with-coverage --cover-package=stalker && \ # Cleanly shut down PostgreSQL /etc/init.d/postgresql stop ================================================ FILE: INSTALL ================================================ See docs/installation.html. ================================================ FILE: LICENSE ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ================================================ FILE: MANIFEST.in ================================================ include *.ini *.cfg include alembic.ini include CHANGELOG.rst include COPYING include COPYING.LESSER include INSTALL include MANIFEST.in include README.rst include stalker/VERSION include TODO include VERSION prune docs/build prune docs/source/generated ================================================ FILE: Makefile ================================================ SHELL:=bash PACKAGE_NAME=stalker NUM_CPUS = $(shell nproc || grep -c '^processor' /proc/cpuinfo) SETUP_PY_FLAGS = --use-distutils VERSION := $(shell cat VERSION) VERSION_FILE=$(CURDIR)/src/stalker/VERSION VIRTUALENV_DIR:=.venv SYSTEM_PYTHON?=python3 all: build FORCE .PHONY: help help: @echo "" @echo "Available targets:" @make -qp | grep -o '^[a-z0-9-]\+' | sort .PHONY: venv venv: @printf "\n\033[36m--- $@: Creating Local virtualenv '$(VIRTUALENV_DIR)' using '$(SYSTEM_PYTHON)' ---\033[0m\n" $(SYSTEM_PYTHON) -m venv $(VIRTUALENV_DIR) build: @printf "\n\033[36m--- $@: Building ---\033[0m\n" echo -e "\n\033[36m--- $@: Local install into virtualenv '$(VIRTUALENV_DIR)' ---\033[0m\n"; source ./$(VIRTUALENV_DIR)/bin/activate; \ echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \ pip install -r requirements.txt; \ pip install -r requirements-dev.txt; \ python -m build; .PHONY: install install: @printf "\n\033[36m--- $@: Installing $(PACKAGE_NAME) to virtualenv at '$(VIRTUALENV_DIR)' using '$(SYSTEM_PYTHON)' ---\033[0m\n" source ./$(VIRTUALENV_DIR)/bin/activate; \ pip install ./dist/$(PACKAGE_NAME)-$(VERSION)-*.whl --force-reinstall; clean: FORCE @printf "\n\033[36m--- $@: Clean ---\033[0m\n" -rm -rf .pytest_cache -rm -f .coverage* -rm -rf .mypy_cache -rm -rf .tox -rm -rf dist -rm -rf build -rm -rf docs/build -rm -rf docs/source/generated/* -rm -rf htmlcov clean-all: clean @printf "\n\033[36m--- $@: Clean All---\033[0m\n" -rm -f INSTALLED_FILES -rm -f setuptools-*.egg -rm -f use-distutils -rm -Rf src/$(PACKAGE_NAME).egg-info -rm -Rf $(VIRTUALENV_DIR) html: ./setup.py readme new-release: @printf "\n\033[36m--- $@: Generating New Release ---\033[0m\n" git add $(VERSION_FILE) git commit -m "Version $(VERSION)" git push git checkout main git pull git merge develop git tag $(VERSION) git push origin main --tags source ./$(VIRTUALENV_DIR)/bin/activate; \ echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \ pip install -r requirements.txt; \ pip install -r requirements-dev.txt; \ python -m build; \ twine check dist/$(PACKAGE_NAME)-$(VERSION).tar.gz; \ twine upload dist/$(PACKAGE_NAME)-$(VERSION).tar.gz; .PHONY: tests tests: @printf "\n\033[36m--- $@: Run Tests ---\033[0m\n" echo -e "\n\033[36m--- $@: Using virtualenv at '$(VIRTUALENV_DIR)' ---\033[0m\n"; source ./$(VIRTUALENV_DIR)/bin/activate; \ echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \ SQLALCHEMY_WARN_20=1 PYTHONPATH=src pytest -n auto -W ignore -W always::DeprecationWarning --color=yes --cov=src --cov-report term --cov-report html --cov-append --cov-fail-under 99 tests; .PHONY: docs docs: cd docs && $(MAKE) html # https://www.gnu.org/software/make/manual/html_node/Force-Targets.html FORCE: ================================================ FILE: README.md ================================================ [![license](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](http://www.gnu.org/licenses/lgpl-3.0) [![Supported Python versions](https://img.shields.io/pypi/pyversions/stalker.svg)](https://pypi.python.org/pypi/stalker) [![Unit Tests](https://github.com/eoyilmaz/stalker/actions/workflows/pytest.yml/badge.svg)](https://github.com/eoyilmaz/stalker/actions/workflows/pytest.yml) [![PyPI Version](https://img.shields.io/pypi/v/stalker.svg)](https://pypi.python.org/pypi/stalker) [![PyPI Downloads](https://static.pepy.tech/badge/stalker)](https://pepy.tech/projects/stalker) About ===== Stalker is an Open Source Production Asset Management (ProdAM) Library designed specifically for Animation and VFX Studios. But it can be used for any kind of projects from any other industry. Stalker is licensed under LGPL v3. Features ======== Stalker has the following features: * Designed for **Animation and VFX Studios** (but not limited to). * OS independent, can work simultaneously with **Windows**, **macOS** and **Linux**. * Supplies excellent **Project Management** capabilities, i.e. scheduling and tracking tasks, milestones and deadlines (via **TaskJuggler**). * Powerful **Asset management** capabilities, allows tracking of asset references in shots, scenes, sequences and projects. * Customizable object model (**Stalker Object Model - SOM**). * Uses **TaskJuggler** as the project planing and tracking backend. * Mainly developed for **PostgreSQL** in mind but **SQLite3** is also supported. * Can be connected to all the major 3D animation packages like **Maya, Houdini, Nuke, Fusion, DaVinci Resolve, Blender** etc. and any application that has a Python API, and for **Adobe Suite** applications like **Adobe Photoshop** through ``win32com`` or ``comtypes`` libraries. * Developed with religious **TDD** practices. Stalker is mainly build over the following OpenSource libraries: * [Python](https://www.python.org) * [PostgreSQL](https://www.postgresql.org/) * [SQLAlchemy](https://www.sqlalchemy.org/) * [Jinja2](https://jinja.palletsprojects.com/en/stable/) * [TaskJuggler](https://taskjuggler.org/) As Stalker is a Python library and doesn't supply any graphical UI you can use other tools like [Stalker Pyramid](https://github.com/eoyilmaz/stalker_pyramid) which is a Pyramid Web Application and [Anima](https://github.com/eoyilmaz/anima) which has PyQt/PySide UIs for applications like Houdini, Maya, Blender, Nuke, Fusion, DaVinci Resolve, Photoshop and many more. Installation ============ Simply use: ```shell pip install stalker ``` Examples ======== Let's play with **Stalker**. Because Stalker uses SQLAlchemy, it is very easy to retrieve complex data. Let's say that you want to query all the Shot Lighting tasks where a specific asset is referenced: ```python from stalker import Asset, File, Shot, Version my_asset = Asset.query.filter_by(name="My Asset").first() # Let's assume we have multiple Versions created for this Asset already my_asset_version = my_asset.versions[0] # get a file from that version my_asset_version_file = my_asset_version.files[0] # now get any other Lighting Versions that is referencing this file refs = ( Version.query .join(File, Version.files) .filter(Version.name=="Lighting") .filter(File.references.contains(my_asset_version_file)) .all() ) ``` Let's say you want to get all the tasks assigned to you in a specific Project: ```python from stalker import Project, Task, User me = User.query.filter_by(name="Erkan Ozgur Yilmaz").first() my_project = Project.query.filter_by(name="My Project").first() query = Task.query.filter_by(project=my_project).filter(Task.resources.contains(me)) my_tasks = query.all() ``` You can further query let's say your WIP tasks by adding more criteria to the ``query`` object: ```python from stalker import Status wip = Status.query.filter_by(code="WIP").first() query = query.filter_by(status=wip) my_wip_tasks = query.all() ``` and that's the way to get complex data in Stalker. See more detailed examples in [API Tutorial](https://pythonhosted.org/stalker/tutorial.html). ================================================ FILE: TODO.rst ================================================ TODO ==== * **Update:** Better support ``duration`` Tasks. The current status workflow is not working with **duration** based tasks. Task statuses should be updated automatically for duration tasks according to their computed start and computed end date values. * Auto history generation. Automatically record all kind of CRUD actions to create a history for any attribute present in an entity. * SCM Integration: The repository can be a local path, and the project can be managed with an SCM, preferably with Mercurial. * Per user settings file: To let Pipeline TDs easily setup a new workstation with a setup script, a predefined file let say with a name of ".strc" can be placed in to the users home folder and Stalker can search for this file and parse it to get things like the database server path, user name and the password. There could be also an $STRC environment variable which is showing a common place lets say in the fileserver, which also may have a ".strc" file. In this way it will be easy to setup only one ".strc" file for the whole studio. * ``__tablename__`` and ``__mapper_args__``: The duty of the ``__tablename__`` and ``__mapper_args__`` variables are very common to any class in the SOM. It can be gathered in a mixin and the :class:`~stalker.core.models.SimpleEntity` can be mixed with this class and the rest will have their table name and polymorphic identity by default. * use pyseq for file sequence handling: PySeq is a great, simple library which handles all the file sequence actions. It would be great to use it in the :class:`~stalker.core.models.Link` instances. So, the :class:`~stalker.core.models.Link` class can also hold a string which can be uncompressed with the pyseq.uncompress function:: from pyseq import uncompress seq = uncompress("./tests/012_vb_110_v001.%04d.png 1-10", format="%h%p%t %r") * Hidden keyword arguments: Because of the heavy inheritance, it is not very clear what parameters are needed to initialize a class. A simple solution is to repeat all the parameters of the inherited class in the __init__ of the child class. DONE ==== * Update error messages: Not all error message are clear. Generally, because of the heavy inheritance, it is not very obvious which class gave the error. Writing down the class name should help the user to understand at least what class is giving the error message. * Drop support to any database other then PostgreSQL. This is needs to be done mainly to benefit from a system that is highly optimized for one database in mind. There are some certain functionality that is only supported in PostgreSQL and will make Stalker better but break the compatibility to any other DB. Also trying to support SQLite3 with its lack of support to some key functionality stabs Stalker in back. * Test CRUD: The database tests should test if all the Create, Read, Update and Delete operations are happening properly. * Plural name: The plural name attribute needs to be reintroduced. * datetime instead of date In the Task class all the time calculation should be done over the datetime.datetime class instead of datetime.date object. This will let us to increase the granularity of the scheduling. * ``end_date`` attribute in DateRangeMixin: there could be a synonym for the ``due_date`` attribute in the :class:`~stalker.core.models.DateRangeMixin`\ . Which will be meaningful for :class:`~stalker.core.models.Booking` class. * Logging: Use the Python logging module to output Debug messages. * StatusMixin and string indices: StatusMixin should be able to set the status with a string, because it is possible to use strings in StatusList. * Start & End Date For Classes Mixed With TaskMixin: All the classes which are mixed with TaskMixin should have a start and end date attribute which will be set to the start date of the first task to the due_date of the last task. * Refactor target_entity_type attributes StatusList, FilenameTemplate and Type classes are using the same target_entity_type attribute, create a mixin for that. * OverBookingWarning: Create a new Warning for the :class:`~stalker.core.models.Booking` class which will be emitted when a resource is booked for the same time period more than once. * Auto StatusList connection: StatusLists can be automatically connected to the created instance if there is already a database setup and a StatusList instance already defined for the current class. This means mixing the model part with the control part but it is acceptable. * Stop the fight between SimpleEntity.name and SimpleEntity.code. Currently name superseeds code, but it is annoying to change the code over and over again just because the name is changed. So change the behaviour to something like that; the code is only updated to the same value with name if it is set to None or empty string. In any other case the code should remain in the same value. * SQLAlchemy ORM Declarative: Use declarative for the whole system. It started to make no sense to use classical approach with Python objects and it started to be very hard to try to update all the relations which is handled automatically by SQLAlchemy. Besides, the work done by all the attributes which are using ValidatedList is replaced with a neat system whenever the mapping has occured. Which is the usage case %90 of the time. Tests are going to be nearly the same. The only programming overhead is the implementation itself. Mixin classes also needs some attention, but as far as I see it is successfully handled withing declarative approach. * "__stalker_version__" in SimpleEntity: Create an attribute called __stalker_version__ in the SimpleEntity, and automatically update it to the current version string of Stalker to be able to see with which version of Stalker this data is created, mainly important for the database part. * Replace all the Mocker based tests with Unittest's which are using real objects. It was necessary to use the Mocker library while designing the rest of the system, but it is now making things complex and started to hide the changes of one object from the others in the system. * Convert all the list comparison test to assertItemsEqual * Add a slot in the ValidatedList which will hold the callable for the validation process when any of the objects are changed (set, remove, delete etc.) to allow the callable to be called when something has changed. This will allow more control on the list, e.g. this will help controling the relation of the classes to each other. * Check FilenameTemplate class documentation. * Check database part of all the previous Type dependent classes (Link, Asset, Project, Task) * Update the exceptions. Check if a proper exception is raised instead of raising ValueErrors all the time. * A Status in StatusList should be accessed by its name used as the index * A status should be comparable with a string like project.status=="complete" or project.status=="cmplt" * for an object which stores a list of other objects, stalker is validating if the list is gathered from the correct type of objects, for example, StatusList objects only accepts a list of Status objects. Stalker is able to check if the elements in a list are Status objects when a list is assigned to the StatusList.statuses attribute, but it can not check anything if the list element is changed individually afterwards. This behaviour should be extended with a validating system which is able to track changes on list elements. SOLUTION: Added the ValidatedList list variant which does all the necessary things explained in the problem. ================================================ FILE: alembic/README ================================================ Generic single-database configuration. ================================================ FILE: alembic/env.py ================================================ # -*- coding: utf-8 -*- """Setup environment for migration.""" from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool from stalker.db.declarative import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure(url=url) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ engine = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, ) connection = engine.connect() context.configure(connection=connection, target_metadata=target_metadata) try: with context.begin_transaction(): context.run_migrations() finally: connection.close() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: alembic/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} def upgrade(): """Upgrade the tables.""" ${upgrades if upgrades else "pass"} def downgrade(): """Downgrade the tables.""" ${downgrades if downgrades else "pass"} ================================================ FILE: alembic/versions/0063f547dc2e_updated_version_inputs_table.py ================================================ """updated version_inputs table. Revision ID: 0063f547dc2e Revises: a9319b19f7be Create Date: 2016-11-29 14:08:41.335000 """ from alembic import op # revision identifiers, used by Alembic. revision = "0063f547dc2e" down_revision = "a9319b19f7be" def upgrade(): """Upgrade the tables.""" op.drop_constraint( "Version_Inputs_link_id_fkey", "Version_Inputs", type_="foreignkey" ) op.create_foreign_key( None, "Version_Inputs", "Links", ["link_id"], ["id"], onupdate="CASCADE", ondelete="CASCADE", ) def downgrade(): """Downgrade the tables.""" op.drop_constraint(None, "Version_Inputs", type_="foreignkey") op.create_foreign_key( "Version_Inputs_link_id_fkey", "Version_Inputs", "Links", ["link_id"], ["id"] ) ================================================ FILE: alembic/versions/019378697b5b_rename_depends_to_to_depends_on.py ================================================ """Rename depends_to to depends_on Revision ID: 019378697b5b Revises: feca9bac7d5a Create Date: 2024-11-01 13:59:11.513575 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "019378697b5b" down_revision = "feca9bac7d5a" def upgrade(): """Upgrade the tables.""" op.alter_column( "Task_Dependencies", "depends_to_id", new_column_name="depends_on_id" ) def downgrade(): """Downgrade the tables.""" op.alter_column( "Task_Dependencies", "depends_on_id", new_column_name="depends_to_id" ) ================================================ FILE: alembic/versions/101a789e38ad_created_task_responsible.py ================================================ """Created "Task.responsible" attribute. Revision ID: 101a789e38ad Revises: 59092d41175c Create Date: 2013-06-24 12:32:04.852386 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "101a789e38ad" down_revision = "59092d41175c" def upgrade(): """Upgrade the tables.""" try: op.drop_column("Sequences", "lead_id") op.add_column("Tasks", sa.Column("responsible_id", sa.Integer(), nullable=True)) except sa.exc.OperationalError: pass def downgrade(): """Downgrade the tables.""" try: op.drop_column("Tasks", "responsible_id") op.add_column("Sequences", sa.Column("lead_id", sa.INTEGER(), nullable=True)) except sa.exc.OperationalError: pass ================================================ FILE: alembic/versions/1181305d3001_added_client_id_column_to_goods_table.py ================================================ """Added client_id column to Goods table. Revision ID: 1181305d3001 Revises: 31b1e22b455e Create Date: 2017-05-17 18:17:46.555000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "1181305d3001" down_revision = "31b1e22b455e" def upgrade(): """Upgrade the tables.""" op.add_column("Goods", sa.Column("client_id", sa.Integer(), nullable=True)) op.create_foreign_key(None, "Goods", "Clients", ["client_id"], ["id"]) def downgrade(): """Downgrade the tables.""" op.drop_constraint(None, "Goods", type_="foreignkey") op.drop_column("Goods", "client_id") ================================================ FILE: alembic/versions/130a7697cd79_vacation_user_can_now_be_nullable.py ================================================ """Vacation.user can now be nullable. Revision ID: 130a7697cd79 Revises: 57a5949c7f29 Create Date: 2013-08-02 19:58:59.638085 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "130a7697cd79" down_revision = "57a5949c7f29" def upgrade(): """Upgrade the tables.""" op.alter_column("Vacations", "user_id", existing_type=sa.INTEGER(), nullable=True) def downgrade(): """Downgrade the tables.""" op.alter_column("Vacations", "user_id", existing_type=sa.INTEGER(), nullable=False) ================================================ FILE: alembic/versions/174567b9c159_note_content.py ================================================ """'Note.content' is now a synonym of 'Note.description'. Revision ID: 174567b9c159 Revises: a6598cde6b Create Date: 2013-11-14 13:38:02.566201 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "174567b9c159" down_revision = "a6598cde6b" def upgrade(): """Upgrade the tables.""" op.drop_column("Notes", "content") def downgrade(): """Downgrade the tables.""" op.add_column("Notes", sa.Column("content", sa.VARCHAR(), nullable=True)) ================================================ FILE: alembic/versions/182f44ce5f07_added_users_company_and_projects_client.py ================================================ """added "Users.company" and "Projects.client" columns and a new Clients table. Revision ID: 182f44ce5f07 Revises: 59bfe820c369 Create Date: 2014-05-29 11:33:02.313000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "182f44ce5f07" down_revision = "59bfe820c369" def upgrade(): """Upgrade the tables.""" # Create Clients table op.create_table( "Clients", sa.Column("id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) # Users table op.add_column("Users", sa.Column("company_id", sa.Integer(), nullable=True)) op.create_foreign_key( name=None, source="Users", referent="Clients", local_cols=["company_id"], remote_cols=["id"], ) # Projects table op.add_column("Projects", sa.Column("client_id", sa.Integer(), nullable=True)) op.create_foreign_key( name=None, source="Projects", referent="Clients", local_cols=["client_id"], remote_cols=["id"], ) def downgrade(): """Downgrade the tables.""" op.drop_column("Users", "company_id") op.drop_column("Projects", "client_id") op.drop_table("Clients") ================================================ FILE: alembic/versions/1875136a2bfc_removed_version_variant_name_attribute.py ================================================ """Removed Version.variant_name attribute Revision ID: 1875136a2bfc Revises: a2007ad7f535 Create Date: 2024-11-28 10:18:56.634490 """ from alembic import op import sqlalchemy as sa import stalker # revision identifiers, used by Alembic. revision = "1875136a2bfc" down_revision = "a2007ad7f535" def upgrade(): """Upgrade the tables.""" # create a new Variant task for all the Versions as their new parents. op.execute( f""" -- add temporary column to simple entities ALTER TABLE "SimpleEntities" ADD temp_variant_parent_id integer; -- insert a new Variant for each distinct Version.variant_name WITH sel1 as ( SELECT subtable.task_id as task_id, 'Variant' as entity_type, subtable.name as name, 'Created by alembic revision: {revision}' as description, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)) as date_created, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)) as date_updated, '' as html_style, '' as html_class, '{stalker.__version__}' as stalker_version, subtable.project_id as project_id, false as is_milestone, subtable.allocation_strategy as allocation_strategy, subtable.persistent_allocation as persistent_allocation, 500 as priority, 10 as bid_timing, 'min' as bid_unit, 0 as schedule_seconds, 0 as total_logged_seconds, 0 as review_number, subtable.good_id as good_id, subtable.status_id as status_id, (SELECT id FROM "StatusLists" WHERE "StatusLists".target_entity_type = 'Variant') as status_list_id, subtable.start as start, subtable.duration as duration, subtable.computed_end as computed_end, subtable.computed_start as computed_start, subtable.end as end, subtable.schedule_timing as schedule_timing, subtable.schedule_unit as schedule_unit, subtable.schedule_constraint as schedule_constraint, subtable.schedule_model as schedule_model, subtable.parent_id as parent_id FROM ( SELECT (ARRAY_AGG(DISTINCT(COALESCE("Versions".task_id))))[1] as task_id, (ARRAY_AGG(DISTINCT(COALESCE("Versions".variant_name))))[1] as name, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".project_id))))[1] as project_id, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".status_id))))[1] as status_id, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".allocation_strategy))))[1] as allocation_strategy, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".persistent_allocation))))[1] as persistent_allocation, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".good_id))))[1] as good_id, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".schedule_constraint))))[1] as schedule_constraint, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".schedule_model))))[1] as schedule_model, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".parent_id))))[1] as parent_id, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".start))))[1] as start, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".duration))))[1] as duration, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".computed_end))))[1] as computed_end, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".computed_start))))[1] as computed_start, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".end))))[1] as end, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".schedule_timing))))[1] as schedule_timing, (ARRAY_AGG(DISTINCT(COALESCE("Version_Tasks".schedule_unit))))[1] as schedule_unit FROM "Versions" JOIN "SimpleEntities" AS "Version_SimpleEntities" ON "Versions".id = "Version_SimpleEntities".id JOIN "Tasks" AS "Version_Tasks" ON "Versions".task_id = "Version_Tasks".id JOIN "StatusLists" AS "Variant_StatusLists" ON "Variant_StatusLists".target_entity_type = 'Variant' GROUP BY "Versions".task_id, "Versions".variant_name ORDER BY "Versions".task_id ) as subtable ), ins1 as ( INSERT INTO "SimpleEntities" ( entity_type, name, description, date_created, date_updated, html_style, html_class, stalker_version, temp_variant_parent_id ) ( SELECT sel1.entity_type, sel1.name, sel1.description, sel1.date_created, sel1.date_updated, sel1.html_style, sel1.html_class, sel1.stalker_version, sel1.task_id -- use task_id as parent_id FROM sel1 ) RETURNING id as variant_id, name as variant_name, temp_variant_parent_id as variant_parent_id ), ins2 as ( INSERT INTO "Entities" (id) (SELECT ins1.variant_id FROM ins1) ), ins3 AS ( INSERT INTO "Tasks" ( id, project_id, is_milestone, allocation_strategy, persistent_allocation, priority, bid_timing, bid_unit, schedule_seconds, total_logged_seconds, review_number, good_id, status_id, status_list_id, start, duration, computed_end, computed_start, "end", schedule_timing, schedule_unit, schedule_constraint, schedule_model, parent_id ) ( SELECT ins1.variant_id, sel1.project_id, sel1.is_milestone, sel1.allocation_strategy, sel1.persistent_allocation, sel1.priority, sel1.bid_timing, CAST(sel1.bid_unit as public."TimeUnit"), sel1.schedule_seconds, sel1.total_logged_seconds, sel1.review_number, sel1.good_id, sel1.status_id, sel1.status_list_id, sel1.start, sel1.duration, sel1.computed_end, sel1.computed_start, sel1.end, sel1.schedule_timing, sel1.schedule_unit, sel1.schedule_constraint, sel1.schedule_model, sel1.task_id -- use the original task as the parent of the new Variant FROM sel1, ins1 WHERE sel1.name = ins1.variant_name AND sel1.task_id = ins1.variant_parent_id ) ) INSERT INTO "Variants" (id) (SELECT ins1.variant_id FROM ins1); -- Update the Versions to use the new Variants as parents UPDATE "Versions" SET task_id = subtable.variant_id FROM ( SELECT "Versions".id as version_id, "Versions".variant_name as version_variant_name, "Versions".task_id as version_task_id, "Tasks".id as task_id, "Variant_Tasks".parent_id as variant_parent_id, "Variant_Tasks".id as variant_id, "Variant_SimpleEntities".name as variant_name FROM "Versions" JOIN "Tasks" ON "Versions".task_id = "Tasks".id JOIN "Tasks" AS "Variant_Tasks" ON "Variant_Tasks".parent_id = "Tasks".id JOIN "SimpleEntities" AS "Variant_SimpleEntities" ON "Variant_Tasks".id = "Variant_SimpleEntities".id WHERE "Versions".variant_name = "Variant_SimpleEntities".name ORDER BY "Versions".id ) as subtable WHERE "Versions".id = subtable.version_id; -- Remove the temporary column ALTER TABLE "SimpleEntities" DROP COLUMN temp_variant_parent_id; -- And drop the Versions.variant_name column ALTER TABLE "Versions" DROP COLUMN variant_name; """ ) def downgrade(): """Downgrade the tables.""" op.add_column( "Versions", sa.Column( "variant_name", sa.VARCHAR(length=256), autoincrement=False, nullable=True ), ) op.execute( """ -- Update Version.variant_name with parent names UPDATE "Versions" SET (variant_name, task_id) = (subtable.variant_name, subtable.task_id) FROM ( SELECT "Versions".id as version_id, "Variant_SimpleEntities".name as variant_name, "Variant_Tasks".parent_id as task_id FROM "Versions" JOIN "SimpleEntities" as "Variant_SimpleEntities" on "Versions".task_id = "Variant_SimpleEntities".id JOIN "Tasks" as "Variant_Tasks" on "Versions".task_id = "Variant_Tasks".id ) as subtable WHERE subtable.version_id = "Versions".id; -- Remove all the variants that had a version before -- match by the Variant.name = Version.variant_name under the same parent task DELETE FROM "Variants" WHERE "Variants".id IN ( SELECT DISTINCT("Variants".id) --, -- "Variant_SimpleEntities".name, -- "Variant_Tasks".parent_id, -- "Versions".variant_name, -- "Versions".task_id FROM "Variants" JOIN "Tasks" AS "Variant_Tasks" ON "Variants".id = "Variant_Tasks".id JOIN "Versions" ON "Variant_Tasks".parent_id = "Versions".task_id JOIN "SimpleEntities" AS "Variant_SimpleEntities" ON "Variants".id = "Variant_SimpleEntities".id WHERE "Variant_SimpleEntities".name = "Versions".variant_name ); """ ) ================================================ FILE: alembic/versions/1c9c9c28c102_price_lists_and_goods.py ================================================ """Add PriceLists and Goods. Revision ID: 1c9c9c28c102 Revises: 856e70016b2 Create Date: 2015-01-26 13:05:50.050345 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "1c9c9c28c102" down_revision = "856e70016b2" def upgrade(): """Upgrade the tables.""" op.create_table( "PriceLists", sa.Column("id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "Goods", sa.Column("id", sa.Integer(), nullable=False), sa.Column("cost", sa.Float(), nullable=True), sa.Column("msrp", sa.Float(), nullable=True), sa.Column("unit", sa.String(length=64), nullable=True), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "PriceList_Goods", sa.Column("price_list_id", sa.Integer(), nullable=False), sa.Column("good_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["good_id"], ["Goods.id"], ), sa.ForeignKeyConstraint( ["price_list_id"], ["PriceLists.id"], ), sa.PrimaryKeyConstraint("price_list_id", "good_id"), ) with op.batch_alter_table("BudgetEntries", schema=None) as batch_op: batch_op.add_column(sa.Column("cost", sa.Float(), nullable=True)) batch_op.add_column(sa.Column("msrp", sa.Float(), nullable=True)) batch_op.add_column(sa.Column("price", sa.Float(), nullable=True)) batch_op.add_column(sa.Column("realized_total", sa.Float(), nullable=True)) batch_op.add_column(sa.Column("unit", sa.String(length=64), nullable=True)) with op.batch_alter_table("Budgets", schema=None) as batch_op: batch_op.add_column(sa.Column("parent_id", sa.Integer(), nullable=True)) def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("Budgets", schema=None) as batch_op: batch_op.drop_column("parent_id") with op.batch_alter_table("BudgetEntries", schema=None) as batch_op: batch_op.drop_column("unit") batch_op.drop_column("realized_total") batch_op.drop_column("price") batch_op.drop_column("msrp") batch_op.drop_column("cost") op.drop_table("PriceList_Goods") op.drop_table("Goods") op.drop_table("PriceLists") ================================================ FILE: alembic/versions/21b88ed3da95_added_referencemixin.py ================================================ """Added ReferenceMixin to Task. Revision ID: 21b88ed3da95 Revises: 4664d72ce1e1 Create Date: 2013-05-31 12:08:59.425539 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "21b88ed3da95" down_revision = "4664d72ce1e1" def upgrade(): """Upgrade the tables.""" try: op.create_table( "Task_References", sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("link_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["link_id"], ["Links.id"], ), sa.ForeignKeyConstraint( ["task_id"], ["Tasks.id"], ), sa.PrimaryKeyConstraint("task_id", "link_id"), ) except sa.exc.OperationalError: pass op.drop_table("Asset_References") op.drop_table("Shot_References") op.drop_table("Sequence_References") def downgrade(): """Downgrade the tables.""" op.create_table( "Sequence_References", sa.Column("sequence_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.Column("link_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.ForeignKeyConstraint( ["link_id"], ["Links.id"], name="Sequence_References_link_id_fkey" ), sa.ForeignKeyConstraint( ["sequence_id"], ["Sequences.id"], name="Sequence_References_sequence_id_fkey", ), sa.PrimaryKeyConstraint( "sequence_id", "link_id", name="Sequence_References_pkey" ), ) op.create_table( "Shot_References", sa.Column("shot_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.Column("link_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.ForeignKeyConstraint( ["link_id"], ["Links.id"], name="Shot_References_link_id_fkey" ), sa.ForeignKeyConstraint( ["shot_id"], ["Shots.id"], name="Shot_References_shot_id_fkey" ), sa.PrimaryKeyConstraint("shot_id", "link_id", name="Shot_References_pkey"), ) op.create_table( "Asset_References", sa.Column("asset_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.Column("link_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.ForeignKeyConstraint( ["asset_id"], ["Assets.id"], name="Asset_References_asset_id_fkey" ), sa.ForeignKeyConstraint( ["link_id"], ["Links.id"], name="Asset_References_link_id_fkey" ), sa.PrimaryKeyConstraint("asset_id", "link_id", name="Asset_References_pkey"), ) op.drop_table("Task_References") ================================================ FILE: alembic/versions/2252e51506de_multiple_repositories.py ================================================ """Multiple Repositories per Project. Revision ID: 2252e51506de Revises: 1c9c9c28c102 Create Date: 2015-01-28 00:46:29.139946 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "2252e51506de" down_revision = "1c9c9c28c102" def upgrade(): """Upgrade the tables.""" op.create_table( "Project_Repositories", sa.Column("project_id", sa.Integer(), nullable=False), sa.Column("repo_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint(["project_id"], ["Projects.id"]), sa.ForeignKeyConstraint(["repo_id"], ["Repositories.id"]), sa.PrimaryKeyConstraint("project_id", "repo_id"), ) # before dropping repository column, carry all the data to the new table op.execute( 'insert into "Project_Repositories"' " select id, repository_id " ' from "Projects"' ) with op.batch_alter_table("Projects", schema=None) as batch_op: batch_op.drop_column("repository_id") def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("Projects", schema=None) as batch_op: batch_op.add_column( sa.Column("repository_id", sa.INTEGER(), autoincrement=False, nullable=True) ) # before dropping Project_Repositories, carry all the data back, # note that only the first repository found per project will be # restored to the Project.repository_id column op.execute(""" UPDATE "Projects" SET repository_id = ( SELECT repo_id FROM "Project_Repositories" WHERE project_id = "Projects".id LIMIT 1 )""" ) op.drop_table("Project_Repositories") ================================================ FILE: alembic/versions/23dff41c95ff_removed_tasks_is_complete_column.py ================================================ """Removed Tasks.is_complete column. Revision ID: 23dff41c95ff Revises: 5999269aad30 Create Date: 2014-06-11 14:00:00.559122 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "23dff41c95ff" down_revision = "5999269aad30" def upgrade(): """Upgrade the tables.""" op.drop_column("Tasks", "is_complete") def downgrade(): """Downgrade the tables.""" op.add_column("Tasks", sa.Column("is_complete", sa.BOOLEAN(), nullable=True)) ================================================ FILE: alembic/versions/255ee1f9c7b3_added_payments_table.py ================================================ """Added Payments table. Revision ID: 255ee1f9c7b3 Revises: ea28a39ba3f5 Create Date: 2016-08-18 03:19:22.301000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "255ee1f9c7b3" down_revision = "ea28a39ba3f5" def upgrade(): """Upgrade the tables.""" op.create_table( "Payments", sa.Column("id", sa.Integer(), nullable=False), sa.Column("invoice_id", sa.Integer(), nullable=True), sa.Column("amount", sa.Float(), nullable=True), sa.Column("unit", sa.String(length=64), nullable=True), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.ForeignKeyConstraint( ["invoice_id"], ["Invoices.id"], ), sa.PrimaryKeyConstraint("id"), ) def downgrade(): """Downgrade the tables.""" op.drop_table("Payments") ================================================ FILE: alembic/versions/258985128aff_create_entitygroups_table.py ================================================ """create EntityGroups table. Revision ID: 258985128aff Revises: 39d3c16ff005 Create Date: 2016-05-16 16:06:39.389000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "258985128aff" down_revision = "39d3c16ff005" def upgrade(): """Upgrade the tables.""" op.create_table( "EntityGroups", sa.Column("id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "EntityGroup_Entities", sa.Column("entity_group_id", sa.Integer(), nullable=False), sa.Column("other_entity_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["entity_group_id"], ["EntityGroups.id"], ), sa.ForeignKeyConstraint( ["other_entity_id"], ["SimpleEntities.id"], ), sa.PrimaryKeyConstraint("entity_group_id", "other_entity_id"), ) def downgrade(): """Downgrade the tables.""" op.drop_table("EntityGroup_Entities") op.drop_table("EntityGroups") ================================================ FILE: alembic/versions/25b3eba6ffe7_derive_version_from.py ================================================ """Derive Version from Link instead of Entity. Revision ID: 25b3eba6ffe7 Revises: 53d8127d8560 Create Date: 2013-05-22 16:51:53.136718 """ from alembic import op import sqlalchemy as sa from stalker import defaults, log # revision identifiers, used by Alembic. revision = "25b3eba6ffe7" down_revision = "53d8127d8560" logger = log.get_logger(__name__) def upgrade(): """Upgrade the tables.""" try: op.drop_column("Versions", "source_file_id") except (sa.exc.OperationalError, sa.exc.InternalError): # SQLite doesn't support it pass try: op.create_foreign_key(None, "Versions", "Links", ["id"], ["id"]) # FOREIGN KEY ( id ) REFERENCES Entities ( id ) DEFERRABLE INITIALLY DEFERRED # FOREIGN KEY ( id ) REFERENCES Links ( id ) DEFERRABLE INITIALLY DEFERRED except NotImplementedError: # there is no way to create the foreign key in SQLite # and it is incredibly hard to upgrade it # so I opt to skip this part for SQLite and loose data # so create a new table with the name Versions_New and create columns # The DDL of the new table # + id INTEGER PRIMARY KEY NOT NULL, # + version_of_id INTEGER NOT NULL, # + take_name TEXT, # + version_number INTEGER NOT NULL, # + parent_id INTEGER, # + is_published TEXT, # + status_id INTEGER NOT NULL, # + status_list_id INTEGER NOT NULL, # + FOREIGN KEY ( status_list_id ) REFERENCES StatusLists ( id ) # DEFERRABLE INITIALLY DEFERRED, # + FOREIGN KEY ( status_id ) REFERENCES Statuses ( id ) # DEFERRABLE INITIALLY DEFERRED, # + FOREIGN KEY ( parent_id ) REFERENCES Versions ( id ) # DEFERRABLE INITIALLY DEFERRED, # + FOREIGN KEY ( version_of_id ) REFERENCES Tasks ( id ) # DEFERRABLE INITIALLY DEFERRED, # + FOREIGN KEY ( id ) REFERENCES Links ( id ) DEFERRABLE INITIALLY DEFERRED op.create_table( "Versions_New", sa.Column("id", sa.Integer, sa.ForeignKey("Links.id"), primary_key=True), sa.Column( "version_of_id", sa.Integer, sa.ForeignKey("Tasks.id"), nullable=False ), sa.Column("take_name", sa.String(256), default=defaults.version_take_name), sa.Column("version_number", sa.Integer, default=1, nullable=False), sa.Column("parent_id", sa.Integer, sa.ForeignKey("Versions.id")), sa.Column("is_published", sa.Boolean, default=False), sa.Column( "status_id", sa.Integer, sa.ForeignKey("Statuses.id"), nullable=False ), sa.Column( "status_list_id", sa.Integer, sa.ForeignKey("StatusLists.id"), nullable=False, ), ) # ********************************************************************* # SKIP THIS PART # then copy the data from the original table to the new table # s = sa.sql.select( # ['Versions', 'Links'] # ).where('Versions.c.source_link_id == Links.c.id') # result = op.execute(s) # # if result: # data = [] # for row in result: # # get the source Link # # data.append() # ********************************************************************* # and then delete the original table op.drop_table("Versions") # and rename the new table to the old one op.rename_table("Versions_New", "Versions") def downgrade(): """Downgrade the tables.""" op.add_column("Versions", sa.Column("source_file_id", sa.INTEGER(), nullable=True)) op.create_foreign_key(None, "Versions", "Entities", ["id"], ["id"]) ================================================ FILE: alembic/versions/275bdc106fd5_added_ticket_summary.py ================================================ """Added "Ticket.summary". Revision ID: 275bdc106fd5 Revises: 130a7697cd79 Create Date: 2013-08-07 00:19:39.414232 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "275bdc106fd5" down_revision = "130a7697cd79" def upgrade(): """Upgrade the tables.""" op.add_column("Tickets", sa.Column("summary", sa.String(), nullable=True)) def downgrade(): """Downgrade the tables.""" op.drop_column("Tickets", "summary") ================================================ FILE: alembic/versions/2aeab8b376dc_fg_color_bg_color.py ================================================ """Remove Statuses.bg_color and Statuses.fg_color columns. Revision ID: 2aeab8b376dc Revises: 5168cc8552a3 Create Date: 2013-11-18 23:44:49.428028 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "2aeab8b376dc" down_revision = "5168cc8552a3" def upgrade(): """Upgrade the tables.""" op.drop_column("Statuses", "bg_color") op.drop_column("Statuses", "fg_color") def downgrade(): """Downgrade the tables.""" op.add_column("Statuses", sa.Column("fg_color", sa.INTEGER(), nullable=True)) op.add_column("Statuses", sa.Column("bg_color", sa.INTEGER(), nullable=True)) ================================================ FILE: alembic/versions/2e4a3813ae76_created_daily_class.py ================================================ """Created Daily class and the "Daily Statuses" status list and the status Open. Revision ID: 2e4a3813ae76 Revises: 23dff41c95ff Create Date: 2014-06-23 17:14:33.013543 """ from alembic import op import sqlalchemy as sa import stalker # revision identifiers, used by Alembic. revision = "2e4a3813ae76" down_revision = "23dff41c95ff" def upgrade(): """Upgrade the tables.""" op.create_table( "Dailies", sa.Column("id", sa.Integer(), nullable=False), sa.Column("status_id", sa.Integer(), nullable=False), sa.Column("status_list_id", sa.Integer(), nullable=False), sa.Column("project_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.ForeignKeyConstraint( ["project_id"], ["Projects.id"], name="project_x_id", use_alter=True ), sa.ForeignKeyConstraint( ["status_id"], ["Statuses.id"], ), sa.ForeignKeyConstraint( ["status_list_id"], ["StatusLists.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "Daily_Links", sa.Column("daily_id", sa.Integer(), nullable=False), sa.Column("link_id", sa.Integer(), nullable=False), sa.Column("rank", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["daily_id"], ["Dailies.id"], ), sa.ForeignKeyConstraint( ["link_id"], ["Links.id"], ), sa.PrimaryKeyConstraint("daily_id", "link_id"), ) # create new Statuses # # 'Open', 'OPEN', def create_status(name, code): # Insert in to SimpleEntities op.execute( f"""INSERT INTO "SimpleEntities" (entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, type_id, thumbnail_id, html_style, html_class, stalker_version) VALUES ('Status', '{name}', '', NULL, NULL, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), NULL, NULL, '', '', '{stalker.__version__}')""" ) # insert in to Entities and Statuses op.execute( f"""INSERT INTO "Entities" (id) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = '{name}' )); INSERT INTO "Statuses" (id, code) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = '{name}'), '{code}');""" ) create_status("Open", "OPEN") # Create Review StatusList # Insert in to SimpleEntities op.execute( f"""INSERT INTO "SimpleEntities" (entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, type_id, thumbnail_id, html_style, html_class, stalker_version) VALUES ('StatusList', 'Daily Statuses', '', NULL, NULL, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), NULL, NULL, '', '', '{stalker.__version__}')""" ) # insert in to Entities and StatusLists op.execute( """INSERT INTO "Entities" (id) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Daily Statuses' )); INSERT INTO "StatusLists" (id, target_entity_type) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Daily Statuses'), 'Daily');""" ) # Add Review Statues To StatusList_Statuses # Add new Task statuses to StatusList op.execute( """INSERT INTO "StatusList_Statuses" (status_list_id, status_id) VALUES ((SELECT id FROM "StatusLists" WHERE target_entity_type = 'Daily'), (SELECT id FROM "Statuses" WHERE code = 'OPEN')), ((SELECT id FROM "StatusLists" WHERE target_entity_type = 'Daily'), (SELECT id FROM "Statuses" WHERE code = 'CLS')) """ ) def downgrade(): """Downgrade the tables.""" op.drop_table("Daily_Links") op.drop_table("Dailies") # Delete Open Status op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_id IN (select id FROM "SimpleEntities" WHERE name = 'Open'); DELETE FROM "Statuses" WHERE id IN (select id FROM "SimpleEntities" WHERE name = 'Open'); DELETE FROM "Entities" WHERE id IN (select id FROM "SimpleEntities" WHERE name = 'Open'); DELETE FROM "SimpleEntities" WHERE name = 'Open'; """ ) # Delete Daily Statuses op.execute( """ DELETE FROM "StatusList_Statuses" WHERE status_list_id=(SELECT id FROM "SimpleEntities" WHERE name='Daily Statuses'); DELETE FROM "StatusLists" WHERE id=(SELECT id FROM "SimpleEntities" WHERE name='Daily Statuses'); DELETE FROM "Entities" WHERE id=(SELECT id FROM "SimpleEntities" WHERE name='Daily Statuses'); DELETE FROM "SimpleEntities" WHERE name = 'Daily Statuses'; """ ) ================================================ FILE: alembic/versions/2f55dc4f199f_wiki_page.py ================================================ """Add Wiki Page. Revision ID: 2f55dc4f199f Revises: 433d9caaafab Create Date: 2014-03-24 16:52:45.127579 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "2f55dc4f199f" down_revision = "433d9caaafab" def upgrade(): """Upgrade the tables.""" op.create_table( "Pages", sa.Column("id", sa.Integer(), nullable=False), sa.Column("title", sa.String(), nullable=True), sa.Column("content", sa.String(), nullable=True), sa.Column("project_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint(["id"], ["Entities.id"]), sa.ForeignKeyConstraint( ["project_id"], ["Projects.id"], name="project_x_id", use_alter=True ), sa.PrimaryKeyConstraint("id"), ) def downgrade(): """Downgrade the tables.""" op.drop_table("Pages") ================================================ FILE: alembic/versions/30c576f3691_budget_and_budget_entry.py ================================================ """Added Budget and BudgetEntry tables. Revision ID: 30c576f3691 Revises: 409d2d73ca30 Create Date: 2014-11-20 22:49:37.015323 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "30c576f3691" down_revision = "409d2d73ca30" def upgrade(): """Upgrade the tables.""" op.create_table( "Budgets", sa.Column("id", sa.Integer(), nullable=False), sa.Column("project_id", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.ForeignKeyConstraint( ["project_id"], ["Projects.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "BudgetEntries", sa.Column("id", sa.Integer(), nullable=False), sa.Column("budget_id", sa.Integer(), nullable=True), sa.Column("amount", sa.Float(), nullable=True), sa.ForeignKeyConstraint( ["budget_id"], ["Budgets.id"], ), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) def downgrade(): """Downgrade the tables.""" op.drop_table("BudgetEntries") op.drop_table("Budgets") ================================================ FILE: alembic/versions/31b1e22b455e_added_exclude_and_check_constraints_to_.py ================================================ """Added exclude and check constraints to TimeLogs table. Revision ID: 31b1e22b455e Revises: c5607b4cfb0a Create Date: 2017-03-10 02:14:38.330000 """ from alembic import op from sqlalchemy import CheckConstraint from stalker import log # revision identifiers, used by Alembic. revision = "31b1e22b455e" down_revision = "c5607b4cfb0a" logger = log.get_logger(__name__) def upgrade(): """Add CheckConstraint and an ExcludeConstraint for the TimeLogs table.""" # First cleanup TimeLogs table logger.info("Removing duplicate TimeLog entries") op.execute( """-- first remove direct duplicates with cte as ( select row_number() over (partition by resource_id, start) as rn, id, start, "end", resource_id from "TimeLogs" where exists ( select 1 from "TimeLogs" as tlogs where tlogs.start <= "TimeLogs".start and "TimeLogs".start < tlogs.end and tlogs.id != "TimeLogs".id and tlogs.resource_id = "TimeLogs".resource_id ) order by start ) delete from "TimeLogs" where "TimeLogs".id in (select id from cte where rn > 1);""" ) logger.info( "Removing contained TimeLog entries (TimeLogs surrounded by other " "TimeLogs" ) op.execute( """-- remove any contained (TimeLogs surrounded by other TimeLogs) TimeLogs with cte as ( select "TimeLogs".id, "TimeLogs".start, "TimeLogs".end, "TimeLogs".resource_id from "TimeLogs" join "TimeLogs" as tlogs on "TimeLogs".start > tlogs.start and "TimeLogs".start < tlogs.end and "TimeLogs".end > tlogs.start and "TimeLogs".end < tlogs.end and "TimeLogs".resource_id = tlogs.resource_id ) delete from "TimeLogs" where "TimeLogs".id in (select id from cte);""" ) logger.info("Trimming residual overlapping TimeLog.end values") op.execute( """-- then trim the end dates of the TimeLogs that are still overlapping with others update "TimeLogs" set "end" = ( select tlogs.start from "TimeLogs" as tlogs where "TimeLogs".start < tlogs.start and "TimeLogs".end > tlogs.start and "TimeLogs".resource_id = tlogs.resource_id limit 1 ) where "TimeLogs".end - "TimeLogs".start > interval '10 min' and exists( select 1 from "TimeLogs" as tlogs where "TimeLogs".start < tlogs.start and "TimeLogs".end > tlogs.start and "TimeLogs".resource_id = tlogs.resource_id ); """ ) logger.info("Trimming residual overlapping TimeLog.start values") op.execute( """-- then trim the start dates of the TimeLogs that are still overlapping with -- others (there may be 10 min TimeLogs left in the previous query) update "TimeLogs" set start = ( select tlogs.end from "TimeLogs" as tlogs where "TimeLogs".start > tlogs.start and "TimeLogs".start < tlogs.end and "TimeLogs".resource_id = tlogs.resource_id limit 1 ) where "TimeLogs".end - "TimeLogs".start > interval '10 min' and exists( select 1 from "TimeLogs" as tlogs where "TimeLogs".start > tlogs.start and "TimeLogs".start < tlogs.end and "TimeLogs".resource_id = tlogs.resource_id limit 1 ); """ ) logger.info("Adding CheckConstraint(end > start) to TimeLogs table") with op.batch_alter_table( "TimeLogs", table_args=(CheckConstraint('"end" > start')) ): logger.info("Adding ExcludeConstraint to TimeLogs table") from stalker.models.task import TimeLog, add_exclude_constraint conn = op.get_bind() add_exclude_constraint(TimeLog.__table__, conn) def downgrade(): """Downgrade the tables.""" # Drop ExcludeConstraint and functions # Sadly we can not reintroduce the deleted data in the upgrade() function logger.info("Dropping CheckConstraint(end > start)") op.execute("""ALTER TABLE "TimeLogs" DROP CONSTRAINT IF EXISTS TimeLogs_check;""") logger.info('Dropping "TimeLogs".overlapping_time_logs function') op.execute( """ALTER TABLE "TimeLogs" DROP CONSTRAINT IF EXISTS overlapping_time_logs;""" ) logger.info("Dropping ts_to_box function") op.execute( "DROP FUNCTION IF EXISTS " "ts_to_box(timestamp with time zone, timestamp with time zone);" ) logger.info("Dropping btree_gist extension") op.execute("""DROP EXTENSION IF EXISTS btree_gist;""") ================================================ FILE: alembic/versions/39d3c16ff005_budget_entries_good_id.py ================================================ """Added BudgetEntries.good_id. Revision ID: 39d3c16ff005 Revises: eaed49db6d9 Create Date: 2015-02-15 02:29:26.301437 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "39d3c16ff005" down_revision = "eaed49db6d9" def upgrade(): """Upgrade the tables.""" with op.batch_alter_table("BudgetEntries", schema=None) as batch_op: batch_op.add_column(sa.Column("good_id", sa.Integer(), nullable=True)) def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("BudgetEntries", schema=None) as batch_op: batch_op.drop_column("good_id") ================================================ FILE: alembic/versions/3be540ad3a93_added_version_revision_number_attribute.py ================================================ """Added Version.revision_number attribute Revision ID: 3be540ad3a93 Revises: 1875136a2bfc Create Date: 2024-12-04 17:04:37.174269 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "3be540ad3a93" down_revision = "1875136a2bfc" def upgrade(): """Upgrade the tables.""" op.execute( """ALTER TABLE "Versions" ADD revision_number integer NOT NULL DEFAULT 1;""" ) def downgrade(): """Downgrade the tables.""" # because we are removing the revision_number column, # add the 1000 * (revision_number - 1) to all the version numbers # to preserve the version sequences, intact... op.execute( """UPDATE "Versions" SET version_number = (1000 * (revision_number - 1) + version_number);""" ) op.execute("""ALTER TABLE "Versions" DROP COLUMN revision_number;""") ================================================ FILE: alembic/versions/409d2d73ca30_user_rate.py ================================================ """Added "Users.rate". Revision ID: 409d2d73ca30 Revises: 5814290f49c7 Create Date: 2014-11-20 22:47:56.013644 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "409d2d73ca30" down_revision = "5814290f49c7" def upgrade(): """Upgrade the tables.""" op.add_column("Users", sa.Column("rate", sa.Float(), nullable=True)) def downgrade(): """Downgrade the tables.""" op.drop_column("Users", "rate") ================================================ FILE: alembic/versions/433d9caaafab_task_review_status_workflow.py ================================================ """Task review/status workflow. Revision ID: 433d9caaafab Revises: 46775e4a3d96 Create Date: 2014-01-31 01:51:08.457109 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql import stalker # revision identifiers, used by Alembic. revision = "433d9caaafab" down_revision = "46775e4a3d96" def upgrade(): """Upgrade the tables.""" # Enum Types time_unit_enum = postgresql.ENUM( "min", "h", "d", "w", "m", "y", name="TimeUnit", create_type=False ) review_schedule_model_enum = postgresql.ENUM( "effort", "length", "duration", name="ReviewScheduleModel", create_type=False ) task_dependency_target_enum = postgresql.ENUM( "onend", "onstart", name="TaskDependencyTarget", create_type=False ) task_dependency_gap_model = postgresql.ENUM( "length", "duration", name="TaskDependencyGapModel", create_type=False ) resource_allocation_strategy_enum = postgresql.ENUM( "minallocated", "maxloaded", "minloaded", "order", "random", name="ResourceAllocationStrategy", create_type=False, ) # Reviews op.create_table( "Reviews", sa.Column("id", sa.Integer(), nullable=False), sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("reviewer_id", sa.Integer(), nullable=False), sa.Column("review_number", sa.Integer(), nullable=True), sa.Column("schedule_timing", sa.Float(), nullable=True), sa.Column("schedule_unit", time_unit_enum, nullable=False), sa.Column("schedule_constraint", sa.Integer(), nullable=False), sa.Column("schedule_model", review_schedule_model_enum, nullable=False), sa.Column("status_id", sa.Integer(), nullable=False), sa.Column("status_list_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["SimpleEntities.id"], ), sa.ForeignKeyConstraint( ["reviewer_id"], ["Users.id"], ), sa.ForeignKeyConstraint( ["status_id"], ["Statuses.id"], ), sa.ForeignKeyConstraint( ["status_list_id"], ["StatusLists.id"], ), sa.ForeignKeyConstraint( ["task_id"], ["Tasks.id"], ), sa.PrimaryKeyConstraint("id"), ) # Task_Responsible op.create_table( "Task_Responsible", sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("responsible_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint(["responsible_id"], ["Users.id"]), sa.ForeignKeyConstraint(["task_id"], ["Tasks.id"]), sa.PrimaryKeyConstraint("task_id", "responsible_id"), ) # Task_Alternative_Resources op.create_table( "Task_Alternative_Resources", sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("resource_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["resource_id"], ["Users.id"], ), sa.ForeignKeyConstraint( ["task_id"], ["Tasks.id"], ), sa.PrimaryKeyConstraint("task_id", "resource_id"), ) # Task Computed Resources op.create_table( "Task_Computed_Resources", sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("resource_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["resource_id"], ["Users.id"], ), sa.ForeignKeyConstraint( ["task_id"], ["Tasks.id"], ), sa.PrimaryKeyConstraint("task_id", "resource_id"), ) # EntityTypes op.add_column("EntityTypes", sa.Column("dateable", sa.Boolean(), nullable=True)) # Projects op.drop_column("Projects", "timing_resolution") # Studios op.add_column("Studios", sa.Column("is_scheduling", sa.Boolean(), nullable=True)) op.add_column( "Studios", sa.Column("is_scheduling_by_id", sa.Integer(), nullable=True) ) op.add_column( "Studios", sa.Column("last_schedule_message", sa.PickleType(), nullable=True) ) op.add_column( "Studios", sa.Column("last_scheduled_at", sa.DateTime(), nullable=True) ) op.add_column( "Studios", sa.Column("last_scheduled_by_id", sa.Integer(), nullable=True) ) op.add_column( "Studios", sa.Column("scheduling_started_at", sa.DateTime(), nullable=True) ) op.drop_column("Studios", "daily_working_hours") # Task Dependencies # ************************************************************************* # dependency_target - onend by default op.add_column( "Task_Dependencies", sa.Column("dependency_target", task_dependency_target_enum, nullable=True), ) # fill data op.execute("""UPDATE "Task_Dependencies" SET dependency_target = 'onend'""") # alter column to be nullable false op.alter_column( "Task_Dependencies", "dependency_target", existing_nullable=True, nullable=False ) # ************************************************************************* op.alter_column( "Task_Dependencies", "depends_to_task_id", new_column_name="depends_to_id" ) # ************************************************************************* # gap_constraint column - 0 by default op.add_column( "Task_Dependencies", sa.Column("gap_constraint", sa.Integer(), nullable=True) ) # fill data op.execute("""UPDATE "Task_Dependencies" SET gap_constraint = 0 """) # alter column to be nullable false op.alter_column( "Task_Dependencies", "gap_constraint", existing_nullable=True, nullable=False ) # ************************************************************************* # ************************************************************************* # gap_model - length by default op.add_column( "Task_Dependencies", sa.Column("gap_model", task_dependency_gap_model, nullable=True), ) # fill data op.execute("""UPDATE "Task_Dependencies" SET gap_model = 'length'""") # alter column to be nullable false op.alter_column( "Task_Dependencies", "gap_model", existing_nullable=True, nullable=False ) # ************************************************************************* # ************************************************************************* # gap_timing - 0 by default op.add_column( "Task_Dependencies", sa.Column("gap_timing", sa.Float(), nullable=True) ) op.add_column( "Task_Dependencies", sa.Column("gap_unit", time_unit_enum, nullable=True) ) # fill data op.execute("""UPDATE "Task_Dependencies" SET gap_timing = 0""") # alter column to be nullable false op.alter_column( "Task_Dependencies", "gap_timing", existing_nullable=True, nullable=False ) # ************************************************************************* # Tasks op.add_column("Tasks", sa.Column("review_number", sa.Integer(), nullable=True)) # ************************************************************************* # allocation_strategy - minallocated by default op.add_column( "Tasks", sa.Column( "allocation_strategy", resource_allocation_strategy_enum, nullable=True ), ) # fill data op.execute("""UPDATE "Tasks" SET allocation_strategy = 'minallocated'""") # alter column to be nullable false op.alter_column( "Tasks", "allocation_strategy", existing_nullable=True, nullable=False ) # ************************************************************************* # ************************************************************************* # persistent_allocation - True by default op.add_column( "Tasks", sa.Column("persistent_allocation", sa.Boolean(), nullable=True) ) # fill data op.execute("""UPDATE "Tasks" SET persistent_allocation = TRUE""") # alter column to be nullable false op.alter_column( "Tasks", "persistent_allocation", existing_nullable=True, nullable=False ) # ************************************************************************* op.drop_column("Tasks", "timing_resolution") op.drop_column("TimeLogs", "timing_resolution") op.create_unique_constraint(None, "Users", ["login"]) op.drop_column("Vacations", "timing_resolution") # before dropping responsible_id column from the Tasks table # move the data to the Task_Responsible table op.execute( 'insert into "Task_Responsible" ' " select id, responsible_id " ' from "Tasks" where responsible_id is not NULL' ) # now drop the data op.drop_column("Tasks", "responsible_id") # create new Statuses # # 'Waiting For Dependency', 'WFD', # 'Dependency Has Revision','DREV', # 'On Hold', 'OH', # 'Stopped', 'STOP', def create_status(name, code): # Insert in to SimpleEntities op.execute( f"""INSERT INTO "SimpleEntities" (entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, type_id, thumbnail_id, html_style, html_class, stalker_version) VALUES ('Status', '{name}', '', NULL, NULL, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), NULL, NULL, '', '', '{stalker.__version__}')""" ) # insert in to Entities and Statuses op.execute( f"""INSERT INTO "Entities" (id) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = '{name}' )); INSERT INTO "Statuses" (id, code) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = '{name}'), '{code}');""" ) create_status("Waiting For Dependency", "WFD") create_status("Dependency Has Revision", "DREV") create_status("On Hold", "OH") create_status("Stopped", "STOP") # Review Statuses create_status("Requested Revision", "RREV") create_status("Approved", "APP") # Add new Task statuses to StatusList def update_status_lists(entity_type, status_code): op.execute( f""" CREATE OR REPLACE FUNCTION add_status_to_status_list(status_list_id INT, status_id INT) RETURNS VOID AS $$ BEGIN INSERT INTO "StatusList_Statuses" (status_list_id, status_id) VALUES (status_list_id, status_id); EXCEPTION WHEN OTHERS THEN -- do nothning END; $$ LANGUAGE 'plpgsql'; select NULL from add_status_to_status_list( (SELECT id FROM "StatusLists" WHERE target_entity_type = '{entity_type}'), (SELECT id FROM "Statuses" WHERE code = '{status_code}') );""" ) # Task for t in ["Task", "Asset", "Shot", "Sequence"]: for s in ["WFD", "RTS", "WIP", "OH", "STOP", "PREV", "HREV", "DREV", "CMPL"]: update_status_lists(t, s) # drop function op.execute("drop function add_status_to_status_list(integer, integer);") # Remove NEW from Task, Asset, Shot and Sequence StatusList op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id = (SELECT id FROM "StatusLists" WHERE target_entity_type = 'Task') AND status_id = (SELECT id FROM "Statuses" WHERE "Statuses".code = 'NEW') """ ) op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id = (SELECT id FROM "StatusLists" WHERE target_entity_type = 'Asset') AND status_id = (SELECT id FROM "Statuses" WHERE "Statuses".code = 'NEW') """ ) op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id = (SELECT id FROM "StatusLists" WHERE target_entity_type = 'Shot') AND status_id = (SELECT id FROM "Statuses" WHERE "Statuses".code = 'NEW') """ ) op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id = (SELECT id FROM "StatusLists" WHERE target_entity_type = 'Sequence') AND status_id = (SELECT id FROM "Statuses" WHERE "Statuses".code = 'NEW') """ ) # Create Review StatusList # Insert in to SimpleEntities op.execute( f"""INSERT INTO "SimpleEntities" (entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, type_id, thumbnail_id, html_style, html_class, stalker_version) VALUES ('StatusList', 'Review Status List', '', NULL, NULL, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), NULL, NULL, '', '', '{stalker.__version__}')""" ) # insert in to Entities and StatusLists op.execute( """INSERT INTO "Entities" (id) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Review Status List' )); INSERT INTO "StatusLists" (id, target_entity_type) VALUES (( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Review Status List'), 'Review');""" ) # Add Review Statues To StatusList_Statuses # Add new Task statuses to StatusList op.execute( """INSERT INTO "StatusList_Statuses" (status_list_id, status_id) VALUES ((SELECT id FROM "StatusLists" WHERE target_entity_type = 'Review'), (SELECT id FROM "Statuses" WHERE code = 'NEW')), ((SELECT id FROM "StatusLists" WHERE target_entity_type = 'Review'), (SELECT id FROM "Statuses" WHERE code = 'RREV')), ((SELECT id FROM "StatusLists" WHERE target_entity_type = 'Review'), (SELECT id FROM "Statuses" WHERE code = 'APP')) """ ) # Update all NEW Tasks to WFD op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='WFD') where status_id = (select id from "Statuses" where code='NEW')""" ) # Update all PREV Tasks to WIP op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='WIP') where status_id = (select id from "Statuses" where code='PREV')""" ) # delete any other status from Task, Asset, Shot and Sequence Status Lists map( lambda x: op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id=( SELECT id FROM "StatusLists" WHERE target_entity_type='{}') AND status_id in ( SELECT id FROM "Statuses" WHERE code NOT IN ('WFD', 'RTS', 'WIP', 'OH', 'STOP', 'PREV', 'HREV', 'DREV', 'CMPL') );""".format( x ) ), ["Task", "Asset", "Shot", "Sequence"], ) # Update Tasks.review_number to 0 for all tasks op.execute("""update "Tasks" set review_number = 0""") # Shots._cut_in -> Shots.cut_in op.alter_column("Shots", "_cut_in", new_column_name="cut_in") # Shots._cut_out -> Shots.cut_out op.alter_column("Shots", "_cut_out", new_column_name="cut_out") # Tasks._schedule_seconds -> Tasks.schedule_seconds op.alter_column("Tasks", "_schedule_seconds", new_column_name="schedule_seconds") # Tasks._total_logged_seconds -> Tasks.total_logged_seconds op.alter_column( "Tasks", "_total_logged_seconds", new_column_name="total_logged_seconds" ) def downgrade(): """Downgrade the tables.""" op.add_column( "Vacations", sa.Column("timing_resolution", postgresql.INTERVAL(), nullable=True), ) # op.drop_constraint(None, 'Users') op.add_column( "TimeLogs", sa.Column("timing_resolution", postgresql.INTERVAL(), nullable=True) ) op.add_column("Tasks", sa.Column("responsible_id", sa.INTEGER(), nullable=True)) # restore data op.execute( """ UPDATE "Tasks" SET responsible_id = t2.responsible_id FROM ( SELECT task_id, responsible_id FROM "Task_Responsible" ) as t2 WHERE "Tasks".id = t2.task_id """ ) op.add_column( "Tasks", sa.Column("timing_resolution", postgresql.INTERVAL(), nullable=True) ) op.drop_column("Tasks", "persistent_allocation") op.drop_column("Tasks", "allocation_strategy") op.drop_column("Tasks", "review_number") op.alter_column( "Task_Dependencies", "depends_to_id", new_column_name="depends_to_task_id" ) op.drop_column("Task_Dependencies", "gap_unit") op.drop_column("Task_Dependencies", "gap_timing") op.drop_column("Task_Dependencies", "gap_model") op.drop_column("Task_Dependencies", "gap_constraint") op.drop_column("Task_Dependencies", "dependency_target") op.add_column( "Studios", sa.Column("daily_working_hours", sa.INTEGER(), nullable=True) ) op.drop_column("Studios", "scheduling_started_at") op.drop_column("Studios", "last_scheduled_by_id") op.drop_column("Studios", "last_scheduled_at") op.drop_column("Studios", "last_schedule_message") op.drop_column("Studios", "is_scheduling_by_id") op.drop_column("Studios", "is_scheduling") op.add_column( "Projects", sa.Column("timing_resolution", postgresql.INTERVAL(), nullable=True) ) op.drop_column("EntityTypes", "dateable") op.drop_table("Task_Alternative_Resources") op.drop_table("Task_Computed_Resources") op.drop_table("Reviews") # will loose all the responsible data, change if you care! op.drop_table("Task_Responsible") # Update all WFD Tasks to NEW op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='NEW') where status_id = (select id from "Statuses" where code='WFD') """ ) # Update all OH Tasks to WIP op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='WIP') where status_id = (select id from "Statuses" where code='OH') """ ) # Update all STOP or DREV Tasks to CMPL op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='WIP') where status_id in (select id from "Statuses" where code in ('STOP', 'DREV')) """ ) op.execute( """update "Tasks" set status_id = (select id from "Statuses" where code='WIP') where status_id = (select id from "Statuses" where code='STOP') """ ) # Delete Statuses op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_id IN ( select id FROM "SimpleEntities" WHERE name IN ('Waiting For Dependency', 'Dependency Has Revision', 'On Hold', 'Stopped', 'Requested Revision', 'Approved')); DELETE FROM "Statuses" WHERE id IN (select id FROM "SimpleEntities" WHERE name IN ('Waiting For Dependency', 'Dependency Has Revision', 'On Hold', 'Stopped', 'Requested Revision', 'Approved')); DELETE FROM "Entities" WHERE id IN (select id FROM "SimpleEntities" WHERE name IN ('Waiting For Dependency', 'Dependency Has Revision', 'On Hold', 'Stopped', 'Requested Revision', 'Approved')); DELETE FROM "SimpleEntities" WHERE name IN ('Waiting For Dependency', 'Dependency Has Revision', 'On Hold', 'Stopped', 'Requested Revision', 'Approved'); """ ) # Delete Review Status List op.execute( """ DELETE FROM "StatusList_Statuses" WHERE status_list_id=( SELECT id FROM "SimpleEntities" WHERE name='Review Status List' ); DELETE FROM "StatusLists" WHERE id=(SELECT id FROM "SimpleEntities" WHERE name='Review Status List'); DELETE FROM "Entities" WHERE id=(SELECT id FROM "SimpleEntities" WHERE name='Review Status List'); DELETE FROM "SimpleEntities" WHERE name = 'Review Status List'; """ ) # column name changes # Shots._cut_in -> Shots.cut_in op.alter_column("Shots", "cut_in", new_column_name="_cut_in") # Shots._cut_out -> Shots.cut_out op.alter_column("Shots", "cut_out", new_column_name="_cut_out") # Tasks._schedule_seconds -> Tasks.schedule_seconds op.alter_column("Tasks", "schedule_seconds", new_column_name="_schedule_seconds") # Tasks._total_logged_seconds -> Tasks.total_logged_seconds op.alter_column( "Tasks", "total_logged_seconds", new_column_name="_total_logged_seconds" ) ================================================ FILE: alembic/versions/4400871fa852_scene_is_now_deriving_from_task.py ================================================ """Scene is now deriving from Task Revision ID: 4400871fa852 Revises: ec1eb2151bb9 Create Date: 2024-11-15 13:16:53.885627 """ from alembic import op import stalker import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "4400871fa852" down_revision = "ec1eb2151bb9" def upgrade(): """Upgrade the tables.""" # Update the Scenes.id to be a foreign key to Tasks.id op.drop_constraint("Scenes_id_fkey", "Scenes", type_="foreignkey") op.create_foreign_key("Scenes_id_fkey", "Scenes", "Tasks", ["id"], ["id"]) # Create a StatusList for Scenes # Create a SimpleEntity for the StatusList op.execute( """ INSERT INTO "SimpleEntities" ( entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, generic_text, html_style, html_class, stalker_version ) VALUES ( 'StatusList', 'Scene Statuses', '', 3, 3, (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), '', '', '', '{stalker_version}' )""".format( stalker_version=stalker.__version__ ) ) # Insert the same data to the Entities op.execute( """ INSERT INTO "Entities" (id) VALUES ( (SELECT "SimpleEntities".id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Scene Statuses') ) """ ) # Insert the same to the StatusLists op.execute( """INSERT INTO "StatusLists" (id, target_entity_type) VALUES ( (SELECT "SimpleEntities".id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Scene Statuses'), 'Scene' ) """ ) # Create the same StatusList -> Status relation of a Task op.execute( """INSERT INTO "StatusList_Statuses" (status_list_id, status_id) SELECT "SimpleEntities".id, "StatusList_Statuses".status_id FROM "SimpleEntities", "StatusList_Statuses" WHERE "SimpleEntities".name = 'Scene Statuses' AND "StatusList_Statuses".status_list_id = ( SELECT "SimpleEntities".id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Task Statuses' ) """ ) # Because Scene class is now deriving from Task # we need create a Task for each Scene in the database, # with the same id of the Scene # carry on the data: id, project_id op.execute( """ INSERT INTO "Tasks" ( id, project_id, allocation_strategy, persistent_allocation, status_id, status_list_id, schedule_model, schedule_constraint ) SELECT "Scenes".id, "Scenes".project_id, 'minallocated', TRUE, (SELECT "Statuses".id FROM "Statuses" WHERE "Statuses".code = 'CMPL'), (SELECT "SimpleEntities".id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Scene Statuses'), 'effort', 0 FROM "Scenes" """ ) # drop the project_id column in Scenes table with op.batch_alter_table("Scenes", schema=None) as batch_op: batch_op.drop_column("project_id") def downgrade(): """Downgrade the tables.""" # Add the project_id column back to the Scenes table op.add_column("Scenes", sa.Column("project_id", sa.Integer(), nullable=False)) # Add the project_id data back to the Scenes table op.execute( """UPDATE "Scenes" SET project_id = ( SELECT "Tasks".project_id FROM "Tasks" WHERE "Tasks".id = ( SELECT "Scenes".id FROM "Scenes" ) )""" ) # set the project_id column not nullable op.execute("""ALTER TABLE "Scenes" ALTER COLUMN project_id SET NOT NULL""") # Remove the scene entries from Tasks table op.execute("""DELETE FROM "Tasks" WHERE id IN (SELECT id FROM "Scenes")""") # Remove the StatusList entries from StatusList_Statuses op.execute( """DELETE FROM "StatusList_Statuses" WHERE status_list_id = ( SELECT id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Scene Statuses' ) """ ) # Remove the StatusList from StatusLists Table op.execute("""DELETE FROM "StatusLists" WHERE target_entity_type = 'Scene'""") # Remove the StatusList from Entities Table op.execute( """DELETE FROM "Entities" WHERE id IN ( SELECT "SimpleEntities".id FROM "SimpleEntities" WHERE "SimpleEntities".name = 'Scene Statuses' ) """ ) # Remove the StatusList from SimpleEntities Table op.execute("""DELETE FROM "SimpleEntities" WHERE name = 'Scene Statuses'""") # Update the Scenes.id to be a foreign key to Entities.id op.drop_constraint("Scenes_id_fkey", "Scenes", type_="foreignkey") op.create_foreign_key("Scenes_id_fkey", "Scenes", "Entities", ["id"], ["id"]) ================================================ FILE: alembic/versions/4664d72ce1e1_renamed_link_path_to_full_path.py ================================================ """Renamed "Link.path" to" Link.full_path". Revision ID: 4664d72ce1e1 Revises: 25b3eba6ffe7 Create Date: 2013-05-23 18:46:18.218662 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "4664d72ce1e1" down_revision = "25b3eba6ffe7" def upgrade(): """Create full_path column.""" try: op.alter_column("Links", "path", new_column_name="full_path") except sa.exc.OperationalError: # SQLite3 # create new table op.create_table( "Links_Temp", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("full_path", sa.String), ) sa.sql.table( "Links_Temp", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("full_path", sa.String), ) sa.sql.table( "Links", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("path", sa.String), ) # copy data from Links.path to Links_Temp.full_path op.execute( 'INSERT INTO "Links_Temp" ' 'SELECT "Links".id, "Links".original_filename, "Links".path ' 'FROM "Links"' ) # drop the Links table and rename Links_Temp to Links op.drop_table("Links") op.rename_table("Links_Temp", "Links") def downgrade(): """Downgrade the tables.""" try: op.alter_column("Links", "path", new_column_name="full_path") except sa.exc.OperationalError: # SQLite3 # create new table op.create_table( "Links_Temp", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("path", sa.String), ) sa.sql.table( "Links_Temp", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("path", sa.String), ) sa.sql.table( "Links", sa.Column("id", sa.Integer, sa.ForeignKey("Entities.id"), primary_key=True), sa.Column("original_filename", sa.String(256), nullable=True), sa.Column("full_path", sa.String), ) # copy data from Links.path to Links_Temp.full_path op.execute( 'INSERT INTO "Links_Temp" ' 'SELECT "Links".id, "Links".original_filename, "Links".full_path ' 'FROM "Links"' ) # drop the Links table and rename Links_Temp to Links op.drop_table("Links") op.rename_table("Links_Temp", "Links") ================================================ FILE: alembic/versions/46775e4a3d96_create_enum_types.py ================================================ """Create enum types. Revision ID: 46775e4a3d96 Revises: 2aeab8b376dc Create Date: 2014-01-31 03:08:36.445876 """ from alembic import op # revision identifiers, used by Alembic. revision = "46775e4a3d96" down_revision = "2aeab8b376dc" def upgrade(): """Upgrade the tables.""" # rename types op.execute('ALTER TYPE "TaskScheduleUnit" RENAME TO "TimeUnit";') # create new types op.execute( """CREATE TYPE "ResourceAllocationStrategy" AS ENUM ('minallocated', 'maxloaded', 'minloaded', 'order', 'random'); CREATE TYPE "TaskDependencyGapModel" AS ENUM ('length', 'duration'); CREATE TYPE "TaskDependencyTarget" AS ENUM ('onend', 'onstart'); CREATE TYPE "ReviewScheduleModel" AS ENUM ('effort', 'length', 'duration'); """ ) # update the Task column to use the TimeUnit type instead of TaskBidUnit op.execute( """ ALTER TABLE "Tasks" ALTER COLUMN bid_unit TYPE "TimeUnit" USING ((bid_unit::text)::"TimeUnit"); """ ) # remove unnecessary types op.execute('DROP TYPE IF EXISTS "TaskBidUnit" CASCADE;') def downgrade(): """Downgrade the tables.""" # add necessary types op.execute( """CREATE TYPE "TaskBidUnit" AS ENUM ('min', 'h', 'd', 'w', 'm', 'y'); """ ) # update the Task column to use the TimeUnit type instead of TaskBidUnit op.execute( """ ALTER TABLE "Tasks" ALTER COLUMN bid_unit TYPE "TaskBidUnit" USING ((bid_unit::text)::"TaskBidUnit"); """ ) # rename types op.execute('ALTER TYPE "TimeUnit" RENAME TO "TaskScheduleUnit";') # create new types op.execute( """ DROP TYPE IF EXISTS "ResourceAllocationStrategy" CASCADE; DROP TYPE IF EXISTS "TaskDependencyGapModel" CASCADE; DROP TYPE IF EXISTS "TaskDependencyTarget" CASCADE; DROP TYPE IF EXISTS "ReviewScheduleModel" CASCADE; """ ) ================================================ FILE: alembic/versions/4a836cf73bcf_create_entitytype_accepts_references.py ================================================ """Create EntityType.accepts_references. Revision ID: 4a836cf73bcf Revises: None Create Date: 2013-05-15 16:27:05.983849 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "4a836cf73bcf" down_revision = None def upgrade(): """Upgrade the tables.""" try: op.add_column("EntityTypes", sa.Column("accepts_references", sa.Boolean)) except (sa.exc.OperationalError, sa.exc.ProgrammingError): # the column already exists pass try: op.add_column("Links", sa.Column("original_filename", sa.String(256))) except (sa.exc.OperationalError, sa.exc.ProgrammingError, sa.exc.InternalError): # the column already exists pass def downgrade(): """Downgrade the tables.""" # no drop column in SQLite so this will not work for SQLite databases op.drop_column("EntityTypes", "accepts_references") op.drop_column("Links", "original_filename") ================================================ FILE: alembic/versions/5078390e5527_shot_scene_relation_is_now_many_to_one.py ================================================ """Shot Scene relation is now many-to-one Revision ID: 5078390e5527 Revises: e25ec9930632 Create Date: 2024-11-18 11:35:10.872216 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "5078390e5527" down_revision = "e25ec9930632" def upgrade(): """Upgrade the tables.""" # Add scene_id column op.add_column("Shots", sa.Column("scene_id", sa.Integer(), nullable=True)) # Create foreign key constraint op.create_foreign_key(None, "Shots", "Scenes", ["scene_id"], ["id"]) # Migrate the data op.execute( """UPDATE "Shots" SET scene_id = ( SELECT scene_id FROM "Shot_Scenes" WHERE "Shot_Scenes".shot_id = "Shots".id LIMIT 1 )""" ) # Drop Shot_Scenes Table op.execute("""DROP TABLE "Shot_Scenes" """) def downgrade(): """Downgrade the tables.""" # Add Shot_Scenes Table op.create_table( "Shot_Scenes", sa.Column("shot_id", sa.Integer(), nullable=False), sa.Column("scene_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["shot_id"], ["Shots.id"], ), sa.ForeignKeyConstraint( ["scene_id"], ["Scenes.id"], ), ) # Transfer Data op.execute( """ UPDATE "Shot_Scenes" SET shot_id, scene_id = ( SELECT id, scene_id FROM "Shots" WHERE "Shots".scene_id != NULL ) """ ) # Drop foreign key constraint op.drop_constraint("Shots_scene_id_fkey", "Shots", type_="foreignkey") # drop Shots.scene_id column op.drop_column("Shots", "scene_id") ================================================ FILE: alembic/versions/5168cc8552a3_html_style_html_class.py ================================================ """Added html_style and html_class columns to SimpleEntities. Revision ID: 5168cc8552a3 Revises: 174567b9c159 Create Date: 2013-11-14 23:03:55.413681 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "5168cc8552a3" down_revision = "174567b9c159" def upgrade(): """Upgrade the tables.""" op.add_column("SimpleEntities", sa.Column("html_class", sa.String(), nullable=True)) op.add_column("SimpleEntities", sa.Column("html_style", sa.String(), nullable=True)) def downgrade(): """Downgrade the tables.""" op.drop_column("SimpleEntities", "html_style") op.drop_column("SimpleEntities", "html_class") ================================================ FILE: alembic/versions/5355b569237b_version_version_of_r.py ================================================ """'Version.version_of' renamed to "Version.task". Revision ID: 5355b569237b Revises: 6297277da38 Create Date: 2013-06-10 11:47:28.984222 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "5355b569237b" down_revision = "6297277da38" def upgrade(): """Upgrade the tables.""" try: op.alter_column("Versions", "version_of_id", new_column_name="task_id") except sa.exc.OperationalError: # SQLite3 # just create the new column # and copy data op.add_column( "Versions", "task_id", sa.Column(sa.Integer, sa.ForeignKey("Tasks.id"), nullable=False), ) # copy data from Links.path to Links_Temp.full_path op.execute( """INSERT INTO "Versions".task_id SELECT "Versions".version_of_id FROM "Versions" """ ) def downgrade(): """Downgrade the tables.""" try: op.alter_column("Versions", "task_id", new_column_name="version_of_id") except sa.exc.OperationalError: # SQLite3 # just create the new column # and copy data op.add_column( "Versions", "version_of_id", sa.Column(sa.Integer, sa.ForeignKey("Tasks.id"), nullable=False), ) op.execute( """INSERT INTO "Versions".version_of_id SELECT "Versions".task_id FROM "Versions" """ ) ================================================ FILE: alembic/versions/53d8127d8560_parent_child_relatio.py ================================================ """parent child relation in Versions. Revision ID: 53d8127d8560 Revises: 4a836cf73bcf Create Date: 2013-05-22 12:44:05.626047 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "53d8127d8560" down_revision = "4a836cf73bcf" def upgrade(): """Upgrade the tables.""" try: op.add_column("Versions", sa.Column("parent_id", sa.Integer(), nullable=True)) except (sa.exc.OperationalError, sa.exc.InternalError): pass def downgrade(): """Downgrade the tables.""" op.drop_column("Versions", "parent_id") ================================================ FILE: alembic/versions/57a5949c7f29_cache_for_total_logged_seconds.py ================================================ """Created cache columns for total_logged_seconds and schedule_seconds attributes. Revision ID: 57a5949c7f29 Revises: 101a789e38ad Create Date: 2013-07-31 16:57:17.674995 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "57a5949c7f29" down_revision = "101a789e38ad" def upgrade(): """Upgrade the tables.""" op.add_column("Tasks", sa.Column("_schedule_seconds", sa.Integer(), nullable=True)) op.add_column( "Tasks", sa.Column("_total_logged_seconds", sa.Integer(), nullable=True) ) def downgrade(): """Downgrade the tables.""" op.drop_column("Tasks", "_total_logged_seconds") op.drop_column("Tasks", "_schedule_seconds") ================================================ FILE: alembic/versions/5814290f49c7_added_shot_source_in_shot_source_out_record_in.py ================================================ """Added Shot.source_in, Shot.source_out and Shot.record_in attributes. Revision ID: 5814290f49c7 Revises: 2e4a3813ae76 Create Date: 2014-09-22 15:25:29.618377 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "5814290f49c7" down_revision = "2e4a3813ae76" def upgrade(): """Upgrade the tables.""" op.add_column("Shots", sa.Column("record_in", sa.Integer(), nullable=True)) op.add_column("Shots", sa.Column("source_in", sa.Integer(), nullable=True)) op.add_column("Shots", sa.Column("source_out", sa.Integer(), nullable=True)) def downgrade(): """Downgrade the tables.""" op.drop_column("Shots", "source_out") op.drop_column("Shots", "source_in") op.drop_column("Shots", "record_in") ================================================ FILE: alembic/versions/583875229230_good_task_relation.py ================================================ """Added Tasks.good_id column. Revision ID: 583875229230 Revises: 2252e51506de Create Date: 2015-02-07 18:53:04.343928 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "583875229230" down_revision = "2252e51506de" def upgrade(): """Upgrade the tables.""" with op.batch_alter_table("Tasks", schema=None) as batch_op: batch_op.add_column(sa.Column("good_id", sa.Integer(), nullable=True)) def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("Tasks", schema=None) as batch_op: batch_op.drop_column("good_id") ================================================ FILE: alembic/versions/59092d41175c_added_version_created_with.py ================================================ """Added Version.created_with. Revision ID: 59092d41175c Revises: 5355b569237b Create Date: 2013-06-19 15:31:53.547392 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "59092d41175c" down_revision = "5355b569237b" def upgrade(): """Upgrade the tables.""" try: op.add_column( "Versions", sa.Column("created_with", sa.String(length=256), nullable=True) ) except sa.exc.OperationalError: pass def downgrade(): """Downgrade the tables.""" try: op.drop_column("Versions", "created_with") except sa.exc.OperationalError: pass ================================================ FILE: alembic/versions/5999269aad30_added_generic_text_attribute.py ================================================ """Added generic_text attribute on SimpleEntity. Revision ID: 5999269aad30 Revises: 182f44ce5f07 Create Date: 2014-06-02 15:17:27.961000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "5999269aad30" down_revision = "182f44ce5f07" def upgrade(): """Upgrade the tables.""" op.add_column("SimpleEntities", sa.Column("generic_text", sa.Text())) def downgrade(): """Downgrade the tables.""" op.drop_column("SimpleEntities", "generic_text") ================================================ FILE: alembic/versions/59bfe820c369_resource_efficiency.py ================================================ """Added "User.efficiency" column. Revision ID: 59bfe820c369 Revises: af869ddfdf9 Create Date: 2014-04-26 23:50:53.880274 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "59bfe820c369" down_revision = "af869ddfdf9" def upgrade(): """Upgrade the tables.""" op.add_column("Users", sa.Column("efficiency", sa.Float(), nullable=True)) # set default value op.execute('update "Users" set efficiency = 1.0') def downgrade(): """Downgrade the tables.""" op.drop_column("Users", "efficiency") ================================================ FILE: alembic/versions/6297277da38_added_vacation_class.py ================================================ """Added Vacation class. Revision ID: 6297277da38 Revises: 21b88ed3da95 Create Date: 2013-06-07 16:03:08.412610 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "6297277da38" down_revision = "21b88ed3da95" def upgrade(): """Upgrade the tables.""" try: op.drop_table("User_Vacations") except sa.exc.OperationalError: pass def downgrade(): """Downgrade the tables.""" op.create_table( "User_Vacations", sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.Column("vacation_id", sa.INTEGER(), autoincrement=False, nullable=False), sa.ForeignKeyConstraint( ["user_id"], ["Users.id"], name="User_Vacations_user_id_fkey" ), sa.ForeignKeyConstraint( ["vacation_id"], ["Vacations.id"], name="User_Vacations_vacation_id_fkey" ), sa.PrimaryKeyConstraint("user_id", "vacation_id", name="User_Vacations_pkey"), ) ================================================ FILE: alembic/versions/644f5251fc0d_remove_project_active_attribute.py ================================================ """Remove Project.active attribute Revision ID: 644f5251fc0d Revises: 5078390e5527 Create Date: 2024-11-18 12:47:09.673241 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "644f5251fc0d" down_revision = "5078390e5527" def upgrade(): """Upgrade the tables.""" # just remove the "active" column with op.batch_alter_table("Projects", schema=None) as batch_op: batch_op.drop_column("active") def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("Projects", schema=None) as batch_op: batch_op.add_column(sa.Column("active", sa.Boolean(), nullable=True)) # restore the value by checking the status op.execute( """UPDATE "Projects" SET active = ( SELECT ( CASE WHEN "Projects".status_id = ( SELECT "Statuses".id FROM "Statuses" WHERE "Statuses".code = 'WIP' ) THEN true ELSE false END ) as active FROM "Projects" ) """ ) ================================================ FILE: alembic/versions/745b210e6907_fix_non_existing_thumbnails.py ================================================ """Fix none-existing thumbnails. Revision ID: 745b210e6907 Revises: f2005d1fbadc Create Date: 2016-06-27 17:52:24.381000 """ from alembic import op # revision identifiers, used by Alembic. revision = "745b210e6907" down_revision = "258985128aff" def upgrade(): """Fix SimpleEntities with none-existing thumbnail_id's.""" op.execute( """ UPDATE "SimpleEntities" SET thumbnail_id = NULL WHERE "SimpleEntities".thumbnail_id is not NULL and not exists( select thum.id from "SimpleEntities" as thum where thum.id = "SimpleEntities".thumbnail_id ) """ ) def downgrade(): """Downgrade the tables.""" # do nothing pass ================================================ FILE: alembic/versions/856e70016b2_roles.py ================================================ """Added Roles. Revision ID: 856e70016b2 Revises: 30c576f3691 Create Date: 2014-11-26 00:25:29.543411 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "856e70016b2" down_revision = "30c576f3691" def upgrade(): """Upgrade the tables.""" op.create_table( "Roles", sa.Column("id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) op.create_table( "Client_Users", sa.Column("uid", sa.Integer(), nullable=False), sa.Column("cid", sa.Integer(), nullable=False), sa.Column("rid", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["cid"], ["Clients.id"], ), sa.ForeignKeyConstraint( ["rid"], ["Roles.id"], ), sa.ForeignKeyConstraint( ["uid"], ["Users.id"], ), sa.PrimaryKeyConstraint("uid", "cid"), ) # # read Users.client_id and create Client_Users entries accordingly # op.rename_table("User_Groups", "Group_Users") op.rename_table("User_Departments", "Department_Users") op.add_column("Department_Users", sa.Column("rid", sa.Integer(), nullable=True)) op.add_column("Project_Users", sa.Column("rid", sa.Integer(), nullable=True)) op.drop_column("Departments", "lead_id") op.drop_column("Projects", "lead_id") op.drop_column("Users", "company_id") def downgrade(): """Downgrade the tables.""" op.add_column("Projects", sa.Column("lead_id", sa.INTEGER(), nullable=True)) op.add_column("Departments", sa.Column("lead_id", sa.INTEGER(), nullable=True)) op.drop_column("Project_Users", "rid") op.drop_column("Department_Users", "rid") op.rename_table("Department_Users", "User_Departments") op.rename_table("Group_Users", "User_Groups") op.add_column("Users", sa.Column("company_id", sa.INTEGER(), nullable=True)) op.drop_table("Client_Users") op.drop_table("Roles") ================================================ FILE: alembic/versions/91ed52b72b82_created_variant_class.py ================================================ """Created Variant class. Revision ID: 91ed52b72b82 Revises: 644f5251fc0d Create Date: 2024-11-22 07:57:46.848687 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "91ed52b72b82" down_revision = "644f5251fc0d" def upgrade(): """Upgrade the tables.""" op.create_table( "Variants", sa.Column("id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["Tasks.id"], ), sa.PrimaryKeyConstraint("id"), ) op.alter_column( "Projects", "fps", existing_type=sa.REAL(), type_=sa.Float(precision=3), existing_nullable=True, ) op.alter_column( "Shots", "fps", existing_type=sa.REAL(), type_=sa.Float(precision=3), existing_nullable=True, ) # create Variant Status Lists op.execute( """ WITH ins1 AS ( INSERT INTO "SimpleEntities" ( entity_type, name, description, date_created, date_updated, html_style, html_class, stalker_version ) VALUES ( 'StatusList', 'Variant Statuses', 'Created by alembic revision: 91ed52b72b82', (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), (SELECT CAST(NOW() at time zone 'utc' AS timestamp)), '', '', '1.0.0.dev1' ) RETURNING id as variant_status_list_id ), ins2 AS ( INSERT INTO "Entities" (id) (SELECT ins1.variant_status_list_id FROM ins1) ) INSERT INTO "StatusLists" (id, target_entity_type) (SELECT ins1.variant_status_list_id, 'Variant' FROM ins1); """ ) # Add the same statuses of Task StatusList to Variant StatusList op.execute( """ INSERT INTO "StatusList_Statuses" (status_list_id, status_id) ( SELECT (SELECT id FROM "StatusLists" WHERE target_entity_type = 'Variant') as status_list_id, "StatusList_Statuses".status_id FROM "StatusList_Statuses" WHERE "StatusList_Statuses".status_list_id = ( SELECT id FROM "StatusLists" WHERE target_entity_type = 'Task' ) ) """ ) def downgrade(): """Downgrade the tables.""" # ### commands auto generated by Alembic - please adjust! ### op.alter_column( "Shots", "fps", existing_type=sa.Float(precision=3), type_=sa.REAL(), existing_nullable=True, ) op.alter_column( "Projects", "fps", existing_type=sa.Float(precision=3), type_=sa.REAL(), existing_nullable=True, ) # remove Variant Status List Statuses op.execute( """ DELETE FROM "StatusList_Statuses" WHERE "StatusList_Statuses".status_list_id = ( SELECT id FROM "StatusLists" WHERE "StatusLists".target_entity_type = 'Variant' ) """ ) # remove Variant Status Lists op.execute( """ WITH del1 AS ( DELETE FROM "StatusLists" WHERE "StatusLists".target_entity_type = 'Variant' RETURNING "StatusLists".id as deleted_status_list_id ), del2 AS ( DELETE FROM "Entities" WHERE "Entities".id = (SELECT del1.deleted_status_list_id FROM del1) ) DELETE FROM "SimpleEntities" WHERE "SimpleEntities".id = (SELECT del1.deleted_status_list_id FROM del1) """ ) op.drop_table("Variants") ================================================ FILE: alembic/versions/92257ba439e1_budget_is_now_statusable.py ================================================ """Budget is now statusable. Revision ID: 92257ba439e1 Revises: f2005d1fbadc Create Date: 2016-07-28 13:20:27.397000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "92257ba439e1" down_revision = "f2005d1fbadc" def upgrade(): """Upgrade the tables.""" op.add_column("Budgets", sa.Column("status_id", sa.Integer(), nullable=True)) op.add_column("Budgets", sa.Column("status_list_id", sa.Integer(), nullable=True)) op.create_foreign_key(None, "Budgets", "Statuses", ["status_id"], ["id"]) op.create_foreign_key(None, "Budgets", "StatusLists", ["status_list_id"], ["id"]) # create a dummy status list for budgets op.execute( """insert into "SimpleEntities" (name, entity_type) values ('Dummy Budget StatusList', 'StatusList'); insert into "Entities" (id) select "SimpleEntities".id from "SimpleEntities" where "SimpleEntities".entity_type = 'StatusList' and "SimpleEntities".name = 'Dummy Budget StatusList' ; insert into "StatusLists" (id, target_entity_type) select "SimpleEntities".id, 'Budget' from "SimpleEntities" where "SimpleEntities".entity_type = 'StatusList' and "SimpleEntities".name = 'Dummy Budget StatusList' ; insert into "StatusList_Statuses" select "SimpleEntities".id, "Statuses".id from "SimpleEntities", "Statuses" where "SimpleEntities".name = 'Dummy Budget StatusList' order by "Statuses".id limit 1 ; update "Budgets" set status_id = ( select "Statuses".id from "Statuses" order by "Statuses".id limit 1 ) ; update "Budgets" set status_list_id = ( select "SimpleEntities".id from "SimpleEntities" where "SimpleEntities".name = 'Dummy Budget StatusList' ) ; """ ) # now alter column to be non nullable op.alter_column("Budgets", "status_id", nullable=False) op.alter_column("Budgets", "status_list_id", nullable=False) def downgrade(): """Downgrade the tables.""" op.execute( """ ALTER TABLE public."Budgets" DROP CONSTRAINT "Budgets_status_id_fkey"; ALTER TABLE public."Budgets" DROP CONSTRAINT "Budgets_status_list_id_fkey"; ALTER TABLE public."Budgets" DROP COLUMN status_id; ALTER TABLE public."Budgets" DROP COLUMN status_list_id; """ ) # remove 'Dummy Budget StatusList' if it exists op.execute( """ delete from "StatusList_Statuses" where "StatusList_Statuses".status_list_id = ( select id from "SimpleEntities" where "SimpleEntities".name = 'Dummy Budget StatusList' ) ; delete from "StatusLists" where "StatusLists".id = ( select id from "SimpleEntities" where "SimpleEntities".name = 'Dummy Budget StatusList' ) ; delete from "Entities" where "Entities".id = ( select id from "SimpleEntities" where "SimpleEntities".name = 'Dummy Budget StatusList' ) ; delete from "SimpleEntities" where "SimpleEntities".name = 'Dummy Budget StatusList' ; """ ) ================================================ FILE: alembic/versions/9f9b88fef376_link_renamed_to_file.py ================================================ """Link renamed to File Revision ID: 9f9b88fef376 Revises: 3be540ad3a93 Create Date: 2025-01-14 15:37:15.746961 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "9f9b88fef376" down_revision = "3be540ad3a93" def upgrade(): """Upgrade the tables.""" # ------------------------------------------------------------------------- # drop constraints first op.drop_constraint("Links_id_fkey", table_name="Links", type_="foreignkey") op.drop_constraint("Daily_Links_link_id_fkey", "Daily_Links", type_="foreignkey") op.drop_constraint("Project_References_link_id_fkey", "Project_References", type_="foreignkey") op.drop_constraint("Task_References_link_id_fkey", "Task_References", type_="foreignkey") op.drop_constraint("Version_Inputs_link_id_fkey", "Version_Inputs", type_="foreignkey") op.drop_constraint("Version_Outputs_link_id_fkey", "Version_Outputs", type_="foreignkey") op.drop_constraint("Versions_id_fkey", "Versions", type_="foreignkey") op.drop_constraint("Daily_Links_daily_id_fkey", table_name="Daily_Links", type_="foreignkey") op.drop_constraint("Daily_Links_pkey", table_name="Daily_Links") op.drop_constraint("Version_Outputs_version_id_fkey", "Version_Outputs", type_="foreignkey") op.drop_constraint("Version_Outputs_pkey", table_name="Version_Outputs", type_="primary") op.drop_constraint("Version_Inputs_version_id_fkey", table_name="Version_Inputs", type_="foreignkey") op.drop_constraint("Version_Inputs_pkey", table_name="Version_Inputs", type_="primary") op.drop_constraint("xc", "SimpleEntities", type_="foreignkey") op.drop_constraint("xu", "SimpleEntities", type_="foreignkey") op.drop_constraint("y", "SimpleEntities", type_="foreignkey") op.drop_constraint("z", "SimpleEntities", type_="foreignkey") # link_id_fkey # Links_pkey -> Files_pkey # This requires a lot of other constraints to be dropped first!!! op.drop_constraint("Links_pkey", "Links", type_="primary") # ------------------------------------------------------------------------- # rename tables op.rename_table("Links", "Files") op.rename_table("Daily_Links", "Daily_Files") op.rename_table("Version_Outputs", "Version_Files") # ------------------------------------------------------------------------- # Rename Version_Inputs to File_References: # # This table is storing which Version was referencing which other # version. # # Data needs to be migrated in tandem with the data moved to the # Version_Files table. op.rename_table("Version_Inputs", "File_References") # ------------------------------------------------------------------------- # create columns # create "Files".created_with op.add_column( "Files", sa.Column("created_with", sa.String(length=256), nullable=True), ) # ------------------------------------------------------------------------- # rename columns op.alter_column("Daily_Files", "link_id", new_column_name="file_id") op.alter_column("File_References", "link_id", new_column_name="reference_id") op.alter_column("File_References", "version_id", new_column_name="file_id") op.alter_column("Project_References", "link_id", new_column_name="reference_id") op.alter_column("Task_References", "link_id", new_column_name="reference_id") op.alter_column("Version_Files", "link_id", new_column_name="file_id") # migrate data # Update "SimpleEntities".entity_type to 'File' # and replace the 'Link_%' in the name with 'File_%' op.execute( """UPDATE "SimpleEntities" SET (entity_type, name) = ('File', REPLACE("SimpleEntities".name, 'Link_', 'File_')) WHERE "SimpleEntities".entity_type = 'Link' """ ) # Update "EntityTypes".name for 'Link' to 'File' op.execute( """UPDATE "EntityTypes" SET name = 'File' WHERE "EntityTypes".name = 'Link' """ ) # Update any Types where target_entity_type == 'Link' to 'File' op.execute( """UPDATE "Types" SET target_entity_type = 'File' WHERE target_entity_type = 'Link' """ ) # migrate the created_with data from Versions to the Files table op.execute( """UPDATE "Files" SET created_with = "Versions".created_with FROM "Versions" WHERE "Versions".id = "Files".id """ ) # ------------------------------------------------------------------------- # Migrate Files that were Versions before, to new entries... # - Go back to Files tables # - Search for Files that have the same ids with Versions # - Create a new entry with the same data to Files, Entities and SimpleEntities # tables. # - Add them to the Version_Files tables, as they were previous versions # - Delete the old files op.execute( f""" -- reshuffle names for entities that have autoname and that are clashing UPDATE "SimpleEntities" SET name = ("SimpleEntities".entity_type || '_' || gen_random_uuid()) WHERE name in ( SELECT name FROM "SimpleEntities" WHERE length(name) > 37 -- entity_type_uuid4 GROUP BY name HAVING COUNT(*) > 1 ORDER BY name ); -- create temp storage for data coming from "Files" table ALTER TABLE "SimpleEntities" ADD original_filename character varying(256) COLLATE pg_catalog."default", ADD full_path text COLLATE pg_catalog."default", ADD created_from_version_id integer, ADD created_with character varying(256); -- create new entry for all Files that were originally Versions WITH sel1 as ( SELECT "File_SimpleEntities".id, 'File' as entity_type, REPLACE("File_SimpleEntities".name, 'Version_', 'File_') as entity_name, "File_SimpleEntities".description, "File_SimpleEntities".created_by_id, "File_SimpleEntities".updated_by_id, "File_SimpleEntities".date_created, "File_SimpleEntities".date_updated, "File_SimpleEntities".type_id, "File_SimpleEntities".generic_text, "File_SimpleEntities".thumbnail_id, "File_SimpleEntities".html_style, "File_SimpleEntities".html_class, "File_SimpleEntities".stalker_version, "Files".original_filename, "Files".full_path, "Files".created_with FROM "Files" JOIN "SimpleEntities" AS "File_SimpleEntities" ON "Files".id = "File_SimpleEntities".id WHERE "File_SimpleEntities".entity_type = 'Version' ORDER BY "File_SimpleEntities".id ), ins1 as ( INSERT INTO "SimpleEntities" ( entity_type, name, description, created_by_id, updated_by_id, date_created, date_updated, type_id, html_style, html_class, stalker_version, original_filename, full_path, created_with, created_from_version_id ) ( SELECT sel1.entity_type, sel1.entity_name, sel1.description, sel1.created_by_id, sel1.updated_by_id, sel1.date_created, sel1.date_updated, sel1.type_id, sel1.html_style, sel1.html_class, sel1.stalker_version, sel1.original_filename, sel1.full_path, sel1.created_with, sel1.id as created_from_version_id FROM sel1 ) RETURNING id as file_id, name as entity_name ) INSERT INTO "Entities" (id) (SELECT ins1.file_id FROM ins1); -- Insert into Files INSERT INTO "Files" (id, original_filename, full_path, created_with) ( SELECT "SimpleEntities".id, "SimpleEntities".original_filename, "SimpleEntities".full_path, "SimpleEntities".created_with FROM "SimpleEntities" WHERE "SimpleEntities".created_from_version_id IS NOT NULL ); -- Insert into Version_Files INSERT INTO "Version_Files" (version_id, file_id) ( SELECT "SimpleEntities".created_from_version_id as version_id, "SimpleEntities".id as file_id FROM "SimpleEntities" WHERE "SimpleEntities".created_from_version_id IS NOT NULL ); -- Update File_References -- so that the newly created Files -- are referencing the newly create other Files and not the old versions -- Update the file_id column first UPDATE "File_References" SET file_id = sel1.file_id FROM ( SELECT id as file_id, created_from_version_id FROM "SimpleEntities" WHERE created_from_version_id IS NOT NULL ) as sel1 WHERE "File_References".file_id = sel1.created_from_version_id; -- then the reference_id column UPDATE "File_References" SET reference_id = sel1.file_id FROM ( SELECT id as file_id, created_from_version_id FROM "SimpleEntities" WHERE created_from_version_id IS NOT NULL ) as sel1 WHERE "File_References".reference_id = sel1.created_from_version_id; -- Drop all the Files that previously was a Version -- Remove constraints first (Otherwise it will be incredibly slow!) -- ALTER TABLE "SimpleEntities" DROP CONSTRAINT "SimpleEntities_thumbnail_id_fkey"; -- ALTER TABLE "Daily_Files" DROP CONSTRAINT "Daily_Files_file_id_fkey"; -- ALTER TABLE "Project_References" DROP CONSTRAINT "Project_References_reference_id_fkey"; -- ALTER TABLE "Task_References" DROP CONSTRAINT "Task_References_reference_id_fkey"; -- ALTER TABLE "File_References" DROP CONSTRAINT "File_References_file_id_fkey"; -- ALTER TABLE "File_References" DROP CONSTRAINT "File_References_reference_id_fkey"; -- ALTER TABLE "Version_Files" DROP CONSTRAINT "Version_Files_file_id_fkey"; -- ALTER TABLE "Files" DROP CONSTRAINT "Files_id_fkey"; -- Really delete data now DELETE FROM "Files" WHERE id in ( SELECT id FROM "SimpleEntities" WHERE entity_type = 'Version' ); -- Recreate constraints -- ALTER TABLE "SimpleEntities" -- ADD CONSTRAINT "SimpleEntities_thumbnail_id_fkey" FOREIGN KEY (thumbnail_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- ALTER TABLE "Daily_Files" -- ADD CONSTRAINT "Daily_Files_file_id_fkey" FOREIGN KEY (file_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- ALTER TABLE "Project_References" -- ADD CONSTRAINT "Project_References_reference_id_fkey" FOREIGN KEY (reference_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- ALTER TABLE "Task_References" -- ADD CONSTRAINT "Task_References_reference_id_fkey" FOREIGN KEY (reference_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- ALTER TABLE "File_References" -- ADD CONSTRAINT "File_References_file_id_fkey" FOREIGN KEY (file_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION, -- ADD CONSTRAINT "File_References_reference_id_fkey" FOREIGN KEY (reference_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- ALTER TABLE "Version_Files" -- ADD CONSTRAINT "Version_Files_file_id_fkey" FOREIGN KEY (file_id) -- REFERENCES public."Files" (id) MATCH SIMPLE -- ON UPDATE CASCADE -- ON DELETE CASCADE; -- ALTER TABLE "Files" -- ADD CONSTRAINT "Files_id_fkey" FOREIGN KEY (id) -- REFERENCES public."Entities" (id) MATCH SIMPLE -- ON UPDATE NO ACTION -- ON DELETE NO ACTION; -- delete temp data in "SimpleEntities" ALTER TABLE "SimpleEntities" DROP COLUMN original_filename, DROP COLUMN full_path, DROP COLUMN created_from_version_id, DROP COLUMN created_with; """ ) # recreate constraints op.create_foreign_key("Files_id_fkey", "Files", "Entities", ["id"], ["id"]) op.create_primary_key("Files_pkey", "Files", ["id"]) op.create_foreign_key( "Daily_Files_daily_id_fkey", "Daily_Files", "Dailies", ["daily_id"], ["id"], ) op.create_foreign_key( "Daily_Files_file_id_fkey", "Daily_Files", "Files", ["file_id"], ["id"], ) op.create_primary_key("Daily_Files_pkey", "Daily_Files", ["daily_id", "file_id"]) op.create_foreign_key( "Version_Files_version_id_fkey", "Version_Files", "Versions", ["version_id"], ["id"], ) op.create_foreign_key( "Version_Files_file_id_fkey", "Version_Files", "Files", ["file_id"], ["id"], onupdate="CASCADE", ondelete="CASCADE", ) op.create_primary_key( "Version_Files_pkey", "Version_Files", ["version_id", "file_id"] ) op.create_foreign_key( "File_References_file_id_fkey", "File_References", "Files", ["file_id"], ["id"], ) op.create_foreign_key( "File_References_reference_id_fkey", "File_References", "Files", ["reference_id"], ["id"], ) op.create_primary_key( "File_References_pkey", "File_References", ["file_id", "reference_id"], ) op.create_foreign_key( "Project_References_reference_id_fkey", "Project_References", "Files", ["reference_id"], ["id"], ) op.create_foreign_key( "SimpleEntities_thumbnail_id_fkey", "SimpleEntities", "Files", ["thumbnail_id"], ["id"], use_alter=True, ) op.create_foreign_key( "SimpleEntities_created_by_id_fkey", "SimpleEntities", "Users", ["created_by_id"], ["id"], use_alter=True, ) op.create_foreign_key( "SimpleEntities_updated_by_id_fkey", "SimpleEntities", "Users", ["updated_by_id"], ["id"], use_alter=True, ) op.create_foreign_key( "SimpleEntities_type_id_fkey", "SimpleEntities", "Types", ["type_id"], ["id"], use_alter=True, ) op.create_foreign_key( "Task_References_reference_id_fkey", "Task_References", "Files", ["reference_id"], ["id"], ) # Versions is now deriving from Entities op.create_foreign_key("Versions_id_fkey", "Versions", "Entities", ["id"], ["id"]) def downgrade(): """Downgrade the tables.""" # drop constraints first op.drop_constraint("Daily_Files_pkey", "Daily_Files", type_="primary") op.drop_constraint("Daily_Files_file_id_fkey", "Daily_Files", type_="foreignkey") op.drop_constraint("Daily_Files_daily_id_fkey", "Daily_Files", type_="foreignkey") op.drop_constraint("File_References_pkey", "File_References", type_="primary") op.drop_constraint("File_References_file_id_fkey", "File_References", type_="foreignkey") op.drop_constraint("File_References_reference_id_fkey", "File_References", type_="foreignkey") op.drop_constraint("Files_pkey", "Files", type_="primary") op.drop_constraint("Files_id_fkey", "Files", type_="foreignkey") op.drop_constraint("Project_References_reference_id_fkey", "Project_References", type_="foreignkey") op.drop_constraint("SimpleEntities_created_by_id_fkey", "SimpleEntities", type_="foreignkey") op.drop_constraint("SimpleEntities_thumbnail_id_fkey", "SimpleEntities", type_="foreignkey") op.drop_constraint("SimpleEntities_updated_by_id_fkey", "SimpleEntities", type_="foreignkey") op.drop_constraint("SimpleEntities_type_id_fkey", "SimpleEntities", type_="foreignkey") op.drop_constraint("Task_References_reference_id_fkey", "Task_References", type_="foreignkey") op.drop_constraint("Version_Files_pkey", "Version_Files", type_="primary") op.drop_constraint("Version_Files_file_id_fkey", "Version_Files", type_="foreignkey") op.drop_constraint("Versions_id_fkey", "Versions", type_="foreignkey") # rename tables op.rename_table("Daily_Files", "Daily_Links") op.rename_table("Files", "Links") op.rename_table("File_References", "Version_Inputs") op.rename_table("Version_Files", "Version_Outputs") # rename columns op.alter_column("Daily_Links", "file_id", new_column_name="link_id") op.alter_column("Version_Outputs", "file_id", new_column_name="link_id") op.alter_column("Version_Inputs", "file_id", new_column_name="version_id") op.alter_column("Version_Inputs", "reference_id", new_column_name="link_id") op.alter_column("Project_References", "reference_id", new_column_name="link_id") op.alter_column("Task_References", "reference_id", new_column_name="link_id") # migrate the data as much as you can # op.execute( # """ # -- There are Versions where there are no corresponding input in the # -- Links table anymore # -- Update all the ids of the Links that are in the Version_Inputs with the # -- id of the version, so that we have a corresponding links for all versions # -- and then delete all the entries from the Entities and SimpleEntities tables # -- for those links. # # UPDATE "Links" SET id = sel1.id FROM ( # SELECT # link_id # FROM "Version_Links" # ) # # """ # ) # ------------------------------------------------------------------------- # drop columns # remove created_with from Versions table op.drop_column("Links", "created_with") # recreate constraints op.create_foreign_key("Daily_Links_daily_id_fkey", "Daily_Links", "Dailies", ["daily_id"], ["id"]) op.create_foreign_key("Daily_Links_link_id_fkey", "Daily_Links", "Links", ["link_id"], ["id"]) op.create_primary_key("Daily_Links_pkey", "Daily_Links", ["daily_id", "link_id"]) op.create_primary_key("Links_pkey", "Links", ["id"]) op.create_foreign_key("Link_id_fkey", "Links", "Entities", ["id"], ["id"]) op.create_foreign_key("Project_References_link_id_fkey", "Project_References", ["link_id"], ["id"]) op.create_foreign_key("Task_References_link_id_fkey", "Task_References", "Links", ["link_id"], ["id"]) op.create_foreign_key("Version_Inputs_version_id_fkey", "Version_Inputs", "Versions", ["version_id"], ["id"]) op.create_foreign_key("Version_Inputs_link_id_fkey", "Version_Inputs", "Links", ["link_id"], ["id"]) op.create_primary_key("Version_Inputs_pkey", "Version_Inputs", ["version_id", "link_id"]) op.create_primary_key("Version_Outputs_pkey", "Version_Outputs", ["version_id", "link_id"]) op.create_foreign_key("Version_Outputs_link_id_fkey", "Versions_Outputs", "Links", ["link_id"], ["id"]) op.create_foreign_key("Version_Outputs_version_id_fkey", "Version_Outputs", "Links", ["version_id"], ["id"]) op.create_foreign_key("Versions_id_fkey", "Versions", "Links", ["id"], ["id"]) op.create_foreign_key("xc", "SimpleEntities", "Users", ["created_by_id"], ["id"], use_alter=True) op.create_foreign_key("xu", "SimpleEntities", "Users", ["updated_by_id"], ["id"], use_alter=True) op.create_foreign_key("y", "SimpleEntities", "Types", ["type_id"], ["id"], use_alter=True) op.create_foreign_key("z", "SimpleEntities", "Links", ["thumbnail_id"], ["id"], use_alter=True) ================================================ FILE: alembic/versions/a2007ad7f535_added_review_version_id_column.py ================================================ """Added Review.version_id column Revision ID: a2007ad7f535 Revises: 91ed52b72b82 Create Date: 2024-11-26 11:36:07.776169 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "a2007ad7f535" down_revision = "91ed52b72b82" def upgrade(): """Upgrade the tables.""" op.add_column("Reviews", sa.Column("version_id", sa.Integer(), nullable=True)) op.create_foreign_key( "Reviews_version_id_fkey", "Reviews", "Versions", ["version_id"], ["id"], ) def downgrade(): """Downgrade the tables.""" op.drop_constraint("Reviews_version_id_fkey", "Reviews", type_="foreignkey") op.drop_column("Reviews", "version_id") ================================================ FILE: alembic/versions/a6598cde6b_versions_are_not_mix.py ================================================ """Versions are not mixed with StatusMixin anymore. Revision ID: a6598cde6b Revises: 275bdc106fd5 Create Date: 2013-10-25 17:35:42.953516 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "a6598cde6b" down_revision = "275bdc106fd5" def upgrade(): """Upgrade the tables.""" op.drop_column("Versions", "status_list_id") op.drop_column("Versions", "status_id") def downgrade(): """Downgrade the tables.""" op.add_column("Versions", sa.Column("status_id", sa.INTEGER(), nullable=False)) op.add_column("Versions", sa.Column("status_list_id", sa.INTEGER(), nullable=False)) ================================================ FILE: alembic/versions/a9319b19f7be_added_shot_fps.py ================================================ """Added "shot.fps". Revision ID: a9319b19f7be Revises: f16651477e64 Create Date: 2016-11-29 13:38:22.380000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "a9319b19f7be" down_revision = "f16651477e64" def upgrade(): """Upgrade the tables.""" op.add_column("Shots", sa.Column("fps", sa.Float(precision=3), nullable=True)) def downgrade(): """Downgrade the tables.""" op.drop_column("Shots", "fps") ================================================ FILE: alembic/versions/af869ddfdf9_entity_to_note_relation_is_now_many_to_many.py ================================================ """Entity to note relation is now many-to-many. Revision ID: af869ddfdf9 Revises: 2f55dc4f199f Create Date: 2014-04-06 09:20:44.509357 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "af869ddfdf9" down_revision = "2f55dc4f199f" def upgrade(): """Upgrade the tables.""" op.create_table( "Entity_Notes", sa.Column("entity_id", sa.Integer(), nullable=False), sa.Column("note_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["entity_id"], ["Entities.id"], ), sa.ForeignKeyConstraint( ["note_id"], ["Notes.id"], ), sa.PrimaryKeyConstraint("entity_id", "note_id"), ) # before dropping notes entity_id column # store all the entity_id values in the secondary table op.execute( """ insert into "Entity_Notes" select "Notes".entity_id, "Notes".id from "Notes" where "Notes".entity_id is not NULL and exists( select "Entities".id from "Entities" where "Entities".id = "Notes".entity_id )""" ) # now drop the entity_id column op.drop_column("Notes", "entity_id") def downgrade(): """Downgrade the tables.""" op.add_column("Notes", sa.Column("entity_id", sa.INTEGER(), nullable=True)) # restore data op.execute( """ UPDATE "Notes" SET entity_id = "Entity_Notes".entity_id FROM "Entity_Notes" WHERE "Notes".id = "Entity_Notes".note_id """ ) op.drop_table("Entity_Notes") ================================================ FILE: alembic/versions/bf67e6a234b4_added_revision_code_attribute.py ================================================ """Added "Repository.code" attribute. Revision ID: bf67e6a234b4 Revises: ed0167fff399 Create Date: 2020-01-01 09:50:19.086342 """ import logging from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "bf67e6a234b4" down_revision = "ed0167fff399" logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) def upgrade(): """Upgrade the tables.""" # add the column logger.info("creating code column in Repositories table") op.add_column( "Repositories", sa.Column("code", sa.String(length=256), nullable=True) ) # copy the name as code logger.info( "filling data to the code column in Repositories table from " "Repositories.name column" ) op.execute( r"""UPDATE "Repositories" SET code = ( SELECT REGEXP_REPLACE(name, '\s+', '') FROM "SimpleEntities" WHERE id="Repositories".id )""" ) logger.info("set code column to not nullable") op.alter_column("Repositories", "code", nullable=False) def downgrade(): """Downgrade the tables.""" logger.info("removing code column from Repositories table") op.drop_column("Repositories", "code") ================================================ FILE: alembic/versions/c5607b4cfb0a_added_support_for_time_zones.py ================================================ """Added support for time zones. Revision ID: c5607b4cfb0a Revises: 0063f547dc2e Create Date: 2017-03-09 02:17:08.209000 """ import logging from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "c5607b4cfb0a" down_revision = "0063f547dc2e" logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) tables_to_update = { "AuthenticationLogs": ["date"], "Tasks": ["computed_start", "computed_end", "start", "end"], "Studios": [ "computed_start", "computed_end", "start", "end", "scheduling_started_at", "last_scheduled_at", ], "SimpleEntities": ["date_created", "date_updated"], "Projects": ["computed_start", "computed_end", "start", "end"], "TimeLogs": ["computed_start", "computed_end", "start", "end"], "Vacations": ["computed_start", "computed_end", "start", "end"], } def upgrade(): """Upgrade the tables.""" # Directly updating the columns will set the timezone of the datetime # fields to the timezone of the machine that is running this code. # # Because the data in the database is already in UTC we need to update the # data also to have their time values correctly shifted to UTC. for table_name in tables_to_update: logger.info(f"upgrading table: {table_name}") with op.batch_alter_table(table_name) as batch_op: for column_name in tables_to_update[table_name]: logger.info(f"altering column: {column_name}") batch_op.alter_column(column_name, type_=sa.DateTime(timezone=True)) sql = """ -- Add the time zone offset UPDATE "{table_name}" SET """.format( table_name=table_name ) for i, column_name in enumerate(tables_to_update[table_name]): if i > 0: sql = "{sql},\n".format(sql=sql) # per column add sql = f"""{sql} "{column_name}" = ( SELECT aliased_table.{column_name}::timestamp at time zone 'utc' FROM "{table_name}" as aliased_table where aliased_table.id = "{table_name}".id )""" op.execute(sql) logger.info(f"done upgrading table: {table_name}") def downgrade(): """Downgrade the tables.""" # Removing the timezone info will not shift the time values. So shift the # values by hand for table_name in tables_to_update: logger.info(f"downgrading table: {table_name}") sql = f""" -- Add the time zone offset UPDATE "{table_name}" SET """ for i, column_name in enumerate(tables_to_update[table_name]): if i > 0: sql = f"{sql},\n" # per column add sql = f"""{sql} "{column_name}" = ( SELECT CAST(aliased_table.{column_name} at time zone 'utc' AS timestamp with time zone) FROM "{table_name}" as aliased_table where aliased_table.id = "{table_name}".id )""" op.execute(sql) logger.info(f"raw sql completed for table: {table_name}") with op.batch_alter_table(table_name) as batch_op: for column_name in tables_to_update[table_name]: batch_op.alter_column(column_name, type_=sa.DateTime(timezone=False)) logger.info(f"done downgrading table: {table_name}") ================================================ FILE: alembic/versions/d8421de6a206_added_project_users_rate_column.py ================================================ """Added "Project_Users.rate". Revision ID: d8421de6a206 Revises: 92257ba439e1 Create Date: 2016-08-17 19:27:00.358000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8421de6a206" down_revision = "92257ba439e1" def upgrade(): """Upgrade the tables.""" op.add_column("Project_Users", sa.Column("rate", sa.Float(), nullable=True)) def downgrade(): """Downgrade the tables.""" op.execute("""ALTER TABLE public."Project_Users" DROP COLUMN IF EXISTS rate;""") ================================================ FILE: alembic/versions/e25ec9930632_shot_sequence_relation_is_now_many_to_.py ================================================ """Shot Sequence relation is now many-to-one Revision ID: e25ec9930632 Revises: 4400871fa852 Create Date: 2024-11-16 00:27:54.060738 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "e25ec9930632" down_revision = "4400871fa852" def upgrade(): """Upgrade the tables.""" # Add sequence_id column op.add_column("Shots", sa.Column("sequence_id", sa.Integer(), nullable=True)) # Create foreign key constraint op.create_foreign_key(None, "Shots", "Sequences", ["sequence_id"], ["id"]) # Migrate the data op.execute( """UPDATE "Shots" SET sequence_id = ( SELECT sequence_id FROM "Shot_Sequences" WHERE "Shot_Sequences".shot_id = "Shots".id LIMIT 1 )""" ) # Drop Shot_Sequences Table op.execute("""DROP TABLE "Shot_Sequences" """) def downgrade(): """Downgrade the tables.""" # Add Shot_Sequences Table op.create_table( "Shot_Sequences", sa.Column("shot_id", sa.Integer(), nullable=False), sa.Column("sequence_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ["shot_id"], ["Shots.id"], ), sa.ForeignKeyConstraint( ["sequence_id"], ["Sequences.id"], ), ) # Transfer Data op.execute( """ UPDATE "Shot_Sequences" SET shot_id, sequence_id = ( SELECT id, sequence_id FROM "Shots" WHERE "Shots".sequence_id != NULL ) """ ) # Drop foreign key constraint op.drop_constraint("Shots_sequence_id_fkey", "Shots", type_="foreignkey") # drop Shots.sequence_id column op.drop_column("Shots", "sequence_id") ================================================ FILE: alembic/versions/ea28a39ba3f5_added_invoices_table.py ================================================ """Added Invoices table. Revision ID: ea28a39ba3f5 Revises: 92257ba439e1 Create Date: 2016-08-17 19:21:40.428000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "ea28a39ba3f5" down_revision = "d8421de6a206" def upgrade(): """Upgrade the tables.""" op.create_table( "Invoices", sa.Column("id", sa.Integer(), nullable=False), sa.Column("budget_id", sa.Integer(), nullable=True), sa.Column("client_id", sa.Integer(), nullable=True), sa.Column("amount", sa.Float(), nullable=True), sa.Column("unit", sa.String(length=64), nullable=True), sa.ForeignKeyConstraint( ["budget_id"], ["Budgets.id"], ), sa.ForeignKeyConstraint( ["client_id"], ["Clients.id"], ), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) def downgrade(): """Downgrade the tables.""" op.drop_table("Invoices") ================================================ FILE: alembic/versions/eaed49db6d9_added_position_column_to_Project_Repositories.py ================================================ """Added position column to Project_Repositories table. Revision ID: eaed49db6d9 Revises: 583875229230 Create Date: 2015-02-10 16:08:03.449570 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "eaed49db6d9" down_revision = "583875229230" def upgrade(): """Upgrade the tables.""" with op.batch_alter_table("Project_Repositories", schema=None) as batch_op: batch_op.add_column(sa.Column("position", sa.Integer(), nullable=True)) batch_op.alter_column("repo_id", new_column_name="repository_id") # insert zeros as the position value op.execute( """update "Project_Repositories" set position=0 """ ) def downgrade(): """Downgrade the tables.""" with op.batch_alter_table("Project_Repositories", schema=None) as batch_op: batch_op.alter_column("repository_id", new_column_name="repo_id") batch_op.drop_column("position") ================================================ FILE: alembic/versions/ec1eb2151bb9_rename_version_take_name_to_version_.py ================================================ """Rename Version.take_name to Version.variant_name Revision ID: ec1eb2151bb9 Revises: 019378697b5b Create Date: 2024-11-01 16:37:18.048904 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "ec1eb2151bb9" down_revision = "019378697b5b" def upgrade(): """Upgrade the tables.""" op.alter_column("Versions", "take_name", new_column_name="variant_name") def downgrade(): """Downgrade the tables.""" op.alter_column("Versions", "variant_name", new_column_name="take_name") ================================================ FILE: alembic/versions/ed0167fff399_added_workinghours_table.py ================================================ """Added WorkingHours table. Revision ID: ed0167fff399 Revises: 1181305d3001 Create Date: 2017-05-20 14:32:48.388000 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "ed0167fff399" down_revision = "1181305d3001" def upgrade(): """Upgrade the tables.""" op.create_table( "WorkingHours", sa.Column("id", sa.Integer(), nullable=False), sa.Column("working_hours", sa.JSON(), nullable=True), sa.Column("daily_working_hours", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["id"], ["Entities.id"], ), sa.PrimaryKeyConstraint("id"), ) op.add_column("Studios", sa.Column("working_hours_id", sa.Integer(), nullable=True)) op.create_foreign_key(None, "Studios", "WorkingHours", ["working_hours_id"], ["id"]) op.drop_column("Studios", "working_hours") op.alter_column("Studios", "last_schedule_message", type_=sa.Text) # warn the user to recreate the working hours # because of the nature of Pickle it is very hard to do it here print("Warning! Can not keep WorkingHours data of Studios.") print("Please, recreate the WorkingHours for all Studio instances!") def downgrade(): """Downgrade the tables.""" op.add_column( "Studios", sa.Column( "working_hours", postgresql.BYTEA(), autoincrement=False, nullable=True ), ) op.drop_constraint("Studios_working_hours_id_fkey", "Studios", type_="foreignkey") op.drop_column("Studios", "working_hours_id") op.drop_table("WorkingHours") op.execute( 'ALTER TABLE "Studios"' "ALTER COLUMN last_schedule_message TYPE BYTEA " "USING last_schedule_message::bytea" ) print("Warning! Can not keep WorkingHours instances.") print("Please, recreate the WorkingHours for all Studio instances!") ================================================ FILE: alembic/versions/f16651477e64_added_authenticationlog_class.py ================================================ """Added AuthenticationLog class. Revision ID: f16651477e64 Revises: 255ee1f9c7b3 Create Date: 2016-11-15 00:22:16.438000 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "f16651477e64" down_revision = "255ee1f9c7b3" def upgrade(): """Upgrade the tables.""" op.create_table( "AuthenticationLogs", sa.Column("id", sa.Integer(), nullable=False), sa.Column("uid", sa.Integer(), nullable=False), sa.Column( "action", sa.Enum("login", "logout", name="AuthenticationActions"), nullable=False, ), sa.Column("date", sa.DateTime(), nullable=False), sa.ForeignKeyConstraint( ["id"], ["SimpleEntities.id"], ), sa.ForeignKeyConstraint( ["uid"], ["Users.id"], ), sa.PrimaryKeyConstraint("id"), ) op.drop_column("Users", "last_login") def downgrade(): """Downgrade the tables.""" op.add_column( "Users", sa.Column( "last_login", postgresql.TIMESTAMP(), autoincrement=False, nullable=True ), ) op.drop_table("AuthenticationLogs") ================================================ FILE: alembic/versions/f2005d1fbadc_added_projectclients.py ================================================ """Added ProjectClients. Revision ID: f2005d1fbadc Revises: 258985128aff Create Date: 2016-06-27 14:33:10.642000 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "f2005d1fbadc" down_revision = "745b210e6907" def upgrade(): """Upgrade the tables.""" op.create_table( "Project_Clients", sa.Column("client_id", sa.Integer(), nullable=False), sa.Column("project_id", sa.Integer(), nullable=False), sa.Column("rid", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["client_id"], ["Clients.id"], ), sa.ForeignKeyConstraint( ["project_id"], ["Projects.id"], ), sa.ForeignKeyConstraint( ["rid"], ["Roles.id"], ), sa.PrimaryKeyConstraint("client_id", "project_id"), ) # before doing anything store current project clients op.execute( """insert into "Project_Clients" select client_id, id, NULL from "Projects" where "Projects".client_id is not NULL """ ) # create missing constraints if any op.execute( """ ALTER TABLE "BudgetEntries" DROP CONSTRAINT IF EXISTS "BudgetEntries_good_id_fkey"; ALTER TABLE "BudgetEntries" ADD CONSTRAINT "BudgetEntries_good_id_fkey" FOREIGN KEY (good_id) REFERENCES public."Goods" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Budgets" DROP CONSTRAINT IF EXISTS "Budgets_parent_id_fkey"; ALTER TABLE public."Budgets" ADD CONSTRAINT "Budgets_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES public."Budgets" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Dailies" DROP CONSTRAINT IF EXISTS "Dailies_project_id_fkey"; ALTER TABLE public."Dailies" ADD CONSTRAINT "Dailies_project_id_fkey" FOREIGN KEY (project_id) REFERENCES public."Projects" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Department_Users" DROP CONSTRAINT IF EXISTS "Department_Users_rid_fkey"; ALTER TABLE public."Department_Users" ADD CONSTRAINT "Department_Users_rid_fkey" FOREIGN KEY (rid) REFERENCES public."Roles" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Pages" DROP CONSTRAINT IF EXISTS "Pages_project_id_fkey"; ALTER TABLE public."Pages" ADD CONSTRAINT "Pages_project_id_fkey" FOREIGN KEY (project_id) REFERENCES public."Projects" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Project_Users" DROP CONSTRAINT IF EXISTS "Project_Users_rid_fkey"; ALTER TABLE public."Project_Users" ADD CONSTRAINT "Project_Users_rid_fkey" FOREIGN KEY (rid) REFERENCES public."Roles" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.drop_constraint("Projects_client_id_fkey", "Projects", type_="foreignkey") op.drop_column("Projects", "client_id") op.execute( """ ALTER TABLE "Scenes" DROP CONSTRAINT IF EXISTS "Scenes_project_id_fkey"; ALTER TABLE public."Scenes" ADD CONSTRAINT "Scenes_project_id_fkey" FOREIGN KEY (project_id) REFERENCES public."Projects" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "SimpleEntities" DROP CONSTRAINT IF EXISTS xu; ALTER TABLE "SimpleEntities" ADD CONSTRAINT xu FOREIGN KEY (updated_by_id) REFERENCES public."Users" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "SimpleEntities" DROP CONSTRAINT IF EXISTS z; ALTER TABLE public."SimpleEntities" ADD CONSTRAINT z FOREIGN KEY (thumbnail_id) REFERENCES public."Links" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "SimpleEntities" DROP CONSTRAINT IF EXISTS y; ALTER TABLE public."SimpleEntities" ADD CONSTRAINT y FOREIGN KEY (type_id) REFERENCES public."Types" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Studios" DROP CONSTRAINT IF EXISTS "Studios_last_scheduled_by_id_fkey"; ALTER TABLE "Studios" ADD CONSTRAINT "Studios_last_scheduled_by_id_fkey" FOREIGN KEY (last_scheduled_by_id) REFERENCES public."Users" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.execute( """ ALTER TABLE "Studios" DROP CONSTRAINT IF EXISTS "Studios_is_scheduling_by_id_fkey"; ALTER TABLE "Studios" ADD CONSTRAINT "Studios_is_scheduling_by_id_fkey" FOREIGN KEY (is_scheduling_by_id) REFERENCES public."Users" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) op.alter_column( "Task_Dependencies", "gap_timing", existing_type=postgresql.DOUBLE_PRECISION(precision=53), nullable=True, ) op.alter_column( "Task_Dependencies", "gap_unit", existing_type=postgresql.VARCHAR(length=256), nullable=True, ) op.execute( """ ALTER TABLE "Tasks" DROP CONSTRAINT IF EXISTS "Tasks_good_id_fkey"; ALTER TABLE public."Tasks" ADD CONSTRAINT "Tasks_good_id_fkey" FOREIGN KEY (good_id) REFERENCES public."Goods" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION; """ ) def downgrade(): """Downgrade the tables.""" op.execute('ALTER TABLE "Tasks" DROP CONSTRAINT "Tasks_good_id_fkey"') op.alter_column( "Task_Dependencies", "gap_unit", existing_type=postgresql.VARCHAR(length=256), nullable=True, ) op.alter_column( "Task_Dependencies", "gap_timing", existing_type=postgresql.DOUBLE_PRECISION(precision=53), nullable=True, ) op.add_column( "Projects", sa.Column("client_id", sa.INTEGER(), autoincrement=False, nullable=True), ) op.create_foreign_key( "Projects_client_id_fkey", "Projects", "Clients", ["client_id"], ["id"] ) # before dropping the Project_Clients, add the first client as the # Project.client_id op.execute( """ update "Projects" set client_id = ( select client_id from "Project_Clients" where project_id = "Projects".id limit 1 ) """ ) op.drop_table("Project_Clients") ================================================ FILE: alembic/versions/feca9bac7d5a_renamed_osx_to_macos.py ================================================ """Renamed OSX to macOS Revision ID: feca9bac7d5a Revises: bf67e6a234b4 Create Date: 2024-11-01 12:22:24.818481 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "feca9bac7d5a" down_revision = "bf67e6a234b4" def upgrade(): """Upgrade the tables.""" op.alter_column("Repositories", "osx_path", new_column_name="macos_path") def downgrade(): """Downgrade the tables.""" op.alter_column("Repositories", "macos_path", new_column_name="osx_path") ================================================ FILE: alembic.ini ================================================ # A generic, single database configuration. [alembic] # path to migration scripts script_location = alembic # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false #sqlalchemy.url = sqlite:///%(here)s/stalker.db sqlalchemy.url = postgresql://stalker_admin:stalker@localhost:5432/stalker # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " epub3 to make an epub3" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" .PHONY: clean clean: rm -rf $(BUILDDIR)/* rm -rf source/generated/* .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Stalker.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Stalker.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Stalker" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Stalker" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: epub3 epub3: $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 @echo @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex cp texinputs/* $(BUILDDIR)/latex/ @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." .PHONY: dummy dummy: $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy @echo @echo "Build finished. Dummy builder generates no files." ================================================ FILE: docs/make.bat ================================================ @ECHO OFF REM Command file for Sphinx documentation set SPHINXBUILD=..\..\Scripts\sphinx-build.exe if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=..\..\Scripts\sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. epub3 to make an epub3 echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled echo. dummy to check syntax errors of document sources goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 1>NUL 2>NUL if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Stalker.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Stalker.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "epub3" ( %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) if "%1" == "dummy" ( %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy if errorlevel 1 exit /b 1 echo. echo.Build finished. Dummy builder generates no files. goto end ) :end ================================================ FILE: docs/make_html.bat ================================================ ..\..\sphinx-build.exe -b html -D graphviz_dot="dot.exe" source build\html ================================================ FILE: docs/source/_static/images/Task_Status_Workflow.vue ================================================ Task_Status_Workflow.vue #FFFFFF #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/400bcd2639fad37c2b0b7e545266b52a #D0D0D0 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd2939fad37c2b0b7e54a889347a #FC938D #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd2c39fad37c2b0b7e54864540f3 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd2d39fad37c2b0b7e546aff6d54 6 7 #FFC63B #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd4239fad37c2b0b7e547fce60e4 #83CEFF #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd4539fad37c2b0b7e543e5eb4f9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd4539fad37c2b0b7e54ddb1139f 9 11 #DAA9FF #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd4739fad37c2b0b7e546a650ff6 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd4839fad37c2b0b7e549280057f 11 13 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd4939fad37c2b0b7e543c642f53 13 9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd4a39fad37c2b0b7e54d6134912 11 18 #8AEE95 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd4b39fad37c2b0b7e54dc411cd0 #EA2218 #000000 #FFFFFF Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd4d39fad37c2b0b7e5415afe5b9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd5539fad37c2b0b7e543dfd69cc 29 9 #D0D0D0 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/400bcd5739fad37c2b0b7e546148af1c #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd5739fad37c2b0b7e543634001b 9 29 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd4339fad37c2b0b7e54d84a5e4e 7 9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd5339fad37c2b0b7e543f29cb47 9 21 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/4040cb2939fad37c2b0b7e5425f40da9 21 9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a1b6ced67f0000013b244fea3bcf813b 18 13 #FFC63B #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a1b6ced77f0000013b244feaa9ee9d7b #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a1b6ced87f0000013b244feacfeea727 18 48 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a1b6ceda7f0000013b244fea6bf684bb 48 13 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a574d2c37f00000148ec1d5416dda6df 9 48 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a574d2c47f00000148ec1d54c76f7627 13 48 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/400bcd5439fad37c2b0b7e54b86d70b9 9 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a5c84bd87f00000148ec1d5486b15f38 48 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/a574d2c17f00000148ec1d542c75e466 7 6 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b02ac6397f0000013e376dbaac4e08d3 9 7 #D0D0D0 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b0b951897f0000010f9ede6dbf93a108 #FC938D #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b0b9518a7f0000010f9ede6df7032851 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b9518b7f0000010f9ede6d33187e87 85 86 #FFC63B #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b0b9518c7f0000010f9ede6d5646bd67 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b9518d7f0000010f9ede6d0afb73cb 88 95 #8AEE95 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b0b9518e7f0000010f9ede6de123e8b3 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b9518f7f0000010f9ede6da65640f1 86 88 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b951907f0000010f9ede6d61625eb4 86 85 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b951917f0000010f9ede6ddfc41ffa 88 86 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b0b951927f0000010f9ede6d857e3460 95 88 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/b0b951937f0000010f9ede6df249fca0 <html> <head color="#404040" style="color: #404040"> <style type="text/css"> <!-- body { margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; font-size: 11; font-family: Arial; color: #404040 } ol { margin-top: 6; font-family: Arial; vertical-align: middle; margin-left: 30; font-size: 11; list-style-position: outside } p { margin-top: 0; margin-left: 0; margin-right: 0; margin-bottom: 0; color: #404040 } ul { margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle; list-style-position: outside; font-family: Arial } --> </style> </head> <body> <p color="#404040" style="text-align: center; color: #404040"> CONTAINER TASK STATUS WORKFLOW </p> </body> </html> #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/b0b951957f0000010f9ede6d01435234 <html> <head color="#404040" style="color: #404040"> <style type="text/css"> <!-- body { margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; font-size: 11; font-family: Arial; color: #404040 } ol { margin-top: 6; font-family: Arial; vertical-align: middle; margin-left: 30; font-size: 11; list-style-position: outside } p { margin-top: 0; margin-left: 0; margin-right: 0; margin-bottom: 0; color: #404040 } ul { margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle; list-style-position: outside; font-family: Arial } --> </style> </head> <body> <p color="#404040" style="text-align: center; color: #404040"> LEAF TASK STATUS WORKFLOW </p> </body> </html> #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/b465a9c87f00000160874ad883e2b183 29 48 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/baa74e4c7f000001444cc84d9fe05d8e 48 29 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/bbd8eded7f000001444cc84d0c8a9afd 21 48 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/bbd8edee7f000001444cc84da1d1e216 48 21 #D0D0D0 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d21dc0a8012b17e4445f9ad9d4f0 #FC938D #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d21ec0a8012b17e4445fe5262599 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d21ec0a8012b17e4445f7e54fab2 134 135 #FFC63B #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d21fc0a8012b17e4445f9fb714d9 #83CEFF #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d220c0a8012b17e4445fa5c7307d #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d221c0a8012b17e4445f09711db8 137 138 #DAA9FF #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d222c0a8012b17e4445f68152c62 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d222c0a8012b17e4445f68878e0b 138 140 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d223c0a8012b17e4445ffb86e992 140 137 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d224c0a8012b17e4445f3f7e4d37 138 144 #8AEE95 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d224c0a8012b17e4445f2622ddfe #EA2218 #000000 #FFFFFF Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d225c0a8012b17e4445f95fe4ae5 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d225c0a8012b17e4445f5cde7a27 147 137 #D0D0D0 #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d226c0a8012b17e4445f58f103df #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d226c0a8012b17e4445f20a567f8 137 147 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d227c0a8012b17e4445f218056c0 135 137 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d228c0a8012b17e4445fb829c90a 137 145 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d229c0a8012b17e4445ffe30d926 145 137 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d229c0a8012b17e4445f6d767d7e 144 140 #FFC63B #000000 #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/0447d22ac0a8012b17e4445f8ac663b8 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22ac0a8012b17e4445f5c71943d 144 153 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22bc0a8012b17e4445f2a8d786b 153 140 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22cc0a8012b17e4445fd590992f 137 153 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22dc0a8012b17e4445f23a0e668 140 153 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22dc0a8012b17e4445f3be48af4 137 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22ec0a8012b17e4445fe8d0268c 153 #EA2218 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d22fc0a8012b17e4445f07f35e93 135 134 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d230c0a8012b17e4445fcce5a2e1 137 135 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d231c0a8012b17e4445f0569c66e 147 153 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d231c0a8012b17e4445f44b3f111 153 147 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d232c0a8012b17e4445f46acc020 145 153 #404040 #404040 Arial-plain-11 http://vue.tufts.edu/rdf/resource/0447d233c0a8012b17e4445fb3acd833 153 145 http://vue.tufts.edu/rdf/resource/400bcd5c39fad37c2b0b7e543d00aadd 0.1872035782673108 #202020 #B3993333 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/400bcd5d39fad37c2b0b7e54a8dd4d0e #000000 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/400bcd5e39fad37c2b0b7e5496c8827d #404040 #FFFFFF Gill Sans-plain-36 http://vue.tufts.edu/rdf/resource/400bcd5f39fad37c2b0b7e5405c26a3d #404040 #FFFFFF Gill Sans-plain-22 http://vue.tufts.edu/rdf/resource/400bcd6039fad37c2b0b7e54838aa09c #404040 #B3BFE3 Gill Sans-plain-18 http://vue.tufts.edu/rdf/resource/400bcd6139fad37c2b0b7e542860fb86 2013-12-29 6 /home/eoyilmaz/Documents/development/stalker/stalker/docs/source/_static/images /home/eoyilmaz/Documents/development/stalker/stalker/docs/source/_static/images/Task_Status_Workflow.vue ================================================ FILE: docs/source/_static/images/stalker_design.vue ================================================ stalker_design.vue #FFFFFF #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/5d104b14c00007d601b277f03c449819 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d14c0a82a4a019efdbdfb2a0477 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d14c0a82a4a019efdbde18ed0a7 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d14c0a82a4a019efdbd0fb6ad9c #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbdad4b79ff #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd06132a49 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd9fd9c70a #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd6059f1d9 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd669ca722 #D4FF00 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b8a673297f0001017e6ab533f3ced4f1 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2bbcec17f0001013611c755dc3f00fa #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b8a673297f0001017e6ab533fe3db766 #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a673297f0001017e6ab533c72f66e7 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a673297f0001017e6ab533ddf68e57 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a6732a7f0001017e6ab533785e3d58 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a6732a7f0001017e6ab5335ce88fa0 #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a6732a7f0001017e6ab533df4fafbf #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a6732a7f0001017e6ab5337661773f #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a6732a7f0001017e6ab533aa4e4f71 #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/37fd2d78c0a8000435fa83793037fe30 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4137afd47f00010135ea5711d86d98a9 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fc5650c0a8000435fa8379675e6ac8 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/746640447f0001014a6b2ab7fc9c5221 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4140ccdd7f00010135ea57116e5577a9 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7657f0001015ee819a8aafadfdf #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/208db1db7f000101264d107e3c78088c #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aec5c0a8000435fa837926646ede #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/4137afe67f00010135ea57113e9099fb 1887 3382 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4140cd037f00010135ea57112130f8c4 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4140cd037f00010135ea57117ab47786 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ec507e437f00010143bf3d773ab1cc57 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/d5acab167b6a9fda5a40fe82ce930ac9 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/d5bfbb467b6a9fda5a40fe82ac8cdeb8 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/d5bfbb477b6a9fda5a40fe824a2cb1f4 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acab0d7b6a9fda5a40fe82688b31f3 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/208db1ed7f000101264d107ef3bb38ae #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/3624ae36c0a8000435fa837938f1c1e7 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4140cd0a7f00010135ea571155c75028 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4140cd0b7f00010135ea5711602370dd #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7687f0001015ee819a82b2a236f #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4140cd0d7f00010135ea5711f07153c7 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2bbced07f0001013611c75577191d5f #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/14e83dd27f0001017490649e82b3b87a #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fc5655c0a8000435fa8379368c5321 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/98294d057f000101247aa859e3c11b58 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ec8bebda7f0001010147e4f04ea33efd #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7146b48d7f0001010a530461cc109033 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/47830e6e7f0001015a3184d85f950ed5 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73dd535d7f0001010b45ad3dcc59d99c #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/6924820d7f00010168376cc3eaa57d6b #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/278b9a9a7f000101775978e75d5e32b6 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/8c9f23e0eead3b3354aea17a122ae7af #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/8c9f23e1eead3b3354aea17a64edbd9c #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/48326e217f0001017a8ff303fe1e74b3 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4140cd107f00010135ea57114a508f5b #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73b722e97f000101783f472b90b90716 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/85ca6d117f0001010cdfa65c929e6a1b #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/2120bb40c0a80004173b812d2e40c79a #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b76a7f0001015ee819a8fe024cea #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/208db2207f000101264d107e85978e7a #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aec9c0a8000435fa83792ea2bbbd #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4144a3f27f00010135ea57117ebd1aac #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fc565ac0a8000435fa8379e65a22be #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4144a3f47f00010135ea57118b03d3ed #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/484975a57f0001015a3184d81211a0d2 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/484b00917f0001015a3184d88131b25d #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b76b7f0001015ee819a858e7cc58 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aecbc0a8000435fa837918244a2e #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/414f1a427f00010135ea57117bdc53d1 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fc565dc0a8000435fa83794c989a62 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/85ca6d1d7f0001010cdfa65c7007b57b #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a8223ca7a62df9af4916575120e6ee34 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/414f1a427f00010135ea5711fda03f80 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/414f1a437f00010135ea57113c08fcf5 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/414f1a427f00010135ea57111b10b68c #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d42b5a304cc66935691a928b2c7f7d9d #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/2c2b7b2c7f00010118154bb12694d02a #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4849e7047f0001015a3184d87c51e040 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/278b9ab27f000101775978e70bad0084 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/278b9ab37f000101775978e7a0658db2 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b76d7f0001015ee819a88a8002c7 #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/362ef8b9c0a8000435fa8379dcc6daaa #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4156061a7f00010135ea5711dcb8f75c #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2bbcee47f0001013611c755c3131536 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97b7c5abc0a82a4a010817798adf5656 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4a8686e97f000101101873a641c3e7c7 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/bd12292ac0a82a4a01152d0efe940b4e #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2c9ec0a82a4a019efdbd325202e5 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/dd5bc86d7f0001010c4cdf7ed67689ed #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4156061a7f00010135ea57115fceb210 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e8a41d0c648c6197658d749a0994b723 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/70f609097f0001010a5304612bb962ad #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4156061c7f00010135ea5711df3cc9d5 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4156061d7f00010135ea5711f78c3ffb #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/738287e27f000101783f472bbfb64909 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/8c9f23eceead3b3354aea17a20109083 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/85d09fcc7f0001010cdfa65c9b0d5367 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/85d0f0437f0001010cdfa65c3ab63ddc #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b76f7f0001015ee819a8bb925774 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aed0c0a8000435fa83790c3a64d8 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/4156061d7f00010135ea5711c2172d7e 1887 1205 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/4156061e7f00010135ea571114c0ee72 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/835f6ce87f000101123acf342d8e5a89 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4156061f7f00010135ea57112dad9a44 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4156061f7f00010135ea57110e5bbdd2 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7707f0001015ee819a8d0c7631c #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/3624ae42c0a8000435fa83796660f2f7 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/415b1bc47f00010135ea571102f905e6 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/d35be5214cc66935691a928b1dbc39ce #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5f5269207f0001011df0758683afba34 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/415b1bc57f00010135ea5711bb1452df #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/415b1bc67f00010135ea5711cc5235aa #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/415b1bc57f00010135ea5711df108926 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/415b1bc67f00010135ea57118ffe5686 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/415b1bc77f00010135ea57113656d812 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/715c39c47f0001010a53046192f39ef8 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4bb089d87f000101283b96bbba818d63 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/efe00692c8211d50048a3743a2254d81 #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/85d5b47c7f0001010cdfa65ca49c356e #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9d0d965c7f0001015d73b35d3456f432 #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8973e797f0001017e6ab533ca951c0f #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/85d3daed7f0001010cdfa65c1ca9c5be #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/982531327f000101247aa859a673b56a #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/959391d87f0001015ee819a88b8f9436 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aed4c0a8000435fa83798d86a388 #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f2902e11c0a8000f011bc52f7510551a #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f2902e11c0a8000f011bc52fda3eaaf8 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412927f00010135ea5711ee89e7ec #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412937f00010135ea5711e1bf0da0 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412937f00010135ea5711025e4908 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412937f00010135ea571114f2487c #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412947f00010135ea5711ba3432c6 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412947f00010135ea57119c4a005f #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412947f00010135ea571193baeb10 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412947f00010135ea57111edef472 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412947f00010135ea571180cf8f37 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/416412967f00010135ea5711e7b676e0 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7747f0001015ee819a8fb4d3c74 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/416412967f00010135ea57116b7f484f 1887 1258 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/41652daa7f00010135ea57110fc3ae1b #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acabf37b6a9fda5a40fe82fa141e70 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aed6c0a8000435fa8379a22ba050 #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbadc0a8000f011bc52f90de7713 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbadc0a8000f011bc52f19c47bcb #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/41652dab7f00010135ea57113765e4bc #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b37f00010135ea5711f7eb2636 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b47f00010135ea5711afaf9d5a #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b47f00010135ea57110de734c6 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b57f00010135ea5711d9b9080c #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b57f00010135ea5711545ebf12 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/417945b47f00010135ea57118bfc42a4 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7767f0001015ee819a8317b3c9c #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7c845ab97f00010136a8a19945559ad8 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c5f034e54cc66935691a928b23df921d #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7c845ab97f00010136a8a19938a35367 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/c7d533fd7f00010136536500f161e613 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/959428897f0001015ee819a8be414bd8 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aed8c0a8000435fa83795d4655ff #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a33b57e77f0001016609e5f078560790 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a800f7887f00010130ce553671097af3 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/70f9fdd47f0001010a5304617b221190 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acac0d7b6a9fda5a40fe82045cd555 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aed9c0a8000435fa837963fcd72d #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/f0822ba57f000101666558bbfaa1423c #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2cb3c0a82a4a019efdbdc1bcd62a #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f0822ba97f000101666558bbc49201a2 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/716648fb7f0001010a530461d3a9c89a #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/dcc1257d7f0001011f758c30f2b26443 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f07316197f00010143bf3d77fa79a157 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/fc128cad7f00010120d48785781bf5c0 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72f3a55b7f0001010b45ad3d8fd4b444 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7787f0001015ee819a8802e3a4c #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/3624ae50c0a8000435fa8379c88ae3ca #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f0822baa7f000101666558bbc8e3f079 1145 1362 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/70dc3fa77f0001010a5304616bf72ea2 1887 1235 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/70dc3fa97f0001010a530461bed23383 1887 1329 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/70fbee177f0001010a53046131252ebc #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3d020999c0a800044f9e91d0cec913ac #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/958f53697f0001015ee819a87bfe14d0 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aedcc0a8000435fa83792e20f08b #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb1c0a8000f011bc52fdce9b087 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb1c0a8000f011bc52fdc0896fc #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/70ff4c457f0001010a5304618b714b63 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/d35be5474cc66935691a928b9ebf2073 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7396a2bf7f000101783f472b0c288dca #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9594288d7f0001015ee819a808d4eae0 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aeddc0a8000435fa83793c037b2a #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/716433b77f0001010a530461ea2689a9 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/716433b87f0001010a530461ae077f28 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/f2dcabc17f0001010d9438de492b2f36 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b77c7f0001015ee819a88d3af5f8 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7175e3647f0001010a5304618afb3c6d #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7175e3647f0001010a530461c2444b8a #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7175e3647f0001010a530461a525aa4a #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7175e3657f0001010a530461d423e0c0 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7175e3647f0001010a530461ed5f93c3 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/dd623f737f0001010c4cdf7e1538b03a #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acac407b6a9fda5a40fe8240c9c0bf #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aedec0a8000435fa8379c28ae15c #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7376e9ec7f000101783f472b76efe287 1887 1444 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/d175941d7f0001014ed1a34a819236b1 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/84ca7c997f00010138aea9817270f61e <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Implemented classes </p> </body> </html> #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/ecbe81757f0001012cc821ef070ba546 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Implemented attributes </p> </body> </html> #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/f21c2a7a7f0001010d9438de5c6ad321 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f220e0137f0001010d9438de0e9e63e0 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7ae32d587f0001017115e16621f44fe5 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acac4f7b6a9fda5a40fe82e49025e5 #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aee0c0a8000435fa837959e2d05e #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb4c0a8000f011bc52ff34ef0a9 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb4c0a8000f011bc52f65bedc44 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/f220e0127f0001010d9438de61473dd2 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ff73db697f0001015481be8a5451779a #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ff73db697f0001015481be8af558ee50 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f21c2a7a7f0001010d9438de2db62acf #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f21c2a7a7f0001010d9438de885e42f6 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f21c2a7a7f0001010d9438de584fbe77 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f21c2a7a7f0001010d9438dead15905c #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7f93720e7f0001017115e1664dc590db #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/4137afd27f00010135ea5711ec68f281 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1f190a3a7f000101578cef1800e852f8 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/820dc20f7f0001015d3258773963db05 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/c44466c67f0001013611c755f271acd4 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97afe790c0a82a4a0108177921807cf8 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acac627b6a9fda5a40fe822a456e6a #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aee2c0a8000435fa8379eb933d7b #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb5c0a8000f011bc52f94cd3e52 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbb5c0a8000f011bc52f9eb42d35 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f22534227f0001010d9438dee0d42673 1887 1396 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f22534227f0001010d9438dea8c2b1fc 1887 1355 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f22534227f0001010d9438de64236ba0 1887 1465 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f22534237f0001010d9438dede87b33c 1887 1299 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f22534237f0001010d9438de69c8feac 1887 1145 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f2c6c0257f0001010d9438de6add753c 1887 1227 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f2c6c0257f0001010d9438de4ef74d59 1887 1157 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/fc1178ae7f00010120d48785675aa223 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/fc1178af7f00010120d48785088a5d16 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7857f0001015ee819a81ccbfc97 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/fc1178b07f00010120d4878508d5a5da 1887 1940 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/126dc1f67f0001014196c169c59591bf 1887 2180 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/476ff9f37f0001015a3184d866b3d9f0 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2bbcf617f0001013611c75512d46e2b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/476ff9f47f0001015a3184d89f755940 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9597b7867f0001015ee819a85ff2e993 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/476ff9f57f0001015a3184d86becdbe1 1887 1995 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/704adc107f0001012ed4f13aa93265ac #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/704adc117f0001012ed4f13ae0ef0e20 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1f0d0cc37f000101578cef18dc05580c #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acac8a7b6a9fda5a40fe827c2c1502 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/208df7367f000101264d107e7f8218a8 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aee6c0a8000435fa8379abbe7bb8 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/704adc147f0001012ed4f13a8b835d41 1887 2012 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c7681c7f0001010b45ad3d65da5015 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/709d95a57f0001012ed4f13a4667f382 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Where to save the templates, or more clearly, where to save the data that holds which file of which pipeline_step should saved where... </p> </body> </html> #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c7681e7f0001010b45ad3dbd6e27e5 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72c7681f7f0001010b45ad3d77bc2d11 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Asset Type holds a lot of things, so may be the asset type should hold the </p> <p style="color: #000000" color="#000000"> data that shows where to save a specific type </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c768217f0001010b45ad3d27ead190 2026 2027 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c768227f0001010b45ad3de419a2bb #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72c768237f0001010b45ad3ddd5305d2 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> This leads a weird setup where an object has to be saved to the same place in every project, this limits the flexibility we aim </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c768247f0001010b45ad3dedb39d1c 2027 2031 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c768257f0001010b45ad3d2cd5572c #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72c768267f0001010b45ad3d7b74ae69 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> hold the data in project node, so any project can have different setups </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c768287f0001010b45ad3ddc9a3886 2026 2035 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c768297f0001010b45ad3d2ac3c4b6 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72c768297f0001010b45ad3d14b50bfc <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> this needs to have an entry for every asset type, so if I'm going to hold a template for assets I need to specify that this template is for assets, and if I want to have a template for shots, I need to specify that this template is for shots </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c7682b7f0001010b45ad3d805a8614 2035 2038 #30D643 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c7682c7f0001010b45ad3d801617fc #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c7682d7f0001010b45ad3dc2a36449 2038 2043 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c8b39c7f0001010b45ad3d3de72c20 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c8b39d7f0001010b45ad3dbf4d4a87 2031 2045 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72c8b39e7f0001010b45ad3dce9e9452 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72c8b39e7f0001010b45ad3d443056e8 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 11; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> <font style="font-size:12;">how to connect the data, in this case</font> </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72c8b3a07f0001010b45ad3d861cccb5 2043 2048 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72ccceac7f0001010b45ad3deca8e4e1 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72cccead7f0001010b45ad3d050b14ca <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> so for every assetType given to the system, the user can specify the default location to save </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72ccceaf7f0001010b45ad3d72cbe1eb 2048 2051 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72ccceb07f0001010b45ad3d6a47dcac #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72ccceb07f0001010b45ad3d80acd543 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 11; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> the files are produced in pipeline steps, for a &quot;Character&quot; assetType, the &quot;Modeling&quot; pipelne step produces the file, so we need three data to hold, one the assetType, second the pipeline step, and third the template string </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72ccceb27f0001010b45ad3d93aaab40 2048 2057 #FC938D #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72d4e8727f0001010b45ad3d6534da51 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72d4e8727f0001010b45ad3d7e0392c9 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Create a new composite foreign key, that holds a key to one asset type object and a pipeline step, and has a string template </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72d4e8737f0001010b45ad3d19b9fc7e 2057 2060 #30D643 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72d4e8737f0001010b45ad3d683e5230 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72d4e8737f0001010b45ad3db5912744 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> this can work with the structure system </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72d4e8747f0001010b45ad3d2421dadd 2060 2063 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72d4e8747f0001010b45ad3dfa561263 2051 2060 #FC938D #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72d6ca677f0001010b45ad3d78d6eb10 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72d6ca687f0001010b45ad3d900a2d2c <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 11; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 11; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> <font style="font-size:12;">use assetType side by side with a template</font> </p> </body> </html> #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72d6ca687f0001010b45ad3dde441d00 2051 2067 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72e63ba77f0001010b45ad3d2724344a #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/72e63ba87f0001010b45ad3dc2870b40 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Should I have a template for any entity those have a connection with the file system </p> </body> </html> #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/72f113a57f0001010b45ad3d8a1153d0 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a57f0001010b45ad3d490f63de 2075 2080 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a67f0001010b45ad3d49b4e257 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a67f0001010b45ad3ddebb1a70 2080 2082 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a67f0001010b45ad3d95cb489f #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a67f0001010b45ad3dc1ae5313 2082 2084 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a77f0001010b45ad3d3f31f2fc #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a77f0001010b45ad3d06e536cd 2080 2086 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a77f0001010b45ad3d2df9b014 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a87f0001010b45ad3d2466129e 2086 2088 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a87f0001010b45ad3d3c38709f #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a87f0001010b45ad3d908c11e0 2088 2091 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a97f0001010b45ad3d6fed6405 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a97f0001010b45ad3d34de9e5c 2084 2095 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113a97f0001010b45ad3d6728aea3 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113a97f0001010b45ad3d3911b327 2095 2100 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113aa7f0001010b45ad3d9af06b7f #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113aa7f0001010b45ad3dd1e49c4e 2100 2102 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72f113aa7f0001010b45ad3d3e1507b7 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72f113ab7f0001010b45ad3d1cbe64b2 2102 2104 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72fbe81f7f0001010b45ad3d3a6d4e41 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72fbe8207f0001010b45ad3d2b6e222b 2104 2109 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/72fbe8207f0001010b45ad3df22cfe6c #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/72fbe8207f0001010b45ad3d28033eea 2109 2111 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7389b2a27f0001010b45ad3d774a4589 #C1F780 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a27f0001010b45ad3d4fa061ad #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7389b2a27f0001010b45ad3d045938fe 2167 2143 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a37f0001010b45ad3d2eb1871e #ECFFD4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a37f0001010b45ad3d2ffed3ed #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a37f0001010b45ad3da34391da #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a37f0001010b45ad3d5d01bd82 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7389b2a47f0001010b45ad3dcbb17974 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/7389b2a47f0001010b45ad3d91817f7f <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Examples: </p> <p style="color: #000000" color="#000000"> {{projects.root}}/{{project.name}} </p> <p style="color: #000000" color="#000000"> /SEQUENCES/{{sequence.name}} </p> <p style="color: #000000" color="#000000"> /SHOTS/{{shot.name}} </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7389b2a57f0001010b45ad3de520dce3 2143 2145 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7389b2a57f0001010b45ad3dda5f0f57 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7394cf4e7f0001010b45ad3d4505f265 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73db9f4c7f0001010b45ad3de656431d #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73db9f4c7f0001010b45ad3dbced640d #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73db9f4c7f0001010b45ad3d456aaae7 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/73ee6ed37f0001010b45ad3d6434d46f #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/958f53917f0001015ee819a8ab2b371f #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aef7c0a8000435fa837957f37360 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/7394cf4e7f0001010b45ad3d9f393b4e 1887 2154 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/739c551d7f0001010b45ad3d8cc96286 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/739c551d7f0001010b45ad3d7e48d57e 2142 2160 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/739c551d7f0001010b45ad3d5a0a2c93 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/739c551e7f0001010b45ad3d485244a7 2160 2162 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/739c551e7f0001010b45ad3d4388adf9 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/739c551e7f0001010b45ad3d829e5aec 2162 2167 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/739e1da67f0001010b45ad3dba5f91ba #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/739e1da67f0001010b45ad3d81c3b80d 2145 2169 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/739e1da77f0001010b45ad3ddeae5d00 2169 2147 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/73a89ec47f0001010b45ad3d3340d8ba #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/73a89ec57f0001010b45ad3d5be80605 2142 2177 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/73a89ec67f0001010b45ad3d49fb4e30 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a800f9087f00010130ce553694f16644 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/372d244c7f00010150882100fb860d9b #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/372d244c7f000101508821007f113662 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1f0762537f000101578cef187c1d8dbe #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acad2e7b6a9fda5a40fe8247f8b756 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8aefac0a8000435fa837971185c26 #30D643 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/73f32c657f0001010b45ad3de4631d2a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/73f32c657f0001010b45ad3d5690194d 2147 2198 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/740838427f0001010b45ad3d5e0a73f3 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Back -references </p> </body> </html> #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/740838437f0001010b45ad3d8a55f990 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Secondary attributes which are derived from current attributes and are not persistet in the database </p> </body> </html> #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/740838437f0001010b45ad3d5ec22072 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Nodes those are not going to be implemented </p> </body> </html> #9DDB53 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/84ca7c987f00010138aea98153c19261 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/84d4ca357f00010138aea981c2362867 #DAA9FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/740838427f0001010b45ad3d613481d3 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/740838437f0001010b45ad3d6fcb50ca #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/740838447f0001010b45ad3df4e42c98 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/7412d2a47f0001010b45ad3d0fe8838d #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce5597f00010177026e406428bc52 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce55a7f00010177026e405b6f7397 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce55b7f00010177026e40538d6e0a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce55c7f00010177026e404ac18180 2227 2224 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce55d7f00010177026e40f5873189 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce55e7f00010177026e4085954d8d #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce55f7f00010177026e40305f270b 2230 2229 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce5617f00010177026e40f6ef7cde #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce5627f00010177026e400796cc3f 2229 2232 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce5637f00010177026e4015495ed2 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e19ce5647f00010177026e406e92ee08 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce5657f00010177026e40a5e1ab26 2236 2230 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce5667f00010177026e40a2f1c46f 2234 2225 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce5677f00010177026e40fab7b7de 2225 2224 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e19ce5687f00010177026e40d871ebc7 2230 2234 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e1a292207f00010177026e40616be0a9 2227 2232 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e1a292217f00010177026e4079dd75c1 2232 2236 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e1a292227f00010177026e40f20f40e8 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/e1a292227f00010177026e404f21e562 2254 2236 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/e1b958057f00010177026e4065f7dcfe #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/ff73db6b7f0001015481be8a2537d4ab 1901 1295 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/f21c2a7b7f0001010d9438ded95282fa 1901 1887 #30D643 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/32deba3f7f0001015088210050d1939f #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/32deba3f7f000101508821006c2d59f4 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Generalize the structure system and add: </p> <ul color="#000000"> <li style="color: #000000" color="#000000"> folder_templates </li> <li style="color: #000000" color="#000000"> asset_templates </li> <li style="color: #000000" color="#000000"> reference_templates </li> </ul> <p color="#000000"> </p> <p style="color: #000000" color="#000000"> to hold templates for every one of them. This leads us a complete structure object where one can see the whole picture in one object... </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/32deba407f00010150882100a056b737 2026 2287 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/370c82157f00010150882100318ba225 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/370c82167f00010150882100fbc03f47 2319 2322 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/370c82167f00010150882100c967ecd4 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/370c82167f00010150882100be8598dc #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/370c82167f000101508821004c57c340 2322 2324 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/370c82167f000101508821000ce84590 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/370c82167f000101508821004cd5726c 2322 2328 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/378f410b7f00010150882100d07ef254 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/37cc22ee7f00010150882100864e6ffb #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/379966d07f000101508821000b2bd7fd #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/379966d07f0001015088210028a2ff79 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/51327de87f0001017517e53b4e0b6faa <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Implemented classes with persistancy (Classic SOM) </p> </body> </html> #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/51327de87f0001017517e53b06dcb343 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5161bffb7f0001017517e53b94128c41 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5161bffb7f0001017517e53b3481e15b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5161bffc7f0001017517e53b06eb0375 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5161bffc7f0001017517e53b0a74a25b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5161bffc7f0001017517e53bfcda26b7 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5161bffc7f0001017517e53b1d3dd4c2 2379 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5161bffc7f0001017517e53b0c1d6699 2382 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5161bffd7f0001017517e53b80768083 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5161bffd7f0001017517e53b94d526a0 2386 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5161bffd7f0001017517e53b87414322 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5161bffd7f0001017517e53bf8dd37fb #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5161bffd7f0001017517e53b5ed7b8f6 2388 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/519d2c637f0001017517e53b1a4782a4 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a800f9bb7f00010130ce55362b9cdc24 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3d029f0cc0a800044f9e91d03a4720d1 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acad957b6a9fda5a40fe820dca0155 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8af06c0a8000435fa83795f0c1c20 #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/379ae6b47f00010150882100c2142256 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Reference Template Example 1: </p> <p style="color: #000000" color="#000000"> ReferenceType = Image </p> <p style="color: #000000" color="#000000"> {{repository.path}}/{{project.code}}/REFS/{{reference.type.name}}/{{reference.link.name}} </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/378f410b7f0001015088210053e34b34 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Asset Version Template Example 1: </p> <p style="color: #000000" color="#000000"> AssetType.name = Shot </p> <p style="color: #000000" color="#000000"> {{repository.path}}/{{project.code}}/SEQUENCES/{{sequence.name}}/SHOTS/{{shot.code}}/{{pipeline_step.code}}/{{shot.code}}_{{take.name}}_{{version.version_number}} </p> </body> </html> #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/51aaed357f0001017517e53b20cfc539 #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/51b7d1837f0001017517e53b679d9674 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Asset Version Template Example 2: </p> <p style="color: #000000" color="#000000"> AssetType.name = Character </p> <p style="color: #000000" color="#000000"> {{repository.path}}/{{project.code}}/ASSETS/{{asset_type.name}}/{{pipeline_step.code}}/{{asset.name}}_{{take.name}}_{{asset_type.name}}_{{version.version_number}} </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/51c2b1ad7f0001017517e53b94d0c271 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Reference Template Example 2: </p> <p style="color: #000000" color="#000000"> ReferenceType = Web (the same with the Image example) </p> <p style="color: #000000" color="#000000"> {{repository.path}}/{{project.code}}/REFS/{{reference.type.name}}/{{reference.link.name}} </p> </body> </html> #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7ae32d837f0001017115e1660c06a911 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7ae32d837f0001017115e1666b17d87e #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d5acada37b6a9fda5a40fe82eb524c4b #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8af09c0a8000435fa8379afc3b443 #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbcfc0a8000f011bc52f32888ef6 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbcfc0a8000f011bc52f462ab2eb #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7ae32d837f0001017115e16676d33287 1901 2440 #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7464cb477f0001014a6b2ab74e33abce <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> PipelineStep </p> <p style="color: #000000" color="#000000"> Examples: </p> <p style="color: #000000" color="#000000"> design-DESIGN </p> <p style="color: #000000" color="#000000"> model-MODEL </p> <p style="color: #000000" color="#000000"> rig-RIG </p> <p style="color: #000000" color="#000000"> fur-FUR </p> <p style="color: #000000" color="#000000"> shading-SHADE </p> <p style="color: #000000" color="#000000"> previs-PREVIS </p> <p style="color: #000000" color="#000000"> match move-MM </p> <p style="color: #000000" color="#000000"> animation-ANIM </p> <p style="color: #000000" color="#000000"> fx-FX </p> <p style="color: #000000" color="#000000"> cloth sim-CLOTHSIM </p> <p style="color: #000000" color="#000000"> layout-LAYOUT </p> <p style="color: #000000" color="#000000"> lighting-LIGHT </p> <p style="color: #000000" color="#000000"> compositing-COMP </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7464cb547f0001014a6b2ab78c34774b <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> AssetType </p> <p style="color: #000000" color="#000000"> Examples: </p> <p style="color: #000000" color="#000000"> Character </p> <p style="color: #000000" color="#000000"> FuryCharacter </p> <p style="color: #000000" color="#000000"> Vehicle </p> <p style="color: #000000" color="#000000"> Prop </p> <p style="color: #000000" color="#000000"> Environment </p> <p style="color: #000000" color="#000000"> Shot </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/704adc127f0001012ed4f13a3db45dfe <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Examples: </p> <p style="color: #000000" color="#000000"> ASSETS </p> <p style="color: #000000" color="#000000"> SEQUENCES </p> <p style="color: #000000" color="#000000"> SEQUENCES\EDIT_MOVIE </p> <p style="color: #000000" color="#000000"> ... </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/32f3ce3d7f000101508821003b9a78db <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> LinkType Examples: </p> <p style="color: #000000" color="#000000"> Image </p> <p style="color: #000000" color="#000000"> ImageSequence </p> <p style="color: #000000" color="#000000"> Video </p> <p style="color: #000000" color="#000000"> Text </p> <p style="color: #000000" color="#000000"> Web </p> <p style="color: #000000" color="#000000"> ... </p> </body> </html> #FDE888 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/519d2c647f0001017517e53b19274f95 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> ProjectType Examples: </p> <p style="color: #000000" color="#000000"> Commercial </p> <p style="color: #000000" color="#000000"> Movie </p> <p style="color: #000000" color="#000000"> Still </p> <p style="color: #000000" color="#000000"> ... </p> </body> </html> #FCDBD9 #404040 #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/519d2c647f0001017517e53b912c4954 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> UserType Examples: </p> <p style="color: #000000" color="#000000"> SuperUser </p> <p style="color: #000000" color="#000000"> Admin </p> <p style="color: #000000" color="#000000"> Normal </p> <p style="color: #000000" color="#000000"> ... </p> </body> </html> #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9589d5c77f0001015ee819a888178704 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/9589d5c87f0001015ee819a8969060b6 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> has __eq__, __ne__ </p> </body> </html> #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/958e29dc7f0001015ee819a82f59b16a #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/958e29dc7f0001015ee819a836cff506 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> doesn't have __eq__, __ne__ </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/989c41f37f0001015ee819a8aa4c7a01 3382 2517 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/989c41f37f0001015ee819a84c18ca80 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/989c41f47f0001015ee819a869e77b53 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/989c41f47f0001015ee819a82998ee58 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/989c41f47f0001015ee819a8dac3e9ea #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fce5c77f000101663ffd147c8b9e26 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/99b647627f00010103d66c396d43c345 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/99b647627f00010103d66c39e7b3a569 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> the Status and StatusList objects doesn't carry any usage information other than the name </p> </body> </html> #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/99b647637f00010103d66c3913f91d93 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/99b647637f00010103d66c39798b7d69 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> A project StatusList may have these status objects </p> <p style="color: #000000" color="#000000"> </p> <p style="color: #000000" color="#000000"> * WaitingToStart </p> <p style="color: #000000" color="#000000"> * In Progress </p> <p style="color: #000000" color="#000000"> * Stopped </p> <p style="color: #000000" color="#000000"> * Completed </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/99b647647f00010103d66c39554a3237 2542 2540 #FDE888 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/99b647647f00010103d66c3971f65221 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/99b647647f00010103d66c39b6f57f91 2536 2542 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/99b647657f00010103d66c391ab244f1 #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/99b647657f00010103d66c39a31ed307 2540 2545 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/99b647657f00010103d66c3962c652da #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/99b647657f00010103d66c39f018504b 2545 2547 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/99b647667f00010103d66c3963aa548b #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/99b647667f00010103d66c39cc52cbaf 2547 2550 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/99b647667f00010103d66c39bd63898b #000000 #000000 Arial-plain-11 http://vue.tufts.edu/rdf/resource/99b647677f00010103d66c39516f6584 2550 2552 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/9ed04b217f000101584edf4a532914e6 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9ed04b227f000101584edf4adc4f16e1 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9ed04b227f000101584edf4ac287ddd0 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/9ed0d73a7f000101584edf4aec9c5bc9 1396 3268 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/9ed0d73a7f000101584edf4a0d1e47f0 1355 3268 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad487f000101584edf4a94fcff70 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad487f000101584edf4ae72ab318 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad497f000101584edf4ad849b8ad #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad497f000101584edf4a3bff7252 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad497f000101584edf4ab7894019 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad497f000101584edf4acff2ae90 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad497f000101584edf4a1105aa98 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4aeaffa9cd #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4a3be63e9c #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4a6202ee85 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4a8c4e4827 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4a026ab232 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4a7f000101584edf4a8be2ca88 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4b7f000101584edf4a57f6e798 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4b7f000101584edf4abdcb2014 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4b7f000101584edf4a9c160278 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4b7f000101584edf4a8f7bf7ed #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4aa59d18f5 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4a9920537a #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4a2e38037c #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4a31e3a548 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4a5dc9cb23 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4c7f000101584edf4a6a5f9529 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4d7f000101584edf4a28f3414f #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4d7f000101584edf4a684a1372 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4d7f000101584edf4a0f42ffd2 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a090ad4d7f000101584edf4ae3d8db49 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a090ad4d7f000101584edf4a6e4c0543 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a3e3fcb37f000101584edf4a6c183c36 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a3e3fcb37f000101584edf4ab9417f26 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a3e3fcb37f000101584edf4af1431523 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/a3e3fcb47f000101584edf4a6af13a2b 1295 3308 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/a3e3fcb47f000101584edf4aefd4d6f5 1887 3308 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/a8ba8fd57f000101584edf4a096af17f <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Secondary tables and columns </p> </body> </html> #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a8ba8fd57f000101584edf4a3546060b #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b88461ff7f0001017e6ab533d4503f36 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2e1996b7f0001013611c755eb7ed3ec #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21ec47f000101663ffd1411756b19 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8c717f000101663ffd14fa7d5a3c #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8d52ad37f0001017e6ab533649e6f1a #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8d52ad37f0001017e6ab5334b256314 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21ec57f000101663ffd14e6bf6992 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8c767f000101663ffd141afe5d40 #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/3624ae91c0a8000435fa8379e0b02f97 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b892c8087f0001017e6ab53319adbe2b #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b892c8087f0001017e6ab53369965690 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b892c8097f0001017e6ab5339330d0c8 #EA2218 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b89461017f0001017e6ab533c77dd91b #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b89461027f0001017e6ab533d3d5b3b9 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b89461027f0001017e6ab533421c6a11 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b8a7d59e7f0001017e6ab5334de98edf #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/360402fc7f000101663ffd14d8ec2158 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/c2bbd0437f0001013611c755eeb1c974 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8a7d59e7f0001017e6ab5339706b727 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/b8a7d59e7f0001017e6ab5334b036143 3382 3372 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/b8a8a4607f0001017e6ab533617f8ad0 1887 3393 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/ba3bf5b37f0001017e6ab5338b9ff6a2 3372 1901 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/c2e990f77f0001013611c75558b3d989 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/13a3d6fc7f0001017490649e5a37d165 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/bd122965c0a82a4a01152d0eff7dbcbe #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/bd122965c0a82a4a01152d0e8ff34d55 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/bd122965c0a82a4a01152d0ee270ee7f #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2cf5c0a82a4a019efdbd112abd0a #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/e8a539b8648c6197658d749ac2c528ef #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2cf5c0a82a4a019efdbdb69fc72f #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/e8a539b8648c6197658d749a4719b74d #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2cf6c0a82a4a019efdbd9323b8c2 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/13a3d6fc7f0001017490649eaefc5577 #30D643 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33e78f547f0001016203171e285ee458 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/33e78f547f0001016203171e3badff02 <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="text-align: center; color: #000000" color="#000000"> Just add another parameter called &quot;taget_entity_type&quot; to the StatusLits object, </p> <p style="text-align: center; color: #000000" color="#000000"> and use it when assigning to other objects to check if it is compatible with the StatusList </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33e78f557f0001016203171e4e51f849 2545 3493 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33e78f557f0001016203171ee3b835b9 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb187f0001016203171e914499e4 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb187f0001016203171eb92d4790 3501 3523 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb257f0001016203171eda0f1c6e #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb257f0001016203171e7277c051 3527 3525 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb257f0001016203171ef4f135a1 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb257f0001016203171ec0f120f3 3523 3527 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb267f0001016203171e0dce531a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb267f0001016203171e019e7b69 3527 3529 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb267f0001016203171ed809bcbd #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb267f0001016203171ed634beb3 3525 3531 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb277f0001016203171e4fc43b50 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb277f0001016203171ed315cddc 3525 3533 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fbcb277f0001016203171e807ae3dd #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/33fbcb277f0001016203171e88a8737b 3533 3536 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/340fb3a07f0001016203171e0b84f385 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/340fb3a17f0001016203171ee96598cc 3529 3538 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/340fb3a17f0001016203171efaeeb79b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/340fb3a17f0001016203171e5fdad64e #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/340fb3a17f0001016203171e87530ff3 3540 3541 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/340fb3a17f0001016203171e05062db5 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/340fb3a27f0001016203171ee4b4bb51 3541 3543 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/570552607f0001016f0e090494a33c8f <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Non-implemented attributes </p> </body> </html> #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/570552617f0001016f0e09045f703f7f #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/9a259fb57f0001010772fbca6a52863b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9a259fb67f0001010772fbcafae74713 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/ebf7261b7f0001012e9d478bae99d2a2 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/ebf7261b7f0001012e9d478b95c14742 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/06dc9a487f000101049ada8600a11492 1887 2397 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/1ef854967f000101578cef183082fb96 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1ef854967f000101578cef1825e67a00 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/1ef854967f000101578cef184d47f17e 3615 3620 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1ef854967f000101578cef183f94113a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/1ef854977f000101578cef18f1f525b9 3620 3622 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1ef854977f000101578cef1889e1b257 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/1ef854977f000101578cef18b1a0a07f 3622 3624 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1ef854977f000101578cef18dd82dd5a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/1ef854977f000101578cef18a1f46511 3624 3626 #B5B995 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/3de32a157f0001010892949f5a269b82 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a167f0001010892949fd04bdf89 #FCDBD9 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a167f0001010892949f8dd61baf #FEFD8C #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a227f0001010892949f64b52421 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a227f0001010892949ff8a4b051 3638 3639 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a237f0001010892949f22be8b46 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a237f0001010892949fe0834314 3639 3641 #FEFD8C #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a247f0001010892949f1625e7bd #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a247f0001010892949fd1aa4890 3637 3643 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a257f0001010892949f7cb2b9f1 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a257f0001010892949f67f3b25a 3643 3645 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a267f0001010892949fcba370c0 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a267f0001010892949f5c46788f 3643 3647 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a277f0001010892949f8996f92c #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a277f0001010892949f2fb8309b 3643 3649 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a287f0001010892949f44fa5d31 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a287f0001010892949fa01e4444 3643 3651 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a297f0001010892949ff4d93d34 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a297f0001010892949fa023f701 3639 3653 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a2a7f0001010892949f44b54c39 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a2a7f0001010892949fb8a36c74 3639 3655 #9DDB53 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3de32a2a7f0001010892949febaf0c39 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3de32a2b7f0001010892949f2e76a112 3639 3657 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/79af23367f0001017997f53852f91300 1887 1402 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/7a3132fe7f0001017997f538a8ecadd7 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7a3132ff7f0001017997f538ca751ab1 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f58e5f87f0001011df07586553401a4 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7a31df207f0001017997f538762e76d7 3669 1402 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/7a31df207f0001017997f538fcd5851b 3669 1402 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/842e31877f0001015d3258770684f65b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/842e31877f0001015d32587797aa8b4f #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/842e31877f0001015d32587701245b1b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/842e31887f0001015d325877a276acf6 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/842e31887f0001015d3258776ba432ce #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/842e31887f0001015d325877eaa6dd71 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/842e31887f0001015d325877f78ac9f5 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/842e31887f0001015d3258774eee4aba 3689 3695 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a4e147e67f0001015c382de564be1104 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a4e147e77f0001015c382de55503d112 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a4e147e87f0001015c382de59322ab1e #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a4e147e87f0001015c382de54a61be40 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/cde1332d7f0001011f758c30b7215c18 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/cde1332e7f0001011f758c30a99bec06 1166 3718 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d2e9e3e57f0001011f758c305fb99f9c #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d2e9e3e57f0001011f758c30c1da54d4 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d2e9e3e57f0001011f758c309827a705 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/d2e9e3e57f0001011f758c30cd7fa2ed #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/d2e9e3e67f0001011f758c30307e5d6e 3720 3725 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/d2e9e3e67f0001011f758c30a9bc47e0 3720 3723 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/d2e9e3e67f0001011f758c30f0fdbd62 3720 3721 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76207f0001014f9078ed4e765585 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76207f0001014f9078ed1d81a9f5 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76217f0001014f9078ed56d6b831 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76217f0001014f9078ede683b13e #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76217f0001014f9078ed28a8095a #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/ed9b76217f0001014f9078edc617eb94 3737 3734 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/ed9b76227f0001014f9078ede397f58b 3741 3737 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/ed9b76227f0001014f9078ed41e7d3ff #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/ed9b76227f0001014f9078ed447ef19a 3734 3741 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/edafea3d7f0001014f9078ed989a19b5 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/edafea3d7f0001014f9078edca304b53 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Declarative SOM (Completed with Tests) </p> </body> </html> #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/208db6a77f000101264d107e132cc774 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/208db6a77f000101264d107ea800a149 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> strictly_typed </p> </body> </html> #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b8701a037f00010133854518f97ed189 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b8701a047f000101338545189002cbe7 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b8701a047f00010133854518c82767d8 #D4FF00 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33c552027f0001017e6d32099d199fb8 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/33c552037f0001017e6d320901980c9c <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Declarative SOM (Started or Partially moved) </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3ed254137f0001017e6d320990e92d47 1887 1166 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3ed254137f0001017e6d3209f4c83f0b 1205 1176 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3ed254137f0001017e6d32091356ed17 1205 1185 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3ed254147f0001017e6d32092876fa0e 1205 1088 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/9323191a7f0001010820c09b268bea70 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9323191c7f0001010820c09b48ba2648 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9323191c7f0001010820c09b3fdbd157 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/9323191d7f0001010820c09b3d11ba97 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/9323191e7f0001010820c09b0d9f2466 1887 3815 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a1ac43f57f0001010820c09b897a0e17 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a1ac43f67f0001010820c09ba9d40681 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a1ac43f77f0001010820c09b3959f466 #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d207f000101663ffd1486c76cc8 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d217f000101663ffd14e3cb886c #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d237f000101663ffd14a0656966 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d247f000101663ffd1482254dd5 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d267f000101663ffd1401ed83d8 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d267f000101663ffd140a50984d #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d277f000101663ffd14c571ff64 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d287f000101663ffd140a12fddf #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d297f000101663ffd14556b3698 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d2a7f000101663ffd14e671d93e #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d2b7f000101663ffd14fa90b1e9 #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d2d7f000101663ffd144c7d1f8a #ECFFD4 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d2e7f000101663ffd1499fddc91 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d2f7f000101663ffd1440611e1b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d307f000101663ffd14fe9a5dcc #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d317f000101663ffd14fbffc76b #FDE888 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d317f000101663ffd149a6733df #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d337f000101663ffd141a200f3e #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d337f000101663ffd14d4a75b5e #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d347f000101663ffd146f3c629b #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d367f000101663ffd14d3f7c399 3846 3851 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d377f000101663ffd140888ec46 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d397f000101663ffd14395cbf10 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d3a7f000101663ffd141c8fb707 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d3b7f000101663ffd142dce9439 3846 3851 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d3c7f000101663ffd148f53f000 3846 3855 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d3c7f000101663ffd14e4057a6e 3834 3855 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d3d7f000101663ffd146e1c7fd0 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d3e7f000101663ffd1434ff22cf #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d3f7f000101663ffd14ab17b17b #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d3f7f000101663ffd14de61dc8e 3834 3861 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d407f000101663ffd14dc0170fa 3834 3861 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d417f000101663ffd14c9ff9af0 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d427f000101663ffd144441696d #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35fa8d427f000101663ffd14b37b7d18 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d447f000101663ffd14034c742d 3842 3866 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d467f000101663ffd14b3fb0bee #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d467f000101663ffd14eb6f869b #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d477f000101663ffd143040c34c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d477f000101663ffd14ebf5331e #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d497f000101663ffd145356577c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3680755a7f000101663ffd145d8f6033 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d4a7f000101663ffd14b7708354 3345 3879 #8AEE95 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d4b7f000101663ffd141146400f #8AEE95 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d4b7f000101663ffd142adf3f72 #8AEE95 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/35fa8d4c7f000101663ffd14b437adda #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d4d7f000101663ffd144d4a9602 3345 3893 #EA2218 #776D6D #000000 Arial-bold-18 http://vue.tufts.edu/rdf/resource/3604033f7f000101663ffd145b469f06 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/360403407f000101663ffd1440a0efe3 3638 3910 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/363d47ef7f000101663ffd147aedb6bd 1887 3345 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9817f000101663ffd147e48304b #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9837f000101663ffd145a0469c0 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9827f000101663ffd144fb9e0f6 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9827f000101663ffd14e8afaa2a #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9827f000101663ffd144ceb272f #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/3678e9817f000101663ffd149b7e6993 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/3678e9837f000101663ffd14af5fb80b 3345 3921 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21ef87f000101663ffd1411b60466 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21ef87f000101663ffd143ee68814 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21ef87f000101663ffd141c2db82d #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/3624aeb3c0a8000435fa8379e276ea76 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21ef97f000101663ffd1446677ed4 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21ef97f000101663ffd147086af45 #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21ef97f000101663ffd14f97322da #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21ef97f000101663ffd14c8ebee7d #E4E6D2 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21efa7f000101663ffd145b9140d4 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/36a21efa7f000101663ffd14cef1077e <html> <head style="color: #000000" color="#000000"> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> http://trac.edgewall.org/wiki/TracWorkflow </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/36a21efb7f000101663ffd1453cee0ae 3933 3941 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/36a21efb7f000101663ffd1490b7da7d 1901 3933 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/36a21efb7f000101663ffd14e8a3c2c0 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21efb7f000101663ffd14a24b7fb3 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/36a21efc7f000101663ffd14dc43d8ba #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/36a21efc7f000101663ffd14e1a098c4 3345 3952 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/36a21efc7f000101663ffd14b48e38a5 3345 3952 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5f526b557f0001011df075868f9d004d #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5f526b567f0001011df0758616c29082 3959 1901 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5f526b567f0001011df0758654c54329 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f526b577f0001011df07586a16bd21d #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f526b587f0001011df07586479df966 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5f526b597f0001011df07586e10ce581 3959 3974 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5f526b5a7f0001011df075861aec2f3a #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f526b5b7f0001011df07586b2951812 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f526b5c7f0001011df07586e0220ae9 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5f526b5c7f0001011df07586103a1a93 3959 3978 #F4E5FF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/5f58e6257f0001011df0758673c76d34 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f58e6267f0001011df07586c5448109 #F4E5FF #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/5f58e6267f0001011df07586a9836027 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5f58e6277f0001011df07586777f0913 3983 1235 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/5f58e6277f0001011df07586feb3e052 3983 1235 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fb2067c0a8000435fa8379ff5e5c4b #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/33fb2068c0a8000435fa8379dcbb914c #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/33fb2068c0a8000435fa8379b118788f #30D643 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8af4ac0a8000435fa8379819ac38b #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/35c8af4ac0a8000435fa83793900fa23 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> __auto_name__ True </p> </body> </html> #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/35c8af4bc0a8000435fa837996a96163 #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/35c8af4bc0a8000435fa83797a0999cc <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> __auto_name__ False </p> </body> </html> #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/35fa8d457f000101663ffd14cd1b4218 3834 3866 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b118a18d4a976c7d56f44fb9c5dca92c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b118a18e4a976c7d56f44fb9e3c74e42 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/b118a18e4a976c7d56f44fb94262c610 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/b118a18f4a976c7d56f44fb9bbe83150 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310bf1d61c1f706111f5d817e32c0 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c01d61c1f706111f5d68aee756 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/10349fb61d61c1f706111f5da867a161 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c41d61c1f706111f5de62954c5 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/1035292f1d61c1f706111f5d98bf7781 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c11d61c1f706111f5dbb37702f #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c21d61c1f706111f5d381d3a8c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c21d61c1f706111f5d370ce97a #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c21d61c1f706111f5d668f6479 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c31d61c1f706111f5ddfaa481b #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c31d61c1f706111f5df5b70542 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c41d61c1f706111f5d83a80721 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c41d61c1f706111f5d1302ab7d #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/103310c51d61c1f706111f5d43894860 1887 4092 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c51d61c1f706111f5dadc12882 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c51d61c1f706111f5d1c0e0a66 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c61d61c1f706111f5dc481d994 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c61d61c1f706111f5dd4376894 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c61d61c1f706111f5d2faedd65 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/103310c71d61c1f706111f5dfad5796d #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c71d61c1f706111f5d1df97965 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/103310c71d61c1f706111f5d1fa3fa02 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/103310c81d61c1f706111f5d46f498e5 4107 4111 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/10349fba1d61c1f706111f5de58b0d92 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/10349fba1d61c1f706111f5dd49712cc #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/10367d161d61c1f706111f5d5f945c46 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/10367d161d61c1f706111f5df4afa051 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/10367d161d61c1f706111f5df47d61a6 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/10349fba1d61c1f706111f5d52b2e31e #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a8223faca62df9af491657517acecb2b #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a8223faca62df9af491657512cf9eb8b #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/a8223fada62df9af491657510ea3a2ed #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a8223fada62df9af49165751d2e649e2 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a8223faea62df9af49165751a44b4527 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/a8223faea62df9af49165751cba15620 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/a8223fafa62df9af49165751e9b56c77 1887 4127 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/efe0076bc8211d50048a37431a1c1633 #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/efe0076bc8211d50048a3743cecdca2f #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/efe0076cc8211d50048a374306e29274 #FEFB03 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/efe0076cc8211d50048a3743133d5bd2 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/efe0076cc8211d50048a374340c4fe92 #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbffc0a8000f011bc52fd9c4fed2 #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffbffc0a8000f011bc52f22a80a94 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/efe0076cc8211d50048a37434fdaa095 1901 4159 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/757fa42fc0a8004336c48f760e6c52fc #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/757fa42fc0a8004336c48f762d6b6b6d #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/757fa430c0a8004336c48f7684388152 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/757fa431c0a8004336c48f76121a87ec #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/757fa431c0a8004336c48f76ea9e7e28 #F2AE45 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/7581cfb5c0a8004336c48f7613cb7500 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/757fa430c0a8004336c48f76e74e4757 1145 4170 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/bce315cdc0a82a4a01152d0e90f705d8 #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/bce315cdc0a82a4a01152d0ebdecdd7d #EA2218 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/bce315cec0a82a4a01152d0e48b05e0b #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/bce315cec0a82a4a01152d0e6dc206c0 1887 4191 #33A8F5 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffc00c0a8000f011bc52f338782cf #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/f28ffc00c0a8000f011bc52f7f3ff996 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Implmented as RESTFul Service with unit tests </p> </body> </html> #AF55F4 #776D6D #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/f28ffc01c0a8000f011bc52f168f3afb #404040 #000000 SansSerif-plain-14 http://vue.tufts.edu/rdf/resource/f28ffc01c0a8000f011bc52fd0608bf1 <html> <head> <style type="text/css"> <!-- body { font-family: Arial; margin-bottom: 0px; color: #000000; margin-top: 0px; font-size: 12; margin-right: 0px; margin-left: 0px } ol { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } p { margin-bottom: 0; color: #000000; margin-top: 0; margin-right: 0; margin-left: 0 } ul { font-family: Arial; list-style-position: outside; margin-top: 6; font-size: 12; margin-left: 30; vertical-align: middle } --> </style> </head> <body> <p style="color: #000000" color="#000000"> Implemented as RESTFul Service with functional tests </p> </body> </html> #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd4499f5e0 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbdd74739f1 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbdee3e5fcf #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d15c0a82a4a019efdbd580043fc #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbdb898570c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbd49bb9763 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbdb9a3c36c #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbd21ac06fb #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbdcdc4ddd0 #FEFB03 #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbd8801c064 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbda9b85014 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbdd0362e2b #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d16c0a82a4a019efdbd7680eb26 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbd0eab4c71 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbdf8cdb703 #F2AE45 #7F7F7F #000000 Arial-plain-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbd85fb5607 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbd5c065e28 #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbd9812cb2e #83CEFF #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbde484861f #FF66CC #776D6D #000000 Arial-bold-12 http://vue.tufts.edu/rdf/resource/97ae2d17c0a82a4a019efdbde98bd4d5 #000000 #000000 Arial-bold-11 http://vue.tufts.edu/rdf/resource/97b7c5fac0a82a4a01081779ad8219f2 1887 4225 http://vue.tufts.edu/rdf/resource/5d104b32c00007d601b277f026b72230 1.2251885318196525 #202020 2009-05-20 6 C:\Users\eoyilmaz\Documents\development\stalker\stalker\docs\source\_static\images C:\Users\eoyilmaz\Documents\development\stalker\stalker\docs\source\_static\images\stalker_design.vue ================================================ FILE: docs/source/_templates/autosummary/base.rst ================================================ {{ fullname }} {{ underline }} .. currentmodule:: {{ module }} .. auto{{ objtype }}:: {{ objname }} ================================================ FILE: docs/source/_templates/autosummary/class.rst ================================================ {{ fullname }} {{ underline }} .. inheritance-diagram:: {{ fullname }} :parts: 1 .. currentmodule:: {{ module }} .. autoclass:: {{ objname }} :show-inheritance: :inherited-members: {% block methods %} .. automethod:: __init__ {% if methods %} .. rubric:: Methods .. autosummary:: {% for item in methods %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} {% block attributes %} {% if attributes %} .. rubric:: Attributes .. autosummary:: {% for item in attributes %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/source/_templates/autosummary/module.rst ================================================ {{ fullname }} {{ underline }} .. automodule:: {{ fullname }} {% block functions %} {% if functions %} .. rubric:: Functions .. autosummary:: {% for item in functions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block classes %} {% if classes %} .. rubric:: Classes .. autosummary:: {% for item in classes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} {% block exceptions %} {% if exceptions %} .. rubric:: Exceptions .. autosummary:: {% for item in exceptions %} {{ item }} {%- endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/source/about.rst ================================================ .. about_toplevel: .. include:: source/../../../README.rst ================================================ FILE: docs/source/changelog.rst ================================================ .. _changelog_toplevel: .. include:: source/../../../CHANGELOG.rst ================================================ FILE: docs/source/conf.py ================================================ # -*- coding: utf-8 -*- # # Stalker documentation build configuration file, created by # sphinx-quickstart on Tue Jul 26 20:41:01 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. import os import sys # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx.ext.graphviz", "sphinx.ext.inheritance_diagram", # 'sphinx.ext.imgmath', "sphinx.ext.mathjax", "sphinx.ext.ifconfig", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] # source_suffix = ".rst" # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "Stalker" copyright = "2009-2024, Erkan Ozgur Yilmaz" author = "Erkan Ozgur Yilmaz" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. # find stalkers path dirName = os.path.dirname(__file__) modulePath = os.path.sep.join(dirName.split(os.path.sep)[:-2]) sys.path.append(modulePath) import stalker version = stalker.__version__ # The full version, including alpha/beta/rc tags. release = stalker.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = 'en' # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["build", "templates"] # The reST default role (used for this markup: `text`) to use for all # documents. # # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'default' # html_theme = 'scrolls' # html_theme = 'agogo' # html_theme = 'sphinxdoc' html_theme = "furo" # html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. # " v documentation" by default. # # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' # # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = "Stalkerdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # "papersize": "a4paper", # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ( "contents", "Stalker.tex", "Stalker Documentation", "Erkan Ozgur Yilmaz", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Additional stuff for the LaTeX preamble. # # latex_preamble = '\setcounter{tocdepth}{3}' # Documents to append as an appendix to all manuals. # # latex_appendices = [] # It false, will not define \strong, \code, itleref, \crossref ... but only # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added # packages. # # latex_keep_old_macro_names = True # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "stalker", "Stalker Documentation", [author], 1)] # If true, show URL addresses after external links. # # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "Stalker", "Stalker Documentation", author, "Stalker", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False # -- Options for Epub output ---------------------------------------------- # Bibliographic Dublin Core info. epub_title = "Stalker" epub_author = "Erkan Ozgur Yilmaz" epub_publisher = "Erkan Ozgur Yilmaz" epub_copyright = "2014, Erkan Ozgur Yilmaz" # The basename for the epub file. It defaults to the project name. # epub_basename = u'Stalker' # The HTML theme for the epub output. Since the default themes are not optimized # for small screen space, using the same theme for HTML and epub output is # usually not wise. This defaults to 'epub', a theme designed to save visual # space. # epub_theme = 'epub' # The language of the text. It defaults to the language option # or en if the language is not set. # epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. # epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. # epub_identifier = '' # A unique identification for the text. # epub_uid = '' # A tuple containing the cover image and cover page html template filenames. # epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. # epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_pre_files = [] # HTML files shat should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. # epub_post_files = [] # A list of files that should not be packed into the epub file. # epub_exclude_files = [] # The depth of the table of contents in toc.ncx. # epub_tocdepth = 3 # Allow duplicate toc entries. # epub_tocdup = True # Choose between 'default' and 'includehidden'. # epub_tocscope = 'default' # Fix unsupported image types using the PIL. # epub_fix_images = False # Scale large images. # epub_max_image_width = 0 # How to display URL addresses: 'footnote', 'no', or 'inline'. # epub_show_urls = 'inline' # If false, no index is generated. # epub_use_index = True # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { "python": ("https://docs.python.org/", None), } autosummary_generate = True autodoc_member_order = "bysource" def setup(app): app.add_object_type( "confval", "confval", objname="configuration value", indextemplate="pair: %s; configuration value", ) # # this next two lines are for Sphinx 1.2 to work # import sqlalchemy.ext.declarative.api # from stalker.db.declarative import Base # sqlalchemy.ext.declarative.api.Base = Base ================================================ FILE: docs/source/configure.rst ================================================ .. _configuration_toplevel: .. _configuring_stalker: Configuring Stalker =================== To configure Stalker and make it fit to your Studios need you should use the ``config.py`` file as mentioned in next sections. config.py File -------------- Stalker uses the ``config.py`` to let one to customize the system config. The ``config.py`` file is searched in a couple of places through the system: * under "~/.strc/" directory (not yet) * under "$STALKER_PATH" The first path is a folder in the users home dir. The second one is a path defined by the ``STALKER_PATH`` environment variable. Defining the ``config.py`` by using the environment variable gives the most customizable and consistent setup through the studio. You can set ``STALKER_PATH`` to a shared folder in your fileserver where all the users can access. Because, ``config.py`` is a regular Python code which is executed by Stalker, you can do anything you were doing in a normal Python script. This is very handy (also dangerous!) if you have another source of information which is reachable by a Python script. If there is no ``STALKER_PATH`` variable in your current environment or it is not showing an existing path or there is no ``config.py`` file the system will use the system defaults. Config Variables ---------------- Variables which can be set in ``config.py`` are as follows: .. confval:: actions Actions for authorization system. These are used to create ACLs. Stalker uses `CRUDL`_ system. Default value is:: actions = ['Create', 'Read', 'Update', 'Delete', 'List'] #CRUDL .. _CRUDL: http://en.wikipedia.org/wiki/Create,_read,_update_and_delete .. confval:: auto_create_admin Tells Stalker to create an admin by default. Default value is:: auto_create_admin = True .. confval:: admin_name The default admin user name. Default value is:: admin_name = 'admin' .. confval:: admin_login The default admin login. Default value is:: admin_login = 'admin' .. confval:: admin_password The default admin password. Default value is:: admin_password = 'admin' .. confval:: admin_email The default email for admin user. Default value is:: admin_email = 'admin@admin.com' .. confval:: admin_department_name The default department name for admin. Default value is:: admin_department_name = 'admins' .. confval:: admin_group_name The default admin permission group name. Default value is:: admin_group_name = 'admins' .. confval:: database_engine_settings A dictionary of config values. The default value is:: database_engine_settings = { "sqlalchemy.url": "sqlite:///:memory:", "sqlalchemy.echo": False, } .. confval:: database_session_settings This value is not used. .. confval:: local_storage_path The local storage path for Stalker. local_storage_path = os.path.expanduser('~/.strc') .. confval:: local_session_data_file_name The per user or local session file name. It is used for storing logged in user info. The default value is:: local_session_data_file_name = 'local_session_data' .. confval:: server_side_storage_path Storage for uploaded files. This used by `Stalker Pyramid`_ and shows the server side storage path. Will be moved to Stalker Pyramid in later versions. Not used by Stalker by default. Default value is:: server_side_storage_path = os.path.expanduser('~/Stalker_Storage') .. _`Stalker Pyramid`: https://pypi.python.org/pypi/stalker_pyramid .. confval:: key The default keyword which is going to be used in password scrambling. Default value is:: key = "stalker_default_key" .. confval:: version_variant_name The default variant name for :class:`~stalker.models.version.Version` instances. Default value is:: version_variant_name = "Main" .. confval:: status_bg_color Default background color for :class:`~stalker.models.status.Status` instances. Default value is:: status_bg_color = 0xffffff .. confval:: status_fg_color Default foreground color for :class:`~stalker.models.status.Status` instances. Default value is:: status_fg_color = 0x000000 .. confval:: ticket_label Default ticket label. Used by :class:`~stalker.models.ticket.Ticket` when generating a ticket name. Default value is:: ticket_label = "Ticket" .. confval:: ticket_status_order Defines the ticket statuses and the order of them. Default value is:: ticket_status_order = [ 'new', 'accepted', 'assigned', 'reopened', 'closed' ] .. confval:: ticket_resolutions Defines the default ticket resolutions. Default value is:: ticket_resolutions = [ 'fixed', 'invalid', 'wontfix', 'duplicate', 'worksforme', 'cantfix' ] .. confval:: ticket_workflow Defines the default ticket workflow. It is a dictionary of actions. Shows the new status per action. Default value is:: ticket_workflow = { 'resolve' : { 'new': { 'new_status': 'closed', 'action': 'set_resolution' }, 'accepted': { 'new_status': 'closed', 'action': 'set_resolution' }, 'assigned': { 'new_status': 'closed', 'action': 'set_resolution' }, 'reopened': { 'new_status': 'closed', 'action': 'set_resolution' }, }, 'accept' : { 'new': { 'new_status': 'accepted', 'action': 'set_owner' }, 'accepted': { 'new_status': 'accepted', 'action': 'set_owner' }, 'assigned': { 'new_status': 'accepted', 'action': 'set_owner' }, 'reopened': { 'new_status': 'accepted', 'action': 'set_owner' }, }, 'reassign': { 'new': { 'new_status': 'assigned', 'action': 'set_owner' }, 'accepted': { 'new_status': 'assigned', 'action': 'set_owner' }, 'assigned': { 'new_status': 'assigned', 'action': 'set_owner' }, 'reopened': { 'new_status': 'assigned', 'action': 'set_owner' }, }, 'reopen': { 'closed': { 'new_status': 'reopened', 'action': 'del_resolution' } } } .. confval:: timing_resolution Defines the default timing resolution for classes which are mixed with :class:`~stalker.models.mixins.DateRangeMixin`\ . Stalker uses the TaskJuggler default timing resolution which is 1 hour:: timing_resolution = datetime.timedelta(hours=1) .. confval:: task_duration Defines the default task duration. If only a start or end value is entered for a :class:`~stalker.models.task.Task` then Stalker calculates the other value by adding or subtracting the default task duration value from it. Default value is 1 hour:: task_duration = datetime.timedelta(hours=1) .. confval:: task_priority Defines the default task priority. This is used by TaskJuggler to prioritize tasks. Should be a number between 0 and 1000. Default value is 500:: task_priority = 500 .. confval:: working_hours Defines the default weekly working hours per week day. Stalker uses the TaskJuggler default value of 9am to 6pm. The values entered are minutes from midnight, and it is a list of lists of two integers. Each list of two integers shows a working hour interval. Default value is:: working_hours = { 'mon': [[540, 1080]], # 9:00 - 18:00 'tue': [[540, 1080]], # 9:00 - 18:00 'wed': [[540, 1080]], # 9:00 - 18:00 'thu': [[540, 1080]], # 9:00 - 18:00 'fri': [[540, 1080]], # 9:00 - 18:00 'sat': [], # saturday off 'sun': [], # sunday off } .. confval:: daily_working_hours Defines the default daily working hour. This is strongly related with the ``working_hours``, ``weekly_working_hours``, ``weekly_working_days`` and ``yearly_working_days`` settings and shows a mean value of daily working hour. Default value is 9:: daily_working_hours = 9 .. confval:: weekly_working_hours Defines the default weekly working hour. This is strongly related with the ``working_hours``, ``daily_working_hours``, ``weekly_working_days`` and ``yearly_working_days`` settings. Default value is 45:: weekly_working_hours = 45 .. confval:: weekly_working_days Defines the default weekly working days. This is strongly related with the ``working_hours``, ``daily_working_hours``, ``weekly_working_hours`` and ``yearly_working_days`` settings. Default value is 5:: weekly_working_days = 5 .. confval:: yearly_working_days Defines the default yearly working days. This is strongly related with the ``working_hours``, ``daily_working_hours``, ``weekly_working_hours`` and ``weekly_working_days`` settings. Default value is 260.714 which equals ``weekly_working_days`` * 52.1428:: yearly_working_days = 260.714 .. confval:: day_order Defines the order of the week days. Default value uses European system:: day_order = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] .. confval:: datetime_units Defines the date and time units. The order should match the ``datetime_unit_names`` setting. Default value is:: datetime_units = ['min', 'h', 'd', 'w', 'm', 'y'] .. confval:: datetime_unit_names Defines the names of date and time units. The order should match the ``datetime_units`` setting. Default value is:: datetime_unit_names = ['minute', 'hour', 'day', 'week', 'month', 'year'] .. confval:: datetime_units_to_timedelta_kwargs Defines the conversion ratios of each date and time unit. Default value is:: datetime_units_to_timedelta_kwargs = { 'min': {'name': 'minutes', 'multiplier': 1}, 'h' : {'name': 'hours' , 'multiplier': 1}, 'd' : {'name': 'days' , 'multiplier': 1}, 'w' : {'name': 'weeks' , 'multiplier': 1}, 'm' : {'name': 'days' , 'multiplier': 30}, 'y' : {'name': 'days' , 'multiplier': 365} } .. confval:: task_schedule_models Defines the default schedule models. These are highly related with TaskJuggler, so anything entered here should exist in TaskJuggler. Default value is:: task_schedule_models = ['effort', 'length', 'duration'] .. confval:: task_schedule_constraints Defines the default schedule constraints. The order also defines a binary number corresponding to each value (00: none, 01: start, 10:end, 11:both) and used in defining which side of a Task is constrained to a date. Also used by TaskJuggler to constrain the start or end or both dates of a task to a certain date. Also a Task with schedule_constraint is set to 2 (both) is considered a **duration** task even if its schedule_model is set to **effort** or **length**. Default value is:: task_schedule_constraints = ['none', 'start', 'end', 'both'] .. confval:: tjp_working_hours_template Defines a Jinja2 template for converting :class:`~stalker.models.studio.WorkingHours` instances to a TaskJuggler compatible string. By default Stalker converts a WorkingHours instance to a ``workinghours`` statement in TaskJuggler. Default value is:: tjp_working_hours_template = """{% macro wh(wh, day) -%} {%- if wh[day]|length %} workinghours {{day}} {% for part in wh[day] -%} {%- if loop.index != 1%}, {% endif -%} {{"%02d"|format(part[0]//60)}}:{{"%02d"|format(part[0]%60)}} - {{"%02d"|format(part[1]//60)}}:{{"%02d"|format(part[1]%60)}} {%- endfor -%} {%- else %} workinghours {{day}} off {%- endif -%} {%- endmacro -%} {{wh(workinghours, 'mon')}} {{wh(workinghours, 'tue')}} {{wh(workinghours, 'wed')}} {{wh(workinghours, 'thu')}} {{wh(workinghours, 'fri')}} {{wh(workinghours, 'sat')}} {{wh(workinghours, 'sun')}}""" .. confval:: tjp_studio_template Defines a Jinja2 template for converting a :class:`~stalker.models.studio.Studio` instance to a TaskJuggler compatible string. By default Stalker converts a Studio instance to a ``project`` statement in TaskJuggler. Default value is:: tjp_studio_template = """project {{ studio.tjp_id }} "{{ studio.name }}" {{ studio.start.date() }} - {{ studio.end.date() }} { timingresolution {{ '%i'|format((studio.timing_resolution.days * 86400 + studio.timing_resolution.seconds)//60|int) }}min now {{ studio.now.strftime('%Y-%m-%d-%H:%M') }} dailyworkinghours {{ studio.daily_working_hours }} weekstartsmonday {{ studio.working_hours.to_tjp }} timeformat "%Y-%m-%d" scenario plan "Plan" trackingscenario plan } """ .. confval:: tjp_project_template Defines a Jinja2 template for converting a :class:`~stalker.models.project.Project` instance to a TaskJuggler compatible string. By default Stalker converts a Project instance to a ``task`` statement in TaskJuggler. Default value is:: tjp_project_template = """task {{project.tjp_id}} "{{project.name}}" { {% for task in project.root_tasks %} {{task.to_tjp}} {% endfor %} } """ .. confval:: tjp_task_template Defines a Jinja2 template for converting a :class:`~stalker.models.task.Task` instance to a TaskJuggler compatible string. By default Stalker converts a Task to a ``task`` statement in TaskJuggler. Default value is:: tjp_task_template = """task {{task.tjp_id}} "{{task.name}}" { {% if task.priority != 500 -%}priority {{task.priority}}{%- endif %} {%- if task.depends_on %} depends_on {% for depends_on in task.depends_on %} {%- if loop.index != 1 %}, {% endif %}{{depends_on.tjp_abs_id}} {%- endfor -%} {%- endif -%} {%- if task.is_container -%} {%- for child_task in task.children %} {{ child_task.to_tjp }} {%- endfor %} {%- else %} {% if task.resources|length -%} {% if task.schedule_constraint %} {%- if task.schedule_constraint == 1 or task.schedule_constraint == 3 -%} start {{ task.start.strftime('%Y-%m-%d-%H:%M') }} {%- endif %} {%- if task.schedule_constraint == 2 or task.schedule_constraint == 3 %} end {{ task.end.strftime('%Y-%m-%d-%H:%M') }} {%- endif -%} {% endif %} {{task.schedule_model}} {{task.schedule_timing}}{{task.schedule_unit}} allocate {% for resource in task.resources -%} {%-if loop.index != 1 %}, {% endif %}{{resource.tjp_id}}{% endfor %} {%- endif -%} {% for time_log in task.time_logs %} booking {{time_log.resource.tjp_id}} {{time_log.start.strftime('%Y-%m-%d-%H:%M:%S')}} +{{'%i'|format(time_log.duration.days*24 + time_log.duration.seconds/3600)}}h { overtime 2 } {%- endfor -%} {% endif %} } """ .. confval:: tjp_department_template Defines a Jinja2 template for converting a :class:`~stalker.models.department.Department` instance to a TaskJuggler compatible string. By default Stalker converts a Department to a ``resource`` statement in TaskJuggler. Default value is:: tjp_department_template = '''resource {{department.tjp_id}} "{{department.name}}" { {%- for resource in department.users %} {{resource.to_tjp}} {%- endfor %} }''' .. confval:: tjp_vacation_template Defines a Jinja2 template for converting a :class:`~stalker.models.vacation.Vacation` instance to a TaskJuggler compatible string. By default Stalker converts a Vacation instance to a ``vacation`` statement in TaskJuggler. Default value is:: tjp_vacation_template = '''vacation {{ vacation.start.strftime('%Y-%m-%d-%H:%M') }}, {{ vacation.end.strftime('%Y-%m-%d-%H:%M') }}''' .. confval:: tjp_user_template Defines a Jinja2 template for converting a :class:`~stalker.models.auth.User` instance to a TaskJuggler ``resource`` statement. Default value is:: tjp_user_template = '''resource {{user.tjp_id}} "{{user.name}}"{% if user.vacations %} { {% for vacation in user.vacations -%} {{vacation.to_tjp}} {% endfor -%} }{% endif %}''' .. confval:: tjp_main_template Defines a Jinja2 template for converting all the information coming from Stalker to a TaskJuggler compatible ``tjp`` file. Default value is:: tjp_main_template = """# Generated By Stalker v{{stalker.__version__}} {{studio.to_tjp}} # resources resource resources "Resources" { {%- for user in studio.users %} {{user.to_tjp}} {%- endfor %} } # tasks {% for project in studio.active_projects %} {{project.to_tjp}} {% endfor %} # reports taskreport breakdown "{{csv_file_full_path}}"{ formats csv timeformat "%Y-%m-%d-%H:%M" columns id, start, end } """ .. confval:: tj_command Defines the TaskJuggler command. Stalker uses this configuration value to call TaskJugglers ``tj3`` command. tj_command = '/usr/local/bin/tj3', .. confval:: path_template Defines a default value for path template for :class:`~stalker.models.template.FilenameTemplate` instances to be used by :class:`~stalker.models.version.Version` instances. This value is not used yet. Default value is:: path_template = '{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%}' .. confval:: filename_template Defines a default value for filename template for :class:`~stalker.models.template.FilenameTemplate` instances to be used by :class:`~stalker.models.version.Version` instances. This value is not used yet. Default value is:: filename_template = '{{task.entity_type}}_{{task.id}}_{{version.variant_name}}_v{{"%03d"|format(version.version_number)}}' .. confval:: sequence_format Defines the default file sequence format to be used with `PySeq`_. This value is not used yet. Default value is:: sequence_format = "%h%p%t %R" Fore details about the format see the `PySeq documentation`_. .. _PySeq: http://rsgalloway.github.com/pyseq/ .. _PySeq documentation: http://rsgalloway.github.com/pyseq/ .. confval:: file_size_format Defines the default file size format to be used in UI. Default value is:: file_size_format = "%.2f MB" .. confval:: date_time_format Defines the default datetime format to be used in UI and string representations of datetime.datetime instances. Default value is:: date_time_format = '%Y.%m.%d %H:%M' .. confval:: resolution_presets Defines default resolution presets. This value is not used yet. Default value is:: resolution_presets = { "PC Video": [640, 480, 1.0], "NTSC": [720, 486, 0.91], "NTSC 16:9": [720, 486, 1.21], "PAL": [720, 576, 1.067], "PAL 16:9": [720, 576, 1.46], "HD 720": [1280, 720, 1.0], "HD 1080": [1920, 1080, 1.0], "1K Super 35": [1024, 778, 1.0], "2K Super 35": [2048, 1556, 1.0], "4K Super 35": [4096, 3112, 1.0], "A4 Portrait": [2480, 3508, 1.0], "A4 Landscape": [3508, 2480, 1.0], "A3 Portrait": [3508, 4960, 1.0], "A3 Landscape": [4960, 3508, 1.0], "A2 Portrait": [4960, 7016, 1.0], "A2 Landscape": [7016, 4960, 1.0], "50x70cm Poster Portrait": [5905, 8268, 1.0], "50x70cm Poster Landscape": [8268, 5905, 1.0], "70x100cm Poster Portrait": [8268, 11810, 1.0], "70x100cm Poster Landscape": [11810, 8268, 1.0], "1k Square": [1024, 1024, 1.0], "2k Square": [2048, 2048, 1.0], "3k Square": [3072, 3072, 1.0], "4k Square": [4096, 4096, 1.0], } .. confval:: default_resolution_preset Defines the default resolution preset fro new Projects. This value is not used yet. Default value is:: default_resolution_preset = "HD 1080" .. confval:: project_structure Defines the default project structure. This value is not used by Stalker. Default value is:: project_structure = """{% for shot in project.shots %} Shots/{{shot.code}} Shots/{{shot.code}}/Plate Shots/{{shot.code}}/Reference Shots/{{shot.code}}/Texture {% endfor %} {% for asset in project.assets%} {% set asset_path = project.full_path + '/Assets/' + asset.type.name + '/' + asset.code %} {{asset_path}}/Texture {{asset_path}}/Reference {% endfor %} """ .. confval:: thumbnail_format Defines the default thumbnail format. This value is not used by Stalker. Default value is:: thumbnail_format = "jpg" .. confval:: thumbnail_quality Defines the default thumbnail quality. This value is not used by Stalker. Default value is:: thumbnail_quality = 70 .. confval:: thumbnail_size Defines the defaul thumbnail size. This value is not used by Stalker. Default value is:: thumbnail_size = [320, 180] ================================================ FILE: docs/source/contents.rst ================================================ .. _contents: Table of Contents ================= .. toctree:: :maxdepth: 3 about.rst installation.rst tutorial.rst design.rst configure.rst upgrade_db.rst contribute.rst roadmap.rst changelog.rst todo.rst summary.rst ================================================ FILE: docs/source/contribute.rst ================================================ .. _contribute_toplevel: ================= How To Contribute ================= Stalker started as an Open Source project with the expectation of contributions. The soul of the open source is to share the knowledge and contribute. These are the areas that you can contribute to: * Documentation * Testing the code * Writing the code * Creating user interface elements (graphics, icons etc.) Development Style ================= Stalker is developed strictly by following `TDD`_ practices. So every participant should follow TDD methodology. Skipping this steps is highly prohibited. Every added code to the trunk should have a corresponding test and the tests should be written before implementing a single line of code. .. _TDD: http://en.wikipedia.org/wiki/Test-driven_development `DRY`_ is also another methodology that a participant should follow. So nothing should be repeated. If something needs to be repeated, then it is a good sign that this part needs to be in a special module, class or function. .. _DRY: http:http://en.wikipedia.org/wiki/Don%27t_repeat_yourself Testing ======= As stated above all the code written should have a corresponding test. Adding new features should start with design sketches. These sketches could be plain text files or mind maps or anything that can express the thing in you mind. While writing down these sketches, it should be kept in mind that these files also could be used to generate the documentation of the system. So writing down the sketches as rest files inside the docs is something very meaningful. The design should be followed by the tests. And the test should be followed by the implementation, and the implementation should be followed by tests again, until you are confident about your code and it is rock solid. Then the refactoring phase can start, and because you have enough tests that will keep your code doing a certain thing, you can freely change your code, because you know that you code will do the same thing if it passes all the tests. The first tests written should always fail by having:: self.fail("the test is not implemented yet") failures. This is something good to have. This will inform us that the test is not written yet. After blocking all the tests and you are confident about the tests are covering all the aspects of your design sketches, you can start writing the tests. Another very important note about the tests are the docstrings of the test methods. You should explain what is this test method testing, and what you expect as a result of the test. It After finishing implementing the tests you can start adding the code that will pass the tests. The test framework of Stalker is unitTest and nose to help testing. These python modules should be installed to test Stalker properly: * Nose * Coverage The coverage of the tests should be kept as close as possible to %100. There is a helper script in the root of the project, called *doTests*. This is a shell script for linux, which runs all the necessary tests and prints the tests results and the coverage table. .. note:: From version 0.1.1 the use of Mocker library is discontinued. The tests are done using real objects. It is done in this way cause the design of the objects were changing too quickly, and it started to be a guess work to see which of the tests are effected by this changes. So the Mocker is removed and it will not be used in future releases. Coding Style ============ For the general coding style every participant should strictly follow `PEP 8`_ rules, and there are some extra rules as listed below: * Class names should start with an upper-case letter, function and method names should start with lower-case letter:: class MyClass(object): """the doc string of the class """ def __init__(self): pass def my_method(self): pass * There should be 1 spaces before and after functions and class methods:: class StatusBase(object): """The StatusBase class """ def __init__(self, name, abbreviation, thumbnail=None): self._name = self._checkName(name) def _checkName(self, name): """checks the name attribute """ if name == "" or not isinstance(name, str): raise(ValueError("the name shouldn't be empty and it should \ be a str")) return name.title() * And also there should be 1 spaces before and after a class body:: #-*- coding: utf-8 -*- class A(object): pass class B(object): pass pass * Any lines that may contain a code or comment can not be longer than 79 characters, all the longer lines should be cancelled with "\\" character and should continue properly from the line below:: def _checkName(self, name): """checks the name attribute """ if name == "" or not isinstance(name, str): raise(ValueError("the name shouldn't be empty and it should be a \ str")) return name.title() This rule is not followed for the first line of the docstrings and in long function or method names (particularly in tests). * If anything is going to be checked against being None you should do it in this way:: if a is None: pass * Do not add docstrings to __init__ rather use the classes' own docstring. * The first line in the docstring should be a brief summary separated from the rest by a blank line. If you are going to add a new python file (\*.py), use the following line in the first line:: #-*- coding: utf-8 -*- .. _PEP 8: http://www.python.org/dev/peps/pep-0008/ SCM - Git ========= The choice of SCM is Git. Every developer should be familiar with it. It is a good start to go the `Git Web Site`_ and do the tutorial if you don't feel familiar enough with hg. .. _Git Web Site: https://git-scm.com/ Adding Changes ============== Stalker is hosted in `GitHub`_. .. _GitHub: https://github.com/eoyilmaz/stalker If you want to do changes in Stalker, the basic pipeline is as follows: * Fork Stalker from `GitHub`_ project page. * Clone your own Stalker repository to your own computer. * Do your addition, run your tests, and be sure that your part doesn't have any errors or failures. * Commit your changes. * Before creating a pull request check if your repository is in sync with the upstream GitHub repository (the repository that you've forked Stalker from) by using the tools supplied in your GitHub project page. * In case there are new changes in upstream, merge them with yours. * Do the tests again. If there are problems in your part of the code, solve the errors/failures. * Commit your changes again. * And push them to your own GitHub repository. * And in the original `GitHub`_ page create a Pull Request. ================================================ FILE: docs/source/design.rst ================================================ .. _design_toplevel: ====== Design ====== This document explores Stalker, an open-source Python library designed for production asset management. Introduction ============ While primarily designed for VFX and animation studios, Stalker's flexible architecture makes it adaptable to various industries. An Asset Management (AM) System is responsible for organizing and storing data created by users, ensuring easy accessibility. A Production Asset Management (ProdAM) System extends the functionality of an AMS by managing production steps, tasks, and enabling collaboration among team members. Implementing an ProdAM System in an animation or VFX studio is crucial for maintaining order and efficiency. The benefits of a well-organized system far outweigh the initial setup effort. Many studios develop their own custom ProdAM solutions. Stalker aims to provide a solid foundation for these systems, reducing the need for redundant development efforts. Stalker focuses on organizing assets and tasks within projects, streamlining workflows. It goes beyond basic asset management by incorporating production steps and collaboration tools. Concepts ======== There are a few key design concepts to understand before diving deeper into Stalker. Essentially, Stalker serves as the **Model** component in an **MTV** (Model-Template-View) architecture. `Stalker Pyramid`_ provides the *Template* and *View* components, defining the presentation layer and user interface. Stalker itself focuses on defining the data structures and their interactions. Stalker Object Model (SOM) -------------------------- Stalker's robust object model, the Stalker Object Model (SOM), provides a flexible framework for building production pipelines. SOM is designed to be both usable out-of-the-box and extensible to meet specific studio needs. Lets look at how a studio simply works and try to create our asset management concepts around it. An animation of VFX studio's primary goal is to complete a :class:`.Project`. This project involves creating a series of :class:`.Sequences`, each composed of individual :class:`.Shot`\ s. These shots, in turn, often rely on reusable :class:`.Asset`\ s. To break down the work into manageable chunks, Projects, Sequences, Shots, and Assets are further divided into :class:`.Task`\ s. These tasks often represent specific pipeline steps like modeling, look development, rigging, animation, lighting, and so on. These tasks can be assigned to specific :class:`User`\ s and require a certain amount of **effort** to complete. This effort is tracked using :class:`.TimeLog`\ s. As work progresses on a task, :class:`.Version`\ s are created to represent different iterations or revisions of the output. These versions are linked to files stored in a :class:`.Repository`. All the names those shown in bold fonts are a class in SOM. and there are a series of other classes to accommodate the needs of a :class:`.Studio`. The inheritance diagram of the classes in the SOM is shown below: .. include:: inheritance_diagram.rst Stalker is a highly configurable and open-source system. This flexibility allows for various customization options. There are two main approaches to extending Stalker: 1. **Simple Customization:** This involves adding or modifying existing entities like statuses, types, or other predefined elements. The current Stalker design is well-suited for this level of customization. More details can be found in the `How to Customize Stalker`_ section. 2. **Extending the SOM:** This involves creating new classes and database tables, or modifying existing ones. This approach is more complex but allows for significant customization of Stalker's core functionality. Refer to the `How To Extend SOM`_ section for further guidance. Features -------- Stalker boasts a robust feature set designed to streamline your production pipeline: 1. **Pure Python:** Built entirely on Python 3.8 and above (continuously tested with Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13), utilizing rigorous Test Driven Development (TDD) practices for exceptional code quality (test coverage is 99.7%). 2. **SQLAlchemy Integration:** Leverages SQLAlchemy for its database backend and Object-Relational Mapping (ORM) capabilities, ensuring efficient data management. Designed PostgreSQL (versions 14 to 17) in mind but not limited to it. 3. **Jinja2 Templates:** Employs Jinja2 for flexible file and folder naming conventions. For a structured naming scheme it is possible to define templates like: {repository.path}/{project.code}/Assets/{asset.type.name}/{asset.code}/{asset.name}_{asset.type.name}_v{version.version_number:03d}.{version.extension} 5. **Review Workflow:** Stalker incorporates a comprehensive task review workflow and a robust task status management system to ensure efficient and quality production. 6. **Automated File Placement:** Upload files, folders, and even file sequences as versions. Stalker utilizes the defined templates to automatically determine their placement on the server, promoting organization. 7. **Fine-Grained Event System:** Gain complete control over the CRUDL (Create, Read, Update, Delete, List) lifecycle. Define custom callbacks to execute before or after specific operations, enabling tailored behavior. 8. **Embedded Ticketing System:** Streamline issue tracking and project discussions with a built-in ticketing system. 9. **TaskJuggler Integration:** Integrate with TaskJuggler for enhanced task management capabilities, supporting basic task attributes. 10. **Predefined Task Statuses:** Manage task progress efficiently with a pre-defined Task Status Workflow, providing a structured approach to tracking task completion stages. For usage examples see :ref:`tutorial_toplevel`\ . How To Customize Stalker ======================== Upcoming! This part will explain the customization of Stalker. How To Extend SOM ================= Upcoming! This part will explain how to extend Stalker Object Model or SOM. .. _`Stalker Pyramid`: https://pypi.python.org/pypi/stalker_pyramid ================================================ FILE: docs/source/index.rst ================================================ .. _index_toplevel: ===================== Stalker Documentation ===================== .. include:: about.rst .. include:: contents.rst Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: docs/source/inheritance_diagram.rst ================================================ .. _inheritance_diagram_toplevel: Inheritance Diagram =================== .. inheritance-diagram:: stalker.exceptions.CircularDependencyError stalker.exceptions.DependencyViolationError stalker.exceptions.LoginError stalker.exceptions.OverBookedError stalker.exceptions.StatusError stalker.models.asset.Asset stalker.models.auth.AuthenticationLog stalker.models.auth.Group stalker.models.auth.LocalSession stalker.models.auth.Permission stalker.models.auth.Role stalker.models.auth.User stalker.models.budget.Budget stalker.models.budget.BudgetEntry stalker.models.budget.Good stalker.models.budget.Invoice stalker.models.budget.Payment stalker.models.budget.PriceList stalker.models.department.Department stalker.models.department.DepartmentUser stalker.models.client.Client stalker.models.entity.Entity stalker.models.entity.EntityGroup stalker.models.entity.SimpleEntity stalker.models.file.File stalker.models.format.ImageFormat stalker.models.message.Message stalker.models.mixins.ACLMixin stalker.models.mixins.CodeMixin stalker.models.mixins.DateRangeMixin stalker.models.mixins.ProjectMixin stalker.models.mixins.ReferenceMixin stalker.models.mixins.ScheduleMixin stalker.models.mixins.StatusMixin stalker.models.mixins.TargetEntityTypeMixin stalker.models.mixins.WorkingHoursMixin stalker.models.note.Note stalker.models.project.Project stalker.models.project.ProjectClient stalker.models.project.ProjectRepository stalker.models.project.ProjectUser stalker.models.repository.Repository stalker.models.review.Review stalker.models.review.Daily stalker.models.review.DailyFile stalker.models.scene.Scene stalker.models.schedulers.SchedulerBase stalker.models.schedulers.TaskJugglerScheduler stalker.models.sequence.Sequence stalker.models.shot.Shot stalker.models.status.Status stalker.models.status.StatusList stalker.models.structure.Structure stalker.models.studio.Studio stalker.models.studio.Vacation stalker.models.studio.WorkingHours stalker.models.tag.Tag stalker.models.task.Task stalker.models.task.TaskDependency stalker.models.task.TimeLog stalker.models.template.FilenameTemplate stalker.models.ticket.Ticket stalker.models.ticket.TicketLog stalker.models.type.EntityType stalker.models.type.Type stalker.models.variant.Variant stalker.models.version.Version stalker.models.wiki.Page :parts: 1 ================================================ FILE: docs/source/installation.rst ================================================ .. _installation_toplevel: ============ Installation ============ How to Install Stalker ====================== This document will help you install and run Stalker. Install Python ============== Stalker is completely written with Python, so it requires Python. It currently works with Python version 2.6 and 2.7. So you first need to have Python installed in your system. On Linux and macOS there is a system wide Python already installed. For Windows, you need to download the Python installer suitable for your Windows operating system (32 or 64 bit) from `Python.org`_ .. _Python.org: http://www.python.org/ Install Stalker =============== The easiest way to install the latest version of Stalker along with all its dependencies is to use the `setuptools`. If your system doesn't have setuptools (particularly Windows) you need to install `setuptools` by using `ez_setup` bootstrap script. Installing `setuptools` with `ez_setup`: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ These steps are generally needed just for Windows. Linux and macOS users can skip this part. 1. download `ez_setup.py`_ 2. run the following command in the command prompt/shell/terminal:: python ez_setup It will install or build the `setuptools` if there are no suitable installer for your operating system. .. _ez_setup.py: http://peak.telecommunity.com/dist/ez_setup.py Installing Stalker (All OSes): ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ After installing the `setuptools` you can run the following command:: easy_install -U stalker Now you have installed Stalker along with all its dependencies. Checking the installation of Stalker ==================================== If everything went ok you should be able to import and check the version of Stalker by using the Python prompt like this:: >>> import stalker >>> stalker.__version__ 0.2.21 For developers ============== It is highly recommended to create a `VirtualEnv` specific for Stalker development. So to setup a virtualenv for Stalker:: virtualenv --no-site-packages stalker Then clone the repository (you need git to do that):: cd stalker git clone https://github.com/eoyilmaz/stalker.git stalker And then to setup the virtual environment for development:: cd stalker ../bin/python setup.py develop This command should install any dependent package to the virtual environment. .. _VirtualEnv: https://pypi.python.org/pypi/virtualenv Installing a Database ===================== Stalker uses a database to store all the data. The only database backend that doesn't require any extra installation is SQLite3. You can setup Stalker to run with an SQLite3 database. But it is much suitable to have a dedicated database server in your studio. And it is recommended to use the same kind of database backend both in development and production to reduce any compatibility problems and any migration headaches. Although Stalker is mainly tested and developed on SQLite3, the developers of Stalker are using it in a studio environment where the main database is PosgreSQL, and it is the recommended database for any application based on Stalker. But, testing and using Stalker in any other database is encouraged. See the `SQLAlchemy documentation`_ for supported databases. .. _SQLAlchemy documentation: http://www.sqlalchemy.org/docs/core/engines.html#supported-dbapis ================================================ FILE: docs/source/roadmap.rst ================================================ .. _roadmap_toplevel: =========================== Stalker Development Roadmap =========================== This section describes the direction Stalker is going. Roadmap Based on Versions ========================= Below you can find the roadmap based on the version 0.1.0: ------ * A complete working set of models in SOM which are using SQLAlchemy.ext.declarative. 0.2.0: ------ * Web interface * Complete ProdAM capabilities. 0.3.0: ------ * Complete working Event system ================================================ FILE: docs/source/status_and_status_lists.rst ================================================ .. _status_and_status_lists_toplevel: Statuses and Status Lists ========================= In Stalker, classes mixed with :class:`.StatusMixin` needs to be created with a *suitable* :class:`.StatusList` instance. Because most of the *statusable* classes are going to be using the same :class:`.Status`\ es (ex: **WIP**, **Pending Review**, **Completed** etc.) over and over again, it is much efficient to create those Statuses only once and use them multiple times by grouping them in :class:`.StatusList`\ s. A *suitable status list* means, the :attr:`.StatusList.target_entity_type` is set to the name of that particular class. ================================================ FILE: docs/source/summary.rst ================================================ .. _summary_toplevel: Summary ======= .. autosummary:: :toctree: generated/ :nosignatures: stalker.db stalker.db.setup stalker.exceptions stalker.exceptions.CircularDependencyError stalker.exceptions.LoginError stalker.exceptions.OverBookedError stalker.exceptions.StatusError stalker.models stalker.models.asset.Asset stalker.models.auth.AuthenticationLog stalker.models.auth.Group stalker.models.auth.LocalSession stalker.models.auth.Role stalker.models.auth.Permission stalker.models.auth.User stalker.models.budget.Budget stalker.models.budget.BudgetEntry stalker.models.budget.Good stalker.models.budget.Invoice stalker.models.budget.Payment stalker.models.budget.PriceList stalker.models.department.Department stalker.models.department.DepartmentUser stalker.models.client.Client stalker.models.client.ClientUser stalker.models.entity.Entity stalker.models.entity.EntityGroup stalker.models.entity.SimpleEntity stalker.models.file.File stalker.models.format.ImageFormat stalker.models.message.Message stalker.models.mixins.ACLMixin stalker.models.mixins.CodeMixin stalker.models.mixins.DateRangeMixin stalker.models.mixins.ProjectMixin stalker.models.mixins.ReferenceMixin stalker.models.mixins.ScheduleMixin stalker.models.mixins.StatusMixin stalker.models.mixins.TargetEntityTypeMixin stalker.models.mixins.WorkingHoursMixin stalker.models.note.Note stalker.models.project.Project stalker.models.project.ProjectClient stalker.models.project.ProjectRepository stalker.models.project.ProjectUser stalker.models.repository.Repository stalker.models.review.Review stalker.models.review.Daily stalker.models.review.DailyFile stalker.models.scene.Scene stalker.models.schedulers.SchedulerBase stalker.models.schedulers.TaskJugglerScheduler stalker.models.sequence.Sequence stalker.models.shot.Shot stalker.models.status.Status stalker.models.status.StatusList stalker.models.structure.Structure stalker.models.studio.Studio stalker.models.studio.WorkingHours stalker.models.tag.Tag stalker.models.task.Task stalker.models.task.TaskDependency stalker.models.task.TimeLog stalker.models.template.FilenameTemplate stalker.models.ticket.Ticket stalker.models.ticket.TicketLog stalker.models.type.EntityType stalker.models.type.Type stalker.models.variant.Variant stalker.models.version.Version stalker.models.wiki.Page ================================================ FILE: docs/source/task_review_workflow.rst ================================================ .. _task_review_workflow_toplevel: ==================== Task Review Workflow ==================== Introduction ============ All tasks in Stalker have a specific purpose and goal. :class:`.Task` resources are responsible for completing these tasks, while task responsible ensure their correct execution. The Task Review Workflow provides a mechanism for reviewing tasks withing Stalker. The Workflow ============ A task resource can request a review from the task's responsible at any stage of task completion, including for supervisory purposes. When a review request is made, Stalker creates a :class:`.Review` instance associated with the :class:`.Task`. This :class:`.Review` instance tracks the review's status (initially set to `NEW`), any requested revisions (including description, additional time allowances, and desired task statuses). Single Responsible and Resource Scenario ======================================== Consider a task with a single responsible and a single resource. When the resource requests a review, Stalker creates a :class:`.Review` instance assigned to the responsible. The responsible then review the task and can: - **Approve**: If the task is complete, the responsible can approve the review by calling the :meth:`.Review.approve()` method. - **Request Revision**: If additional work is required, the responsible can request a revision by calling the :meth:`.Review.request_revision()` method. This involves specifying the necessary revisions, additional time. Multiple Responsible Scenario ============================= If multiple responsible are assigned to a task, a Review instance is created for each of them when a review is requested. The task is considered incomplete until all responsible have approved the review. If multiple responsible request revisions, the total revision time is added to the task, and the resource continues working. Dependent Tasks =============== When a revision is requested for a completed task with dependent tasks, different scenarios arise: **Scenario A: Dependent Tasks Are All in Ready-To-Start (RTS) Status* If there are no dependent tasks or none have started (all in `RTS`), the dependent tasks are set to `Waiting For Dependency (WFD)` to prevent work until the original task is completed. **Scenario B: Started or Completed Dependent Tasks** If there are dependent tasks and some have started or completed, their status is updated based on the following table: +----------------+--------------+ | Initial Status | Final Status | +----------------+--------------+ | WFD | WFD | +----------------+--------------+ | RTS | WFD | +----------------+--------------+ | WIP | DREV | +----------------+--------------+ | PREV | PREV | +----------------+--------------+ | HREV | DREV | +----------------+--------------+ | DREV | DREV | +----------------+--------------+ | OH | OH | +----------------+--------------+ | STOP | STOP | +----------------+--------------+ | CMPL | DREV | +----------------+--------------+ Once the revised tasks is approved and set back to `CMPL`, dependent tasks are restored to their original statuses based on their time logs: +-----------------+------+------+-----+----+------+ | | DREV | PREV | WFD | OH | STOP | +-----------------+------+------+-----+----+------+ | Has No TimeLogs | RTS | PREV | RTS | OH | STOP | +-----------------+------+------+-----+----+------+ | Has TimeLogs | WIP | PREV | WIP | OH | STOP | +-----------------+------+------+-----+----+------+ As you see the task statuses will be restored to their original statuses except for HREV and CMPL. HREV tasks can not be restored, because even in a normal situation where there are no revision requested for the dependent task, creating a new time log will set its status to WIP, and a CMPL task can not be stored to CMPL status because there were revisions to the depending task so there should be some work to be done to update this task, so it is restored as WIP. The following workflow diagram illustrates the task status transitions, and it is a good idea to familiarize yourself with the task statuses used in Stalker. .. image:: _static/images/Task_Status_Workflow.png :width: 637 px :height: 381 px :align: center Revision Counter ================ Both :class:`.Task` and :class:`.Review` instances have ``review_number`` attribute. Reviews with the same ``review_number`` belong to the same review set. Multiple :class:`.Review` instances with the same :attr:`Review.review_number` can exist if they have different reviewers. - The :attr:`.Task.review_number` starts at 0 for the initial revision and increments with each review requests. So a :class:`.Task` with ``review_number`` is 0 has no reviews yet. - A newly created :class:`.Review` instance has a ``review_number`` one higher than the :attr:`.Task.review_number` at the time of creation. To create revisions effectively, use the :meth:`.Task.request_review()` method. This ensures correct :class:`.Review` instance creation per reviewer and correct ``review_number`` assignment and will return the newly created :class:`.Review` instances as a list. Each responsible should use the :meth:`.Review.approve()` or :meth:`.Review.request_revision()` methods to set the appropriate status and additional revision information. ================================================ FILE: docs/source/todo.rst ================================================ .. _todo_toplevel: .. include:: source/../../../TODO.rst ================================================ FILE: docs/source/tutorial/asset_management.rst ================================================ .. _tutorial_asset_management_toplevel: Asset Management ================ Now that we've created projects, tasks and resources, it's time to manage the files generated during production. File Storage and Repository setup --------------------------------- Contrary to a Source Code Management (SCM) System where revisions to a file is handled incrementally, Stalker handles file versions all together. Meaning that, all the files (:class:`.File`) that are created for individual versions (:class:`.Version`) of a task are individual files stored in a shared location accessible to everyone in your studio. This location is called a :class:`.Repository` in Stalker. Defining Repository Paths ------------------------- A repository can be a network share or a locally mounted directory. You can define multiple repositories for different project types or needs. We've already created a repository while creating our first project. But the repository has missing information. Here's how to define paths for a commercial project repository:: .. code-block:: python commercial_repo.linux_path = "/mnt/M/commercials" commercial_repo.macos_path = "/Volumes/M/commercials" commercial_repo.windows_path = "M:/commercials" # Stalker automatically corrects backslashes (\) to forward slashes (/) And if you ask for the path to a repository object, it will always return the correct path according to your operating system: .. code-block:: python print(commercial_repo.path) You'll get the appropriate path based on your OS: * **Windows:** M:/commercials * **Linux:** /mnt/M/commercials * **macOS:** /Volumes/M/commercials This ensures consistent file path handling across different platforms. .. note:: Stalker consistently uses forward slashes (/) in path definitions, regardless of the operating system. This applies even if you initially specify paths with backward slashes (\\). Assigning Repository to Project ------------------------------- Connecting a repository to your project lets Stalker know where project files are stored. However, it still needs information about the project's specific directory structure. Defining Project Structure -------------------------- A :class:`.Structure` object defines the directory hierarchy for your project within the repository. We create a structure named "Commercial Projects Structure" and assign it to our project: .. code-block:: python from stalker import Structure commercial_project_structure = Structure( name="Commercial Projects Structure" ) # now assign this structure to our project new_project.structure = commercial_project_structure .. versionadded:: 0.2.13 Starting with Stalker version 0.2.13, :class:`.Project` instances can be associated with multiple :class:`.Repository` instances. This allows for more flexible file management, such as storing published version files on a separate server or directing rendered outputs to a different location. While the following examples are simplified, future versions will showcase the full potential of multiple repositories. Creating Filename Templates --------------------------- Next we create :class:`.FilenameTemplate` instances. These templates define how filenames and paths will be generated for by :class:`.Version` instances associated with tasks. Here, we create a :class:`.FilenameTemplate` named "Task Template for Commercials" that uses `Jinja2`_ syntax for the ``path`` and ``filename`` arguments. The :class:`.Version.generate_path()` method knows how to render these templates to generate a ``pathlib.Path`` object: .. code-block:: python from stalker import FilenameTemplate task_template = FilenameTemplate( name='Task Template for Commercials', target_entity_type='Task', path='$REPO{{project.repository.code}}/{{project.code}}/{%- for p in parent_tasks -%}{{p.nice_name}}/{%- endfor -%}', filename='{{version.nice_name}}_r{{"%02d"|format(version.revision_number)}}_v{{"%03d"|format(version.version_number)}}' ) # Append the template to the project structure commercial_project_structure.templates.append(task_template) # No need to add anything as the project is already in the database DBsession.commit() Explanation of the Template: * ``$REPO{{project.repository.code}}``: This references the first repository assigned to the project. Importantly, this uses an environment variable ``$REPO``. Stalker dynamically creates environment variables for each repository upon database connection or creation, simplifying path definitions within templates. * ``{{project.code}}``: This represent the project code and it is guaranteed to be file system safe. * ``{%- for p in parent_tasks -%}{{p.nice_name}}/{%- endfor -%}``: This loop iterates over parent tasks, creating subdirectories for each. * ``{{version.nice_name}}_r{{"%02d"|format(version.revision_number)}}_v{{"%03d"|format(version.version_number)}}``: This defines the filename format with revision and version numbers padded. Creating and Managing Versions ------------------------------ Now, let's create a :class:`.Version`` instance for the "comp" task: .. code-block:: python from stalker import Version vers1 = Version(task=comp) # Generate a path using the template path = vers1.generate_path() print(path.parent) # '$REPO33/FC/SH001/comp' print(path.name) # 'SH001_comp_r01_v001' print(path) # '$REPO33/FC/SH001/comp/SH001_comp_r01_v001' # Absolute paths with repository root based on your OS # unfortunately the Path object doesn't directly render environment variables print(os.path.expandvars(path.parent)) # '/mnt/M/commercials/FC/SH001/comp' print(os.path.expandvars(path)) # '/mnt/M/commercials/FC/SH001/comp/SH001_comp_r01_v001' # Get the revision number (manually incremented) print(vers1.revision_number) # 1 # Get version number (automatically incremented) print(vers1.version_number) # 1 # commit to database DBsession.commit() Stalker automatically generates a consistent path and filename for the version based on the template. Stalker eliminates the need for those cumbersome and confusing file naming conventions like ``Shot1_comp_Final``, ``Shot1_comp_Final_revised``, ``Shot1_comp_Final_revised_Final``, ``Shot1_comp_Final_revised_Final_real_final`` ...and the list goes on, we've all experienced the frustration of such naming conventions, haven't we 😊.. It ensures a consistent and organized file structure, making asset management significantly more efficient. The :attr:`.Version.is_published` attribute within the :class:`.Version` class helps differentiate between finalized and in-progress versions. Setting :attr:`.is_published` to ``True`` flags a version as ready for use or review. .. code-block:: python vers1.is_published = False # This version is still being worked on Automatic Version Numbering --------------------------- Stalker automatically increments version numbers for each new version created for the same task. This ensures you always have the latest iteration readily identified. .. code-block:: python vers2 = Version(task=comp) print(vers2.version_number) # Output: 2 print(vers2.generate_path().name) # Output: 'SH001_comp_r01_v002' vers3 = Version(task=comp) print(vers3.version_number) # Output: 3 print(vers3.generate_path().name) # Output: 'SH001_comp_r01_v003' :attr:`.Version.revision_number` is not updated automatically and left for the user to update: .. code-block:: python vers4 = Version(task=comp, revision_number=2) print(vers4.version_number) # Output: 4 print(vers4.generate_path().name) # Output: 'SH001_comp_r02_v001' Querying Versions ----------------- You can retrieve all versions associated with a specific task using either using the :attr:`.Task.versions` attribute or by doing a database query. .. code-block:: python # using pure Python vers_from_python = comp.versions # [, # , # ] # # Using SQLAlchemy query vers_from_query = Version.query.filter_by(task=comp).all() # again returns # [, # , # ] # Both methods return a list of Version objects assert vers_from_python == vers_from_query .. _Jinja2: http://jinja.pocoo.org/ .. note:: Files related to :class:`.Version` instances can be created by instantiating the :class:`.File` class. The :attr:`.File.full_path` can be set with the value coming from the :meth:`.Version.generate_path()` method, which by default will contain environment variables for the repository path and Stalker will save the :att:`.File.full_path` attribute value in the database without converting the environment variables so the paths will stay operating system independent. You can define default directories within your project structure using custom templates. .. code-block:: python commercial_project_structure.custom_template = """ Temp References References/Movies References/Images """ When executed, this template will generate the following directory structure: .. code-block:: shell Temp References Movies Images ================================================ FILE: docs/source/tutorial/basics.rst ================================================ .. _tutorial_basics_toplevel: Basics ====== Imagine you've just installed Stalker and want to integrate it into your first project. The first step involves connecting to the database to store information about your studio and projects. Connecting to the Database -------------------------- A helper function is provided for connecting to the default database. Use the following command: .. code-block:: python from stalker.db.setup import setup setup({"sqlalchemy.url": "sqlite:///"}) This creates an in-memory SQLite3 database, suitable only for testing. For practical use, consider a file-based SQLite3 database: .. code-block:: python # Windows setup({"sqlalchemy.url": "sqlite:///C:/studio.db"}) # Linux or macOS setup({"sqlalchemy.url": "sqlite:////home/ozgur/studio.db"}) This command will do the following: 1. **Database Connection:** Creates an `engine`_ to establish the connection. 2. **Database Creation:** Creates the SQLite3 database file if doesn't exist. 3. **Session Creation:** Creates a `session`_ instance for interacting with the database. 4. **Mapping:** Defines how SOM classes `map`_ to database tables (see SQLAlchemy documentation for details). .. _session: http://www.sqlalchemy.org/docs/orm/session.html .. _engine: http://www.sqlalchemy.org/docs/core/engines.html .. _map: http://www.sqlalchemy.org/docs/orm/mapper_config.html .. note:: While SQLite3 support was officially dropped in Stalker v0.2.18, it's still possible to use SQLite3 databases with Stalker. However, PostgreSQL (versions 14 to 17) is the recommended database backend. Database Initialization ----------------------- On your initial connection, use `db.init()` to create essential default data for Stalker to function properly: .. code-block:: python db.init() This is a one-time operation; subsequent calls to `db.init()` won't break anything, but they're unnecessary. Creating a Studio ----------------- Let's create a :class:`.Studio` object to represent your studio: .. code-block:: python from stalker import Studio my_studio = Studio( name='My Great Studio' ) We'll explain the concept of :class:`.Studio` later in the tutorial. Creating a User --------------- Now, let's create a :class:`.User` object representing yourself in the database: 1. Import the :class:`.User` class: .. code-block:: python from stalker import User 2. Create the :class:`.User` object: .. code-block:: python me = User( name="Erkan Ozgur Yilmaz", login="eoyilmaz", email="some_email_address@gmail.com", password="secret", description="This is me" ) This creates a user object that represents you. Creating and Assigning a Department ----------------------------------- 1. Import the :class:`.Department` class: .. code-block:: python from stalker import Department 2. Create a :class:`.Department` object: .. code-block:: python tds_department = Department( name="TDs", description="This is the TDs department" ) 3. Assign yourself to the department: There are two ways to do this: * Using the :class:`.Department` object: .. code-block:: python tds_department.users.append(me) * Using the :class:`.User` object: .. code-block:: python me.departments.append(tds_department) Both methods achieve the same result. Verifying Department Assignment ------------------------------- You can verify the assignment by printing the :attr:`.User.departments` for your user: .. code-block:: python print(me.departments) # Output: [] Saving Data to the Database --------------------------- So far, the data hasn't been saved to the database yet. To commit the changes, use the :class:`.DBSession` object: .. code-block:: python from stalker.db.session import DBSession DBSession.add(my_studio) DBSession.add(me) DBSession.add(tds_department) DBSession.commit() Retrieving Data --------------- Let's retrieve data from the database. Here, we'll fetch all departments, get the second one (excluding the default `admins` department), and print the name of its first member: .. code-block:: python all_departments = Department.query.all() print(all_departments) # Output: [, ] # "admins" department is created by default admins = all_departments[0] tds = all_departments[1] all_users = tds.users # Department.users is a "synonym" for Department.members # they are essentially the same attribute print(all_users[0]) # Output: This retrieves and prints the information. ================================================ FILE: docs/source/tutorial/collaboration.rst ================================================ .. _tutorial_collaboration_toplevel: Collaboration in Stalker ======================== While we've covered the core functionalities of Stalker, effective collaboration is essential in any production pipeline. Stalker provides several tools to facilitate communication and knowledge sharing among team members. Note System: You can leave :class:`Note`\ s on any Stalker :class:`.Entity` (except other :class:`.Note`\ s and :class:`.Tag`\ s). This allows you to add comments, reminders, or specific instructions to tasks, assets, versions, and other objects. Messaging System: A direct messaging system (currently under development) will allow you to send private messages to individuals or groups of users. Ticket System: Create and track tickets for specific projects to report issues, request features, or discuss project-related matters. Wiki Pages: Create and maintain project-specific wiki pages to document procedures, best practices, and other important information. ================================================ FILE: docs/source/tutorial/conclusion.rst ================================================ .. _tutorial_toplevel: Conclusion ========== In this tutorial, you have nearly learned a quarter of what Stalker supplies as a Python library. Stalker provides a robust framework for production asset management, serving the needs of both large and small studios. Its 16-years development history (as of 2025) and use in major feature films and countless commercials is a testament to its effectiveness. While Stalker itself lacks a graphical user interface (GUI), its power extends beyond raw code. Here are some additional tools that leverage Stalker's core functionality: `Stalker Pyramid`_: A web application built using the `Pyramid`_ framework, utilizing Stalker as its database model. This allows for user-friendly web-based interaction with project data. `Anima Pipeline`_: A pipeline library that incorporates Stalker, showcasing how its functionalities can be integrated into a pipeline management system. Notably, Anima demonstrates the creation of Qt UIs using Stalker. For a deeper dive into how Stalker interacts with UIs and web applications, consider exploring the repositories of `Stalker Pyramid`_ and `Anima Pipeline`_. By understanding how Stalker integrates with these tools, you can unlock its full potential for streamlining your production workflows. .. _Stalker Pyramid: https://www.github.com/eoyilmaz/stalker_pyramid .. _Anima Pipeline: https://github.com/eoyilmaz/anima .. _Pyramid: https://trypyramid.com/ ================================================ FILE: docs/source/tutorial/creating_simple_data.rst ================================================ .. _tutorial_creating_simple_data_toplevel: Creating Simple Data ==================== Let's imagine you're starting a new commercial project and want to use Stalker. The first step is to create a :class:`.Project` object to store project information. Project setup ------------- .. versionadded:: 0.2.24.2 Starting with Stalker v0.2.24.2, you no longer need to manually create :class:`.StatusList` instances for :class:`.Project` objects. The :func:`stalker.db.setup.init()` function will automatically create them during database initialization. .. note:: When the Stalker database is first initialized (with ``db.setup.init()``), a set of default :class:`.Status` instances for :class:`.Task`, :class:`.Asset`, :class:`.Shot`, :class:`.Sequence`, :class:`.Ticket` and :class:`.Variant` classes are created, along with their respective :class:`.StatusList` instances. Creating a Repository --------------------- To create a project, we first need to create a :class:`.Repository`. The Repository (or Repo) is a directory on your file server, where project files are stored and accessible to all workstations and render farm computers: .. code-block:: python from stalker import Repository commercial_repo = Repository( name="Commercial Repository", code="CR" ) .. versionadded:: 0.2.24 Starting with Stalker version 0.2.24 :class:`.Repository` instances have a :attr:`stalker.models.repository.Repository.code` attribute to help generate universal paths across operating systems and Stalker installations. :class:`.Repository` class will be explained in detail in upcoming sections. Creating a Project ------------------ Now, let's create the project: .. code-block:: python new_project = Project( name="Fancy Commercial", code='FC', repositories=[commercial_repo], ) Adding Project Details ---------------------- Let's add more details to the project: .. code-block:: python import tzlocal import datetime from stalker import ImageFormat new_project.description = ( "The commercial is about this fancy product. The " "client want us to have a shiny look with their " "product bla bla bla..." ) new_project.image_format = ImageFormat( name="HD 1080", width=1920, height=1080 ) new_project.fps = 25 local_tz = tzlocal.get_localzone() new_project.end = datetime.datetime(2024, 5, 15, tzinfo=local_tz) new_project.users.append(me) Saving the Project ------------------ To save the project and its associated data to the database: .. code-block:: python DBSession.add(new_project) DBSession.commit() Even though we've created multiple objects (project, repository etc.), we only need to add the ``new_project`` object to the database. Stalker will handle the relationships and save the related objects automatically. .. note:: Starting with Stalker v0.2.18, all the datetime information must include timezone information. In the example, we've used the local timezone. Creating Sequences and Shots ---------------------------- A :class:`.Project` is typically composed of :class:`.Task` instances, which represent units of work that need to be completed. A :class:`.Task` in Stalker defines the total `effort` required to be considered finished. Tasks can also be `duration` or `length` based, in which case they define the required time to be considered finished. Leaf tasks, the final tasks in a task hierarchy, are assigned to specific :class:`.User` instances who are responsible for completing them. More details about :class:`.Task` and its attributes can be found in the :class:`.Task` class documentation. :class:`.Asset`, :class:`.Shot` and :class:`.Sequences` are specialized types of Tasks. Let's create a :class:`.Sequence`: .. code-block:: python from stalker import Sequence seq1 = Sequence( name="Sequence 1", code="SEQ1", project=new_project, ) And some :class:`.Shot`\ s withing the sequence: .. code-block:: python from stalker import Shot sh001 = Shot( name='SH001', code='SH001', project=new_project, sequences=[seq1] ) sh002 = Shot( code='SH002', project=new_project, sequences=[seq1] ) sh003 = Shot( code='SH003', project=new_project, sequences=[seq1] ) Save the changes to the database: .. code-block:: python DBsession.add_all([sh001, sh002, sh003]) DBsession.commit() .. note:: * While we've created :class:`.Shot` objects with a :class:`.Sequence` instance, it's not strictly necessary. You can create :class:`.Shot` objects without assigning them to a Sequence. * For smaller projects like commercials, you might skip creating sequences altogether. * For larger projects like feature films, using sequences to group shots is recommended. ================================================ FILE: docs/source/tutorial/extending_som.rst ================================================ .. _tutorial_extending_som_toplevel: Extending SOM (coming) ====================== This part will be covered soon ================================================ FILE: docs/source/tutorial/pipeline.rst ================================================ .. _tutorial_pipeline_toplevel: Pipeline ======== So far, we've covered the basics of creating data in Stalker. However. to fully utilize Stalker's power, we need to define our studio's **pipeline**. This involves creating tasks and establishing dependencies between them. Creating Tasks -------------- Let's create some :class:`.Task`\ s for one of the shots we created earlier: .. code-block:: python from stalker import Task previs = Task( name="Previs", parent=sh001 ) matchmove = Task( name="Matchmove", parent=sh001 ) anim = Task( name="Animation", parent=sh001 ) lighting = Task( name="Lighting", parent=sh001 ) comp = Task( name="Comp", parent=sh001 ) Defining Dependencies --------------------- Now, let's define the dependencies between these tasks: .. code-block:: python comp.depends_on = [lighting] lighting.depends_on = [anim] anim.depends_on = [previs, matchmove] By establishing these dependencies, we're telling Stalker that certain tasks need to be completed before others can begin. For example, the "Comp" task depends on the "Lighting" task, meaning the "Lighting" task must be finished before the "Comp" task can start. Stalker uses these dependencies to schedule tasks effectively. We'll delve deeper into task scheduling and other pipeline-related concepts later in this tutorial. ================================================ FILE: docs/source/tutorial/query_update_delete_data.rst ================================================ .. _tutorial_query_update_delete_data_toplevel: Querying, Updating and Deleting Data ==================================== Now that you've created some data, let's explore how to update and delete it. Updating Data ------------- Imagine you created a shot with incorrect information: .. code-block:: python sh004 = Shot( code='SH004', project=new_project, sequences=[seq1] ) DBSession.add(sh004) DBSession.commit() Later, you realize you need to fix the code: .. code-block:: python sh004.code = "SH005" DBsession.commit() Retrieving Data --------------- To retrieve a shot from the database, you can use a query: .. code-block:: python wrong_shot = Shot.query.filter_by(code="SH005").first() This retrieves the first shot with the code "SH005". Updating Retrieved Data ----------------------- If you need to modify the retrieve data: .. code-block:: python wrong_shot.code = "SH004" # Correct the code DBsession.commit() # Save the changes Deleting Data ------------- To delete data, use the :meth:`DBSession.delete()` method: .. code-block:: python DBsession.delete(wrong_shot) DBsession.commit() After deleting data, you program variables might still hold references to the deleted objects, but those objects no longer exist in the database. .. code-block:: python wrong_shot = Shot.query.filter_by(code="SH005").first() print(wrong_shot) # This will print None For More information -------------------- For advanced update and delete options (like cascades) in SQLAlchemy, refer to the official `SQLAlchemy documentation`_. .. _SQLAlchemy documentation: http://www.sqlalchemy.org/docs/orm/session.html ================================================ FILE: docs/source/tutorial/scheduling.rst ================================================ .. _tutorial_scheduling_toplevel: Scheduling ========== Now that we've defined tasks, resources, and dependencies, let's schedule our project! TaskJuggler Integration ----------------------- Stalker utilizes `TaskJuggler`_ to solve scheduling problems and determine when resources should work on specific tasks. .. warning:: * Ensure you have `TaskJuggler`_ installed on your system. * Configure Stalker to locate the ``tj3`` executable: * **Linux:** This is usually straightforward under Linux, just install `TaskJuggler`_ and Stalker will be able to use it. * **macOS & Windows:** Create a ``STALKER_PATH`` environment variable pointing to a folder containing a ``config.py`` file. Add the following line to ``config.py``: .. code-block:: python tj_command = r"C:\Path\to\tj3.exe" The default value for ``tj_command`` is ``/usr/local/bin/tj3``. If you run ``which tj3`` on Linux or macOS and it returns this value, no additional setup is needed. .. _TaskJuggler: http://www.taskjuggler.org/ Scheduling Your Project ----------------------- Let's schedule our project using the :class:`.Studio` instance that we've created at the beginning of this tutorial: .. code-block:: python from stalker import TaskJugglerScheduler my_studio.scheduler = TaskJugglerScheduler() # Set a large duration (e.g., 1 year) to avoid TaskJuggler complaining the # project is not fitting into the time frame. my_studio.duration = datetime.timedelta(days=365) my_studio.schedule(scheduled_by=me) DBsession.commit() # Save changes This process might take a few seconds for small project or long for larger ones. Viewing Scheduled Dates ----------------------- Once completed, each task will have its ``computed_start`` and ``computed_end`` values populated: .. code-block:: python for task in [previs, matchmove, anim, lighting, comp]: print("{:16s} {} -> {}".format( task.name, task.computed_start, task.computed_end )) Outputs: .. code-block:: shell Previs 2024-04-02 16:00 -> 2024-04-15 15:00 Matchmove 2024-04-15 15:00 -> 2024-04-17 13:00 Animation 2024-04-17 13:00 -> 2024-04-23 17:00 Lighting 2024-04-23 17:00 -> 2024-04-24 11:00 Comp 2024-04-24 11:00 -> 2024-04-24 17:00 Understanding the Output ------------------------ The output will display start and end dates for each task, reflecting the dependencies. In this example, since each task has only one assigned resource (you), they follow one another. Further Explorations -------------------- Scheduling is complex topic. For in-depth information, refer to the `TaskJuggler`_ documentation. TaskJuggler Project Representation ---------------------------------- You can check the ``to_tjp`` values of the data objects: .. code-block:: python print(my_studio.to_tjp) print(me.to_tjp) print(comp.to_tjp) print(new_project.to_tjp) If you're familiar with TaskJuggler, you'll recognize the output format. Stalker maps its data to TaskJuggler-compatible strings. Although, Stalker is currently supporting a subset of directives, it is enough for scheduling complex projects with intricate dependencies and hierarchies. Support for additional TaskJuggler directives will grow with future Stalker versions. ================================================ FILE: docs/source/tutorial/task_and_resource_management.rst ================================================ .. _tutorial_task_resource_management_toplevel: Task and Resource Management ============================ Now that we have created shots and tasks, we need to assign resources (users) to these tasks to complete the work. Let's assign ourselves to all the tasks: .. code-block:: python previs.resources = [me] previs.schedule_timing = 10 previs.schedule_unit = 'd' matchmove.resources = [me] matchmove.schedule_timing = 2 matchmove.schedule_unit = 'd' anim.resources = [me] anim.schedule_timing = 5 anim.schedule_unit = 'd' lighting.resources = [me] lighting.schedule_timing = 3 lighting.schedule_unit = 'd' comp.resources = [me] comp.schedule_timing = 6 comp.schedule_unit = 'h' Here, we've assigned ourselves as the resource for each task and specified the estimated time to complete the task using ``schedule_timing`` and ``schedule_unit`` attributes. Saving Changes -------------- To save these changes to the database: .. code-block:: python DBsession.commit() Note that we didn't explicitly add any new object to the session. Since all the tasks are related to the ``sh001`` shot, which is already tracked by the session, SQLAlchemy will automatically track and save the changes to the database. With this information, Stalker can now schedule these tasks, taking info account dependencies and resource availability. This will help you plan and manage your project more efficiently. ================================================ FILE: docs/source/tutorial/tutorial_files/tutorial.py ================================================ # -*- coding: utf-8 -*- import os import stalker.db.setup stalker.db.setup.setup({"sqlalchemy.url": "sqlite:///"}) stalker.db.setup.init() from stalker import Studio my_studio = Studio(name="My Great Studio") from stalker import User me = User( name="Erkan Ozgur Yilmaz", login="eoyilmaz", email="some_email_address@gmail.com", password="secret", description="This is me", ) from stalker import Department tds_department = Department(name="TDs", description="This is the TDs department") tds_department.users.append(me) print(me.departments) # you should get something like # [] from stalker.db.session import DBSession DBSession.add(my_studio) DBSession.add(me) DBSession.add(tds_department) DBSession.commit() all_departments = Department.query.all() print(all_departments) # This should print something like # [, ] # "admins" department is created by default admins = all_departments[0] tds = all_departments[1] all_users = tds.users # Department.users is a synonym for Department.members # they are essentially the same attribute print(all_users[0]) # this should print # # we will reuse the Statuses created by default (in db.init()) from stalker import Status status_new = Status.query.filter_by(code="NEW").first() status_wip = Status.query.filter_by(code="WIP").first() status_cmpl = Status.query.filter_by(code="CMPL").first() # a status list which is suitable for Project instances from stalker import StatusList, Project project_statuses = StatusList( name="Project Status List", statuses=[status_new, status_wip, status_cmpl], target_entity_type="Project" # you can also use Project which is the # class itself ) from stalker import Repository # and the repository itself commercial_repo = Repository(name="Commercial Repository", code="CR") new_project = Project( name="Fancy Commercial", code="FC", status_list=project_statuses, repositories=[commercial_repo], ) import tzlocal import datetime from stalker import ImageFormat new_project.description = """The commercial is about this fancy product. The client want us to have a shiny look with their product bla bla bla...""" new_project.image_format = ImageFormat(name="HD 1080", width=1920, height=1080) new_project.fps = 25 local_tz = tzlocal.get_localzone() new_project.end = datetime.datetime(2014, 5, 15, tzinfo=local_tz) new_project.users.append(me) DBSession.add(new_project) DBSession.commit() from stalker import Sequence seq1 = Sequence( name="Sequence 1", code="SEQ1", project=new_project, ) from stalker import Shot sh001 = Shot(name="SH001", code="SH001", project=new_project, sequences=[seq1]) sh002 = Shot(code="SH002", project=new_project, sequences=[seq1]) sh003 = Shot(code="SH003", project=new_project, sequences=[seq1]) DBSession.add_all([sh001, sh002, sh003]) DBSession.commit() sh004 = Shot(code="SH004", project=new_project, sequences=[seq1]) DBSession.add(sh004) DBSession.commit() sh004.code = "SH005" DBSession.commit() # first find the data wrong_shot = Shot.query.filter_by(code="SH005").first() # now update it wrong_shot.code = "SH004" # commit the changes to the database DBSession.commit() DBSession.delete(wrong_shot) DBSession.commit() wrong_shot = Shot.query.filter_by(code="SH005").first() print(wrong_shot) # should print None from stalker import Task previs = Task(name="Previs", parent=sh001) matchmove = Task(name="Matchmove", parent=sh001) anim = Task(name="Animation", parent=sh001) lighting = Task(name="Lighting", parent=sh001) comp = Task(name="comp", parent=sh001) comp.depends_on = [lighting] lighting.depends_on = [anim] anim.depends_on = [previs, matchmove] previs.resources = [me] previs.schedule_timing = 10 previs.schedule_unit = "d" matchmove.resources = [me] matchmove.schedule_timing = 2 matchmove.schedule_unit = "d" anim.resources = [me] anim.schedule_timing = 5 anim.schedule_unit = "d" lighting.resources = [me] lighting.schedule_timing = 3 lighting.schedule_unit = "d" comp.resources = [me] comp.schedule_timing = 6 comp.schedule_unit = "h" DBSession.commit() from stalker import TaskJugglerScheduler my_studio.scheduler = TaskJugglerScheduler() my_studio.duration = datetime.timedelta(days=365) # we are setting the my_studio.schedule(scheduled_by=me) # duration to 1 year just # to be sure that TJ3 # will not complain # about the project is not # fitting in to the time # frame. DBSession.commit() # to reflect the change print(previs.computed_start) # 2014-04-02 16:00:00 print(previs.computed_end) # 2014-04-15 15:00:00 print(matchmove.computed_start) # 2014-04-15 15:00:00 print(matchmove.computed_end) # 2014-04-17 13:00:00 print(anim.computed_start) # 2014-04-17 13:00:00 print(anim.computed_end) # 2014-04-23 17:00:00 print(lighting.computed_start) # 2014-04-23 17:00:00 print(lighting.computed_end) # 2014-04-24 11:00:00 print(comp.computed_start) # 2014-04-24 11:00:00 print(comp.computed_end) # 2014-04-24 17:00:00 print(my_studio.to_tjp) print(me.to_tjp) print(comp.to_tjp) print(new_project.to_tjp) commercial_repo.linux_path = "/mnt/M/commercials" commercial_repo.macos_path = "/Volumes/M/commercials" commercial_repo.windows_path = "M:/commercials" # you can use reverse slashes # (\\) if you want print(commercial_repo.path) # under Windows outputs: # M:/commercials # # in Linux and variants: # /mnt/M/commercials # # and in macOS: # /Volumes/M/commercials from stalker import Structure commercial_project_structure = Structure(name="Commercial Projects Structure") # now assign this structure to our project new_project.structure = commercial_project_structure from stalker import FilenameTemplate task_template = FilenameTemplate( name="Task Template for Commercials", target_entity_type="Task", path="$REPO{{project.repository.id}}/{{project.code}}/{%- for p in parent_tasks -%}{{p.nice_name}}/{%- endfor -%}", filename='{{version.nice_name}}_v{{"%03d"|format(version.version_number)}}', ) # and append it to our project structure commercial_project_structure.templates.append(task_template) # commit to database DBSession.commit() # no need to add anything, project is already on db from stalker import Version vers1 = Version(task=comp) # we need to update the paths path = vers1.generate_path() # check the path and filename print(path.parent) # '$REPO33/FC/SH001/comp' print(path.name) # 'SH001_comp_Main_v001' print(path) # '$REPO33/FC/SH001/comp/SH001_comp_Main_v001' # now the absolute values, values with repository root # because I'm running this code in a Linux laptop, my results are using the # linux path of the repository print(vers1.absolute_path) # '/mnt/M/commercials/FC/SH001/comp' print( vers1.absolute_full_path ) # '/mnt/M/commercials/FC/SH001/comp/SH001_comp_Main_v001' # check the version_number print(vers1.version_number) # 1 # commit to database DBSession.commit() vers1.is_published = False # I still work on this version, this is not a # usable one # be sure that you've committed the previous version to the database # to let Stalker now what number to give for the next version vers2 = Version(task=comp) vers2.generate_path() # this call probably will disappear in next version of # Stalker, so Stalker will automatically update the # paths on Version.__init__() print(vers2.version_number) # 2 print(vers2.filename) # 'SH001_comp_Main_v002' # before creating a new version commit this one to db DBSession.commit() # now create a new version vers3 = Version(task=comp) vers3.generate_path() print(vers3.version_number) # 3 print(vers3.filename) # 'SH001_comp_Main_v002' # using pure Python vers_from_python = comp.versions # [, # , # ] # or using a query vers_from_query = Version.query.filter_by(task=comp).all() # again returns # [, # , # ] assert vers_from_python == vers_from_query commercial_project_structure.custom_template = """ Temp References References/Movies References/Images """ ================================================ FILE: docs/source/tutorial.rst ================================================ .. _tutorial_toplevel: ============ API Tutorial ============ .. _tutorial_contents: Table of Contents ================= .. toctree:: :maxdepth: 3 tutorial/basics.rst tutorial/creating_simple_data.rst tutorial/query_update_delete_data.rst tutorial/pipeline.rst tutorial/task_and_resource_management.rst tutorial/scheduling.rst task_review_workflow.rst tutorial/asset_management.rst tutorial/collaboration.rst tutorial/extending_som.rst tutorial/conclusion.rst Introduction ============ Stalker leverages the powerful `SQLAlchemy ORM`_ to facilitate interaction with databases using the Stalker Object Model (SOM). This tutorial introduces you to the Stalker Python API and SOM. If you're familiar with SQLAlchemy, you'll find the transition smooth. Otherwise, SOM offers a user-friendly way to manage databases. .. _SQLAlchemy ORM: http://www.sqlalchemy.org/docs/orm/tutorial.html ================================================ FILE: docs/source/upgrade_db.rst ================================================ .. upgrade_db_toplevel: ================== Upgrading Database ================== Introduction ============ From time to time, with new releases of Stalker, your Stalker database may need to be upgraded. This is done with the `Alembic`_ library, which is a database migration library for `SQLAlchemy`_. .. _Alembic: http://alembic.zzzcomputing.com/en/latest/ .. _SQLAlchemy: http://www.sqlalchemy.org Instructions ============ The upgrade is easy, just run the following command on the root of the stalker installation directory:: # for Windows ..\Scripts\alembic.exe upgrade head # for Linux or macOS ../bin/alembic upgrade head # this should output something like that: # # INFO [alembic.runtime.migration] Context impl PostgresqlImpl. # INFO [alembic.runtime.migration] Will assume transactional DDL. # INFO [alembic.runtime.migration] Running upgrade 745b210e6907 -> f2005d1fbadc, added ProjectClients That's it, your database is now migrated to the latest version. ================================================ FILE: examples/__init__.py ================================================ ================================================ FILE: examples/extending/__init__.py ================================================ ================================================ FILE: examples/extending/camera_lens.py ================================================ # -*- coding: utf-8 -*- """ In this example we are going to extend the Stalker Object Model (SOM) with two new type of classes which are derived from the :class:`stalker.models.entity.Entity` class. One of our new classes is going to hold information about Camera, or more specifically it will hold the information of the Camera used on the set for a shooting. The Camera class should hold these information: * The make of the camera * The model of the camera * specifications like: * aperture gate * horizontal film back size * vertical film back size * cropping factor * media it uses (film or digital) * The web page of the product if available The other class is going to be the Lens. A Lens class should hold these information: * The make of the lens * The model of the lens * min focal length * max focal length (for zooms, it will be the same for prime lenses) * The web page of the product if available To make this example simple and to not introduce the :class:`~stalker.models.mixins.ReferenceMixin` in this example, which is explained in other examples, we are going to use simple STRINGs for the web page links of the manufacturer. And because we don't want to again make things complex we are not going to touch the :class:`stalker.models.shot.Shot` class which probably will benefit these two classes. In normal circumstances we would like to introduce a new class which derives from the original :class:`stalker.models.shot.Shot` and add these Camera and Lens relations to it. But again to not to make things complex we are just going to settle with these two. Don't forget that, for the sake of brevity we are skipping a lot of things while creating these classes, first of all we are not doing any validation on the data given to us. Secondly we are not using any properties, but we are giving the bare class variables to the users of our classes. And because we are not using any properties we are mapping the tables directly to our classes without setting up any synonyms for our attributes. """ from sqlalchemy import Column, Integer, Float, ForeignKey, String from stalker import Entity class Camera(Entity): """The Camera class holds basic information about the Camera used on the sets. Args: make (str): the make of the camera model (str): the model of the camera aperture_gate (float): the aperture gate opening distance horizontal_film_back (float): the horizontal length of the film back vertical_film_back (float): the vertical length of the film back web_page (str): the web page of the camera """ __tablename__ = "Cameras" __mapper_args__ = {"polymorphic_identity": "Camera"} camera_id = Column("id", Integer, ForeignKey("Entities.id"), primary_key=True) make = Column(String) model = Column(String) aperture_gate = Column(Float(precision=4), default=0) horizontal_film_back = Column(Float(presicion=4), default=0) vertical_film_back = Column(Float(precision=4), default=0) web_page = Column(String) def __init__( self, make="", model="", aperture_gate=0, horizontal_film_back=0, vertical_film_back=0, web_page="", **kwargs ): # pass all the extra data to the super (which is Entity) super(Camera, self).__init__(**kwargs) self.make = make self.model = model self.aperture_gate = aperture_gate self.horizontal_film_back = horizontal_film_back self.vertical_film_back = vertical_film_back self.web_page = web_page class Lens(Entity): """The Lens class holds data about lenses used in shootings Args: make (str): the make of the lens model (str): the model of the lens min_focal_length (float): the min_focal_length. max_focal_length (float): the max_focal_length web_page (str): the product web page """ __tablename__ = "Lenses" __mapper_args__ = {"polymorphic_identity": "Lens"} lens_id = Column("id", Integer, ForeignKey("Entities.id"), primary_key=True) make = Column(String) model = Column(String) min_focal_length = Column(Float(precision=1)) max_focal_length = Column(Float(precision=1)) web_page = Column(String) def __init__( self, make="", model="", min_focal_length=0, max_focal_length=0, web_page="", **kwargs ): # pass all the extra data to the super (which is Entity) super(Lens, self).__init__(**kwargs) self.make = make self.model = model self.min_focal_length = min_focal_length self.max_focal_length = max_focal_length self.web_page = web_page # now we have extended SOM with two new classes ================================================ FILE: examples/extending/great_entity.py ================================================ # -*- coding: utf-8 -*- """ In this example we are going to extend stalker with a new entity type, which is also mixed in with a :class:`stalker.models.mixins.ReferenceMixin`. To create your own data type, just derive it from a suitable SOM class. """ from sqlalchemy import Column, Integer, ForeignKey from stalker import SimpleEntity, ReferenceMixin class GreatEntity(SimpleEntity, ReferenceMixin): """The new great entity class, which is a new simpleEntity with ReferenceMixin.""" __tablename__ = "GreatEntities" __mapper_args__ = {"polymorphic_identity": "GreatEntity"} great_entity_id = Column( "id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ) ================================================ FILE: examples/extending/statused_entity.py ================================================ # -*- coding: utf-8 -*- """ In this example we are going to extend Stalker with a new entity type, which is also mixed in with :class:`stalker.models.mixins.StatusMixin`. """ from sqlalchemy import Column, Integer, ForeignKey from stalker import SimpleEntity, StatusMixin class NewStatusedEntity(SimpleEntity, StatusMixin): """The new statused entity class, which is a new simpleEntity with status abilities. """ __tablename__ = "NewStatusedEntities" __mapper_args__ = {"polymorphic_identity": "NewStatusedEntity"} new_statused_entity_id = Column( "id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ) # voilà now we have introduced a new type to the SOM and also mixed it with a # StatusMixin ================================================ FILE: examples/flat_project_example.py ================================================ # -*- coding: utf-8 -*- """This is an example which uses two different folder structure in two different projects. The first one prefers to use a flat one, in which all the files are in the same folder. The second project uses a more traditional folder structure where every Task/Asset/Shot/Sequence has its own folder and the Task hierarchy is directly reflected to folder hierarchy. """ import os import stalker.db.setup from stalker import ( db, Project, Repository, Structure, FilenameTemplate, Task, Status, StatusList, Version, Sequence, Shot, ) # initialize an in memory sqlite3 database stalker.db.setup.setup() # fill in default data stalker.db.setup.init() # create a new repository repo = Repository( name="Test Repository", linux_path="/mnt/T/stalker_tests/", macos_path="/Volumes/T/stalker_tests/", windows_path="T:/stalker_tests/", ) # create a Structure for our flat project flat_task_template = FilenameTemplate( name="Flat Task Template", target_entity_type="Task", path="{{project.code}}", # everything will be under the same folder filename="{{task.nice_name}}_{{version.variant_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}' # you can customize this as you wish, you can even use a uuid4 # as the file name ) flat_struct = Structure( name="Flat Project Structure", templates=[flat_task_template] # we need another template for Assets, # Shots and Sequences but I'm skipping it # for now ) # query a couple of statuses status_new = Status.query.filter_by(code="NEW").first() status_wip = Status.query.filter_by(code="WIP").first() status_cmpl = Status.query.filter_by(code="CMPL").first() proj_statuses = StatusList( name="Project Statuses", target_entity_type="Project", statuses=[status_new, status_wip, status_cmpl], ) p1 = Project( name="Flat Project Example", code="FPE", status_list=proj_statuses, repository=repo, structure=flat_struct, ) # now lets create a Task t1 = Task(name="Building 1", project=p1) t2 = Task(name="Model", parent=t1) t3 = Task(name="Lighting", parent=t1, depends_on=[t2]) # store all the data in the database db.DBSession.add_all([t1, t2, t3]) # this is enough to store the rest # lets create a Maya file for the Model task t2_v1 = Version(task=t1) # set the extension for maya path1 = t2_v1.generate_path(extension=".ma") # for now this is needed to render the template, but will # lets create a new version for Lighting t3_v1 = Version(task=t3) path2 = t3_v1.generate_path(extension=".ma") # you should see that all are in the same folder print(os.path.expandvars(path1)) print(os.path.expandvars(path2)) # # Lets create a second Project that use some other folder structure # # create a new project structure normal_task_template = FilenameTemplate( name="Flat Task Template", target_entity_type="Task", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", # all in different folder filename="{{task.nice_name}}_{{version.variant_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}', ) # we will use sequences and shots in this project so lets define a template # for each of the types # because we will use Sequences, Shots and Assets for this type of projects # we need to supply a new FilenameTemplate for each type (we will not do it # again for other new projects that will use this structure). # # Also, we can use the same template variables from the normal_task_template seq_template = FilenameTemplate( name="Sequence Template", target_entity_type="Sequence", path=normal_task_template.path, filename=normal_task_template.filename, ) shot_template = FilenameTemplate( name="Shot Template", target_entity_type="Shot", path=normal_task_template.path, filename=normal_task_template.filename, ) asset_template = FilenameTemplate( name="Asset Template", target_entity_type="Asset", path=normal_task_template.path, filename=normal_task_template.filename, ) normal_struct = Structure( name="Normal Project Structure", templates=[normal_task_template, seq_template, shot_template, asset_template], ) p2 = Project( name="Normal Project Example", code="NPE", status_list=proj_statuses, repository=repo, # can be freely in the same repo structure=normal_struct, # but uses a different structure ) # now create new tasks for the normal project seq1 = Sequence(name="Sequence", code="SEQ001", project=p2) shot1 = Shot(name="SEQ001_0010", code="SEQ001_0010", parent=seq1, sequence=seq1) comp = Task(name="Comp", parent=shot1) # you probably will supply a different name/code # it is a good idea to commit the data now db.DBSession.add(shot1) # this should be enough to add the rest # now create new maya files for them comp_v1 = Version(task=comp, variant_name="Test") path = comp_v1.generate_path(extension=".ma") # as you see it is in a proper shot folder print(os.path.expandvars(path)) ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] authors = [ {name = "Erkan Özgür Yılmaz", email = "eoyilmaz@gmail.com"}, ] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Operating System :: OS Independent", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Topic :: Database", "Topic :: Software Development", "Topic :: Utilities", "Topic :: Office/Business :: Scheduling", ] description = "A Production Asset Management (ProdAM) System" dynamic = ["version", "dependencies"] keywords = [ "production", "asset", "management", "vfx", "animation", "maya", "houdini", "nuke", "fusion", "softimage", "blender", "vue", ] license = { file = "LICENSE" } maintainers = [ {name = "Erkan Özgür Yılmaz", email = "eoyilmaz@gmail.com"}, ] name = "stalker" readme = "README.md" requires-python = ">= 3.8" [project.urls] "Home Page" = "https://github.com/eoyilmaz/stalker" GitHub = "https://github.com/eoyilmaz/stalker" Documentation = "https://stalker.readthedocs.io" Repository = "https://github.com/eoyilmaz/stalker.git" [tool.setuptools] include-package-data = true [tool.setuptools.packages.find] where = ["src"] [tool.setuptools.package-data] stalker = ["VERSION", "py.typed"] [tool.setuptools.exclude-package-data] stalker = ["alembic", "docs", "tests"] [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } optional-dependencies.test = { file = ["requirements-dev.txt"] } version = { file = ["VERSION"] } [tool.distutils.bdist_wheel] universal = false [tool.pytest.ini_options] pythonpath = ["."] addopts = "-n auto -W ignore -W always::DeprecationWarning --color=yes --cov=src --cov-report term --cov-report html --cov-append tests" [tool.black] [tool.flake8] exclude = [ ".github", "__pycache__", ".coverage", ".DS_Store", ".pytest_cache", ".venv", ".vscode", "build", "dist", "stalker.egg-info", ] extend-select = ["B950"] ignore = ["D107", "E203", "E501", "E701", "SC200", "W503"] max-complexity = 12 max-line-length = 80 [tool.tox] requires = ["tox>=4.23.2"] env_list = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] [tool.tox.env_run_base] description = "run the tests with pytest" package = "wheel" wheel_build_env = ".pkg" set_env = { SQLALCHEMY_WARN_20 = "1" } deps = [ "pytest>=6", "pytest-cov", "pytest-xdist", ] commands = [ ["pytest"], ] [tool.mypy] ================================================ FILE: requirements-dev.txt ================================================ black coverage darglint flake8 flake8-bugbear flake8-docstrings flake8-import-order flake8-mutable flake8-pep3101 flake8-spellcheck flake8-pyproject furo mypy pyglet pytest pytest-cov pytest-github-actions-annotate-failures pytest-xdist sphinx sphinx-autoapi # sphinx-findthedocs tox twine types-pytz wheel ================================================ FILE: requirements.txt ================================================ alembic build jinja2 psycopg2-binary pytz six sqlalchemy >= 2 tzlocal ================================================ FILE: setup.py ================================================ # -*- coding: utf-8 -*- from setuptools import setup setup() ================================================ FILE: src/stalker/VERSION ================================================ 1.1.2.1 ================================================ FILE: src/stalker/__init__.py ================================================ # -*- coding: utf-8 -*- """Stalker is a Production Asset Management (ProdAM) designed for Animation/VFX Studios. See docs for more information. """ from stalker.version import __version__ # noqa: F401 from stalker import config, log # noqa: I100 if True: defaults: config.Config = config.Config() from stalker.models.asset import Asset from stalker.models.auth import ( AuthenticationLog, Group, LocalSession, Permission, Role, User, ) from stalker.models.budget import Budget, BudgetEntry, Good, Invoice, Payment, PriceList from stalker.models.client import Client, ClientUser from stalker.models.department import Department, DepartmentUser from stalker.models.entity import Entity, EntityGroup, SimpleEntity from stalker.models.format import ImageFormat from stalker.models.file import File from stalker.models.message import Message from stalker.models.mixins import ( ACLMixin, AmountMixin, CodeMixin, DAGMixin, DateRangeMixin, ProjectMixin, ReferenceMixin, ScheduleMixin, StatusMixin, TargetEntityTypeMixin, UnitMixin, WorkingHoursMixin, ) from stalker.models.note import Note from stalker.models.project import ( Project, ProjectClient, ProjectRepository, ProjectUser, ) from stalker.models.repository import Repository from stalker.models.review import Daily, DailyFile, Review from stalker.models.scene import Scene from stalker.models.schedulers import SchedulerBase, TaskJugglerScheduler from stalker.models.sequence import Sequence from stalker.models.shot import Shot from stalker.models.status import Status, StatusList from stalker.models.structure import Structure from stalker.models.studio import Studio, Vacation, WorkingHours from stalker.models.tag import Tag from stalker.models.task import Task, TaskDependency, TimeLog from stalker.models.template import FilenameTemplate from stalker.models.ticket import Ticket, TicketLog from stalker.models.type import EntityType, Type from stalker.models.variant import Variant from stalker.models.version import Version from stalker.models.wiki import Page __all__ = [ "ACLMixin", "AmountMixin", "Asset", "AuthenticationLog", "Budget", "BudgetEntry", "Client", "ClientUser", "CodeMixin", "DAGMixin", "Daily", "DailyFile", "DateRangeMixin", "Department", "DepartmentUser", "Entity", "EntityGroup", "EntityType", "File", "FilenameTemplate", "Good", "Group", "ImageFormat", "Invoice", "LocalSession", "Message", "Note", "Page", "Payment", "Permission", "PriceList", "Project", "ProjectClient", "ProjectMixin", "ProjectRepository", "ProjectUser", "ReferenceMixin", "Repository", "Review", "Role", "Scene", "ScheduleMixin", "SchedulerBase", "Sequence", "Shot", "SimpleEntity", "Status", "StatusList", "StatusMixin", "Structure", "Studio", "Tag", "TargetEntityTypeMixin", "Task", "TaskDependency", "TaskJugglerScheduler", "Ticket", "TicketLog", "TimeLog", "Type", "UnitMixin", "User", "Vacation", "Variant", "Version", "WorkingHours", "WorkingHoursMixin", ] logger = log.get_logger(__name__) ================================================ FILE: src/stalker/config.py ================================================ # -*- coding: utf-8 -*- """Config related functions and classes are situated here.""" import datetime import os import sys from typing import Any, Dict from stalker import log logger = log.get_logger(__name__) class ConfigBase(object): """Config abstraction. This is based on Sphinx's config idiom. """ default_config_values: Dict[str, Any] = {} def __init__(self) -> None: self.config_values = self.default_config_values.copy() self.user_config: Dict[str, Any] = {} self._parse_settings() def _parse_settings(self) -> None: """Parse the settings. The priority order is: stalker.config config.py under .stalker_rc directory config.py under $STALKER_PATH Raises: RuntimeError: If there is a Syntax error in the configuration. """ # for now just use $STALKER_PATH # try to get the environment variable if self.env_key not in os.environ: # don't do anything logger.debug("no environment key found for user settings") else: logger.debug("environment key found") resolved_path = os.path.expanduser( os.path.join(os.environ[self.env_key], "config.py") ) # using `while` is not safe to expand variables # so expand vars for 100 times which already is ridiculously # complex max_recursion = 100 i = 0 while "$" in resolved_path and i < max_recursion: resolved_path = os.path.expandvars(resolved_path) i += 1 try: logger.debug("importing user config") with open(resolved_path) as f: exec(f.read(), self.user_config) except IOError: logger.warning( f"The $STALKER_PATH: {resolved_path} doesn't exists! " "skipping user config" ) except SyntaxError as e: raise RuntimeError( f"There is a syntax error in your configuration file: {e}" ) finally: # append the data to the current settings logger.debug("updating system config") for key in self.user_config: # if key in self.config_values: self.config_values[key] = self.user_config[key] def __getattr__(self, name: str) -> Any: """Return the config value as if it is an attribute look up. Args: name (str): The name of the config value. Returns: Any: The value related to the given config value. """ return self.config_values[name] def __getitem__(self, name: str) -> Any: """Return item with the key. Args: name (str): The key to find the value of. Returns: Any: The value related to the given key. """ return getattr(self, name) def __setitem__(self, name: str, value: Any) -> None: """Set the item with index of name to value. Args: name (str): The name as the index. value (Any): The value to set the item to. """ self.config_values[name] = value def __delitem__(self, name: str) -> None: """Delete the item with the given name. Args: name (str): The name of the item to delete. """ self.config_values.pop(name) def __contains__(self, name: str) -> bool: """Check if this contains the name. Args: name (str): The config name. Returns: bool: True if this contains the name, False otherwise. """ return name in self.config_values class Config(ConfigBase): """Holds system-wide configuration variables. See `configuring stalker`_ for more detail. .. _configuring stalker: ../configure.html """ env_key = "STALKER_PATH" default_config_values = dict( # # The default settings for the database, see sqlalchemy.create_engine # for possible parameters # database_engine_settings={ "sqlalchemy.url": "sqlite://", "sqlalchemy.echo": False, # "sqlalchemy.pool_pre_ping": True, }, database_session_settings={}, # Local storage path local_storage_path=os.path.expanduser("~/.strc"), local_session_data_file_name="local_session_data", # Storage for uploaded files server_side_storage_path=os.path.expanduser("~/Stalker_Storage"), repo_env_var_template="REPO{code}", # # Tells Stalker to create an admin by default # auto_create_admin=True, # # these are for new projects # after creating the project you can change them from the interface # admin_name="admin", admin_login="admin", admin_password="admin", admin_email="admin@admin.com", admin_department_name="admins", admin_group_name="admins", # the default keyword which is going to be used in password scrambling key="stalker_default_key", actions=["Create", "Read", "Update", "Delete", "List"], # CRUDL # Tickets ticket_label="Ticket", # define the available actions per Status ticket_status_names=["New", "Accepted", "Assigned", "Reopened", "Closed"], ticket_status_codes=["NEW", "ACP", "ASG", "ROP", "CLS"], ticket_resolutions=[ "fixed", "invalid", "wontfix", "duplicate", "worksforme", "cantfix", ], ticket_workflow={ "resolve": { "New": {"new_status": "Closed", "action": "set_resolution"}, "Accepted": {"new_status": "Closed", "action": "set_resolution"}, "Assigned": {"new_status": "Closed", "action": "set_resolution"}, "Reopened": {"new_status": "Closed", "action": "set_resolution"}, }, "accept": { "New": {"new_status": "Accepted", "action": "set_owner"}, "Accepted": {"new_status": "Accepted", "action": "set_owner"}, "Assigned": {"new_status": "Accepted", "action": "set_owner"}, "Reopened": {"new_status": "Accepted", "action": "set_owner"}, }, "reassign": { "New": {"new_status": "Assigned", "action": "set_owner"}, "Accepted": {"new_status": "Assigned", "action": "set_owner"}, "Assigned": {"new_status": "Assigned", "action": "set_owner"}, "Reopened": {"new_status": "Assigned", "action": "set_owner"}, }, "reopen": { "Closed": {"new_status": "Reopened", "action": "del_resolution"} }, }, # Task Management timing_resolution=datetime.timedelta(hours=1), task_priority=500, working_hours={ "mon": [[540, 1080]], # 9:00 - 18:00 "tue": [[540, 1080]], # 9:00 - 18:00 "wed": [[540, 1080]], # 9:00 - 18:00 "thu": [[540, 1080]], # 9:00 - 18:00 "fri": [[540, 1080]], # 9:00 - 18:00 "sat": [], # saturday off "sun": [], # sunday off }, # this is strongly related with the working_hours settings, # this should match each other daily_working_hours=9, weekly_working_days=5, weekly_working_hours=45, yearly_working_days=261, # math.ceil(5 * 52.1428) day_order=["mon", "tue", "wed", "thu", "fri", "sat", "sun"], datetime_units=["min", "h", "d", "w", "m", "y"], datetime_unit_names=["minute", "hour", "day", "week", "month", "year"], datetime_units_to_timedelta_kwargs={ "min": {"name": "minutes", "multiplier": 1}, "h": {"name": "hours", "multiplier": 1}, "d": {"name": "days", "multiplier": 1}, "w": {"name": "weeks", "multiplier": 1}, "m": {"name": "days", "multiplier": 30}, "y": {"name": "days", "multiplier": 365}, }, task_status_names=[ "Waiting For Dependency", "Ready To Start", "Work In Progress", "Pending Review", "Has Revision", "Dependency Has Revision", "On Hold", "Stopped", "Completed", ], task_status_codes=[ "WFD", "RTS", "WIP", "PREV", "HREV", "DREV", "OH", "STOP", "CMPL", ], project_status_names=["Ready To Start", "Work In Progress", "Completed"], project_status_codes=["RTS", "WIP", "CMPL"], review_status_names=["New", "Requested Revision", "Approved"], review_status_codes=["NEW", "RREV", "APP"], daily_status_names=["Open", "Closed"], daily_status_codes=["OPEN", "CLS"], task_schedule_models=["effort", "length", "duration"], task_dependency_gap_models=["length", "duration"], task_dependency_targets=["onend", "onstart"], allocation_strategy=[ "minallocated", "maxloaded", "minloaded", "order", "random", ], persistent_allocation=True, tjp_main_template2="""# Generated By Stalker v{{stalker.__version__}} {{studio.to_tjp}} # resources resource resources "Resources" { {%- for vacation in studio.vacations %} {{vacation.to_tjp}} {%- endfor %} {%- for user in studio.users %} {{user.to_tjp}} {%- endfor %} } # tasks {{ tasks_buffer }} # reports taskreport breakdown "{{csv_file_name}}"{ formats csv timeformat "%Y-%m-%d-%H:%M" columns id, start, end {%- if compute_resources %}, resources{% endif %} }""", tj_command="tj3" if sys.platform == "win32" else "/usr/local/bin/tj3", path_template="{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%}", # noqa: B950 filename_template='{{version.nice_name}}_r{{"%02d"|format(version.revision_number)}}_v{{"%03d"|format(version.version_number)}}', # noqa: B950 # -------------------------------------------- # the following settings came from oyProjectManager sequence_format="%h%p%t %R", file_size_format="%.2f MB", date_time_format="%Y.%m.%d %H:%M", resolution_presets={ "PC Video": [640, 480, 1.0], "NTSC": [720, 486, 0.91], "NTSC 16:9": [720, 486, 1.21], "PAL": [720, 576, 1.067], "PAL 16:9": [720, 576, 1.46], "HD 720": [1280, 720, 1.0], "HD 1080": [1920, 1080, 1.0], "1K Super 35": [1024, 778, 1.0], "2K Super 35": [2048, 1556, 1.0], "4K Super 35": [4096, 3112, 1.0], "A4 Portrait": [2480, 3508, 1.0], "A4 Landscape": [3508, 2480, 1.0], "A3 Portrait": [3508, 4960, 1.0], "A3 Landscape": [4960, 3508, 1.0], "A2 Portrait": [4960, 7016, 1.0], "A2 Landscape": [7016, 4960, 1.0], "50x70cm Poster Portrait": [5905, 8268, 1.0], "50x70cm Poster Landscape": [8268, 5905, 1.0], "70x100cm Poster Portrait": [8268, 11810, 1.0], "70x100cm Poster Landscape": [11810, 8268, 1.0], "1k Square": [1024, 1024, 1.0], "2k Square": [2048, 2048, 1.0], "3k Square": [3072, 3072, 1.0], "4k Square": [4096, 4096, 1.0], }, default_resolution_preset="HD 1080", project_structure="""{% for shot in project.shots %} Shots/{{shot.code}} Shots/{{shot.code}}/Plate Shots/{{shot.code}}/Reference Shots/{{shot.code}}/Texture {% endfor %} {% for asset in project.assets%} {% set asset_path = project.full_path + '/Assets/' + asset.type.name + '/' + asset.code %} {{asset_path}}/Texture {{asset_path}}/Reference {% endfor %} """, # noqa: B950 thumbnail_format="jpg", thumbnail_quality=70, thumbnail_size=[320, 180], ) ================================================ FILE: src/stalker/db/__init__.py ================================================ ================================================ FILE: src/stalker/db/declarative.py ================================================ # -*- coding: utf-8 -*- """The declarative base class is situated here.""" import logging from typing import Any, Type from sqlalchemy.orm import declarative_base from stalker.db.session import DBSession from stalker.log import get_logger from stalker.utils import make_plural logger: logging.Logger = get_logger(__name__) class ORMClass(object): """The base of the Base class.""" query = DBSession.query_property() @property def plural_class_name(self) -> str: """Return plural name of this class. Returns: str: The plural version of this class. """ return make_plural(self.__class__.__name__) Base: Type[Any] = declarative_base(cls=ORMClass) ================================================ FILE: src/stalker/db/session.py ================================================ # -*- coding: utf-8 -*- """The venerable DBSession is situated here. This is a runtime storage for the DB session. Greatly simplifying the usage of a scoped session. """ from typing import Any, List, TYPE_CHECKING, Union from sqlalchemy.orm import scoped_session, sessionmaker if TYPE_CHECKING: # pragma: no cover from stalker.models.entity import SimpleEntity class ExtendedScopedSession(scoped_session): """A customized scoped_session which adds new functionality.""" def save(self, data: Union[None, List[Any], "SimpleEntity"] = None) -> None: """Add and commits data at once. Args: data (Union[list, stalker.models.entity.SimpleEntity]): Either a single or a list of :class:`stalker.models.entity.SimpleEntity` or derivatives. """ if data is not None: if isinstance(data, list): self.add_all(data) else: self.add(data) self.commit() DBSession = ExtendedScopedSession(sessionmaker(future=True)) ================================================ FILE: src/stalker/db/setup.py ================================================ # -*- coding: utf-8 -*- """Database module of Stalker. Whenever stalker.db or something under it imported, the :func:`stalker.db.setup` becomes available to let one set up the database. """ import logging import os from typing import Any, Dict, List, Optional, Union from sqlalchemy import Column, Table, Text, engine_from_config, text from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError from stalker import ( DateRangeMixin, Department, EntityType, Group, Permission, ReferenceMixin, Repository, ScheduleMixin, Status, StatusList, StatusMixin, Studio, Type, User, defaults, log, ) from stalker.db.declarative import Base from stalker.db.session import DBSession logger: logging.Logger = log.get_logger(__name__) # TODO: Try to get it from the API (it was not working inside a package before) alembic_version: str = "9f9b88fef376" def setup(settings: Optional[Dict[str, Any]] = None) -> None: """Connect the system to the given database. If the database is None then it sets up using the default database in the settings file. Args: settings (Dict[str, Any]): This is a dictionary which has keys prefixed with "sqlalchemy" and shows the settings. The most important one is the engine. The default is None, and in this case it uses the settings from stalker.config.Config.database_engine_settings """ if settings is None: settings = defaults.database_engine_settings logger.debug("no settings given, using the default setting") # logger.debug(f"settings: {settings}") # create engine logger.debug(f"settings: {settings}") engine = engine_from_config(settings, "sqlalchemy.") logger.debug(f"engine: {engine}") # create the Session class DBSession.remove() DBSession.configure(bind=engine) # check alembic versions of the database # and raise an error if it is not matching with the system check_alembic_version() # create the database logger.debug("creating the tables") Base.metadata.create_all(engine) DBSession.commit() # update defaults update_defaults_with_studio() # create repo env variables create_repo_vars() def update_defaults_with_studio() -> None: """Update the default values from Studio instance. Update only if a database and a Studio instance is present. """ with DBSession.no_autoflush: studio = Studio.query.first() # studio = DBSession.query(Studio).first() logger.debug("studio: {}".format(studio)) if studio: logger.debug("found a studio, updating defaults") studio.update_defaults() def init() -> None: """Fill the database with default values.""" logger.debug("initializing database") # register all Actions available for all SOM classes class_names = [ "Asset", "AuthenticationLog", "Budget", "BudgetEntry", "Client", "Daily", "Department", "Entity", "EntityGroup", "File", "FilenameTemplate", "Good", "Group", "ImageFormat", "Invoice", "Message", "Note", "Page", "Payment", "Permission", "PriceList", "Project", "Repository", "Review", "Role", "Scene", "Sequence", "Shot", "SimpleEntity", "Status", "StatusList", "Structure", "Studio", "Tag", "Task", "Ticket", "TicketLog", "TimeLog", "Type", "User", "Vacation", "Variant", "Version", ] for class_name in class_names: _temp = __import__("stalker", globals(), locals(), [class_name], 0) class_ = eval("_temp.{}".format(class_name)) register(class_) # create the admin if needed admin = None if defaults.auto_create_admin: admin = __create_admin__() # create statuses create_ticket_statuses() # create statuses for Tickets create_entity_statuses( entity_type="Daily", status_names=defaults.daily_status_names, status_codes=defaults.daily_status_codes, user=admin, ) create_entity_statuses( entity_type="Project", status_names=defaults.project_status_names, status_codes=defaults.project_status_codes, user=admin, ) create_entity_statuses( entity_type="Task", status_names=defaults.task_status_names, status_codes=defaults.task_status_codes, user=admin, ) create_entity_statuses( entity_type="Review", status_names=defaults.review_status_names, status_codes=defaults.review_status_codes, user=admin, ) # create alembic revision table create_alembic_table() logger.debug("finished initializing the database") def create_repo_vars() -> None: """Create environment variables for all the repositories in the current database.""" # get all the repositories all_repos = Repository.query.all() for repo in all_repos: os.environ[repo.env_var] = repo.path def get_alembic_version() -> Union[None, str]: """Return the alembic version of the database. Returns: str: The alembic version. """ # try to query the version value conn = DBSession.connection() engine = conn.engine if not engine.dialect.has_table(conn, "alembic_version"): return None sql_query = "select version_num from alembic_version" try: return DBSession.connection().execute(text(sql_query)).fetchone()[0] except (OperationalError, ProgrammingError, TypeError): DBSession.rollback() return None def check_alembic_version() -> None: """Check the alembic version of the database. Raises: ValueError: If the alembic version is not matching with current version of Stalker. """ current_alembic_version = get_alembic_version() logger.debug(f"current_alembic_version: {current_alembic_version}") if current_alembic_version and current_alembic_version != alembic_version: # invalidate the connection DBSession.connection().invalidate() # and raise a ValueError (which I'm not sure is the correct exception) raise ValueError(f"Please update the database to version: {alembic_version}") def create_alembic_table() -> None: """Create the default alembic_version table. Also create the data so that any new database will be considered as the latest version. """ # Now, this is not the correct way of doing this, there is a proper way of # doing it and it is explained nicely in the Alembic library documentation. # # But it is simply not working when Stalker is installed as a package. # # So as a workaround here we are doing it manually # don't forget to update the version_num (and the corresponding test # whenever a new alembic revision is created) version_num = alembic_version table_name = "alembic_version" conn = DBSession.connection() engine = conn.engine # check if the table is already there table = Table( table_name, Base.metadata, Column("version_num", Text), extend_existing=True ) if not engine.dialect.has_table(conn, table_name): logger.debug("creating alembic_version table") # create the table no matter if it exists or not we need it either way Base.metadata.create_all(engine) # first try to query the version value sql_query = "select version_num from alembic_version" try: version_num = DBSession.connection().execute(text(sql_query)).fetchone()[0] except TypeError: logger.debug(f"inserting {version_num} to alembic_version table") # the table is there but there is no value so insert it ins = table.insert().values(version_num=version_num) DBSession.connection().execute(ins) DBSession.commit() logger.debug("alembic_version table is created and initialized") else: # the value is there do not touch the table logger.debug("alembic_version table is already there, not doing anything!") def __create_admin__() -> User: """Create the admin. Returns: User: The admin user. """ logger.debug("creating the default administrator user") # create the admin department admin_department = Department.query.filter_by( name=defaults.admin_department_name ).first() if not admin_department: admin_department = Department(name=defaults.admin_department_name) DBSession.add(admin_department) DBSession.commit() # create the admins group admins_group = Group.query.filter_by(name=defaults.admin_group_name).first() if not admins_group: admins_group = Group(name=defaults.admin_group_name) DBSession.add(admins_group) # check if there is already an admin in the database admin = User.query.filter_by(name=defaults.admin_name).first() if admin: # there should be an admin user do nothing logger.debug("there is an admin already") return admin else: admin = User( name=defaults.admin_name, login=defaults.admin_login, password=defaults.admin_password, email=defaults.admin_email, departments=[admin_department], groups=[admins_group], ) DBSession.add(admin) DBSession.commit() # admin.created_by = admin # admin.updated_by = admin # update the department as created and updated by admin user admin_department.created_by = admin admin_department.updated_by = admin admins_group.created_by = admin admins_group.updated_by = admin DBSession.add(admin) DBSession.commit() return admin def create_ticket_statuses() -> None: """Create the default ticket statuses.""" # create as admin admin = User.query.filter(User.login == defaults.admin_name).first() # create statuses for Tickets ticket_names = defaults.ticket_status_names ticket_codes = defaults.ticket_status_codes create_entity_statuses("Ticket", ticket_names, ticket_codes, admin) # Again I hate doing this in this way types = Type.query.filter_by(target_entity_type="Ticket").all() t_names = [t.name for t in types] # create Ticket Types logger.debug("Creating Ticket Types") if "Defect" not in t_names: ticket_type_1 = Type( name="Defect", code="Defect", target_entity_type="Ticket", created_by=admin, updated_by=admin, ) DBSession.add(ticket_type_1) if "Enhancement" not in t_names: ticket_type_2 = Type( name="Enhancement", code="Enhancement", target_entity_type="Ticket", created_by=admin, updated_by=admin, ) DBSession.add(ticket_type_2) try: DBSession.commit() except IntegrityError: DBSession.rollback() logger.debug("Ticket Types are already in the database!") else: # DBSession.flush() logger.debug("Ticket Types are created successfully") def create_entity_statuses( entity_type: str = "", status_names: Optional[List[str]] = None, status_codes: Optional[List[str]] = None, user: Optional[User] = None, ) -> None: """Create the default task statuses. Args: entity_type (str): The entity type. status_names (List[str]): The list of status names. status_codes (List[str]): The list of status codes in the same order of the ``status_names`` args. user (stalker.model.auth.User): The :class:`stalker.model.auth.User` instance to use as the creator for the newly created data. Raises: ValueError: If entity_type, status_names or status_codes are empty. """ if not entity_type: DBSession.rollback() raise ValueError("Please supply entity_type") if not status_names: DBSession.rollback() raise ValueError("Please supply status names") if not status_codes: DBSession.rollback() raise ValueError("Please supply status codes") # create statuses for entity logger.debug(f"Creating {entity_type} Statuses") with DBSession.no_autoflush: statuses = Status.query.filter(Status.name.in_(status_names)).all() logger.debug(f"status_names: {status_names}") logger.debug(f"statuses: {statuses}") status_names_in_db = list(map(lambda x: x.name, statuses)) logger.debug(f"statuses_names_in_db: {status_names_in_db}") for name, code in zip(status_names, status_codes): if name not in status_names_in_db: logger.debug(f"Creating Status: {name} ({code})") new_status = Status(name=name, code=code, created_by=user, updated_by=user) statuses.append(new_status) DBSession.add(new_status) else: logger.debug(f"Status {name} ({code}) is already created skipping!") # create the Status List status_list = StatusList.query.filter( StatusList.target_entity_type == entity_type ).first() if status_list is None: logger.debug(f"No {entity_type} Status List found, creating new!") status_list = StatusList( name=f"{entity_type} Statuses", target_entity_type=entity_type, created_by=user, updated_by=user, ) else: logger.debug(f"{entity_type} Status List already created, updating statuses") status_list.statuses = statuses DBSession.add(status_list) try: DBSession.commit() except (IntegrityError, OperationalError) as e: logger.debug(f"error in DBSession.commit, rolling back: {e}") DBSession.rollback() else: logger.debug(f"Created {entity_type} Statuses successfully") DBSession.flush() def register(class_: Type) -> None: """Register the given class to the database. It is mainly used to create the :class:`.Action` s needed for the :class:`.User` s and :class:`.Group` s to be able to interact with the given class. Whatever class you have created needs to be registered. Example, let's say that you have a data class which is specific to your studio and it is not present in Stalker Object Model (SOM), so you need to extend SOM with a new data type. Here is a simple Data class inherited from the :class:`.SimpleEntity` class (which is the simplest class you should inherit your classes from or use more complex classes down to the hierarchy):: from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker.models.entity import SimpleEntity class MyDataClass(SimpleEntity): '''This is an example class holding a studio specific data which is not present in SOM. ''' __tablename__ = 'MyData' __mapper_arguments__ = {'polymorphic_identity': 'MyData'} my_data_id : Mapped[int] = mapped_column( 'id', ForeignKey('SimpleEntities.c.id'), primary_key=True, ) Now because Stalker is using Pyramid authorization mechanism it needs to be able to have an :class:`.Permission` about your new class, so you can assign this :class;`.Permission` to your :class:`.User` s or :class:`.Group` s. So you need to register your new class with :func:`stalker.db.register` like shown below:: .. code-block: python from stalker.db import setup setup.register(MyDataClass) This will create the necessary Actions in the 'Actions' table on your database, then you can create :class:`.Permission` s and assign these to your :class:`.User` s and :class:`.Group` s so they are Allowed or Denied to do the specified Action. Args: class_ (Type): The class itself that needs to be registered. Raises: TypeError: If the class_ arg is not a ``type`` instance. """ # create the Permissions permissions_db = Permission.query.all() if not isinstance(class_, type): raise TypeError("To register a class please supply the class itself.") # register the class name to entity_types table class_name = class_.__name__ if not EntityType.query.filter_by(name=class_name).first(): new_entity_type = EntityType(class_name) # update attributes if issubclass(class_, StatusMixin): new_entity_type.statusable = True if issubclass(class_, DateRangeMixin): new_entity_type.dateable = True if issubclass(class_, ScheduleMixin): new_entity_type.schedulable = True if issubclass(class_, ReferenceMixin): new_entity_type.accepts_references = True DBSession.add(new_entity_type) for action in defaults.actions: for access in ["Allow", "Deny"]: permission_obj = Permission(access, action, class_name) if permission_obj not in permissions_db: DBSession.add(permission_obj) try: DBSession.commit() except IntegrityError: DBSession.rollback() ================================================ FILE: src/stalker/db/types.py ================================================ # -*- coding: utf-8 -*- """Stalker specific data types are situated here.""" import datetime import json from typing import Any, Dict, TYPE_CHECKING, Union import pytz from sqlalchemy.types import DateTime, JSON, TEXT, TypeDecorator import tzlocal if TYPE_CHECKING: # pragma: no cover from sqlalchemy.engine.interfaces import Dialect class JSONEncodedDict(TypeDecorator): """Stores and retrieves JSON as TEXT.""" impl = TEXT def process_bind_param(self, value: Union[None, Any], dialect: "Dialect") -> str: """Process bind param. Args: value (Union[None, Any]): The object to convert to JSON. dialect (sqlalchemy.engine.interface.Dialect): The dialect. Returns: str: The str representation of the JSON data. """ if value is not None: value = json.dumps(value) return value def process_result_value( self, value: Union[None, str], dialect: "Dialect" ) -> Union[None, Dict[str, Any]]: """Process result value. Args: value (Union[None, Any]): The str representation of the JSON data. dialect (sqlalchemy.engine.interface.Dialect): The dialect. Returns: dict: The dict representation of the JSON data. """ return_value: Union[None, Dict[str, Any]] = None if value is not None: return_value = json.loads(value) return return_value GenericJSON = JSON().with_variant(JSONEncodedDict, "sqlite") """A JSON variant that can be used both for PostgreSQL and SQLite3 It will be native JSON for PostgreSQL and JSONEncodedDict for SQLite3 """ class DateTimeUTC(TypeDecorator): """Store UTC internally without the timezone info. Inject timezone info as the data comes back from db. """ impl = DateTime def process_bind_param(self, value: Any, dialect: str) -> datetime.datetime: """Process bind param. Args: value (Any): The value. dialect (str): The dialect. Returns: datetime.datetime: The datetime value with UTC timezone. """ if value is not None: # convert the datetime object to have UTC # and strip the datetime value out (which is automatic for SQLite3) value = value.astimezone(pytz.utc) return value def process_result_value(self, value: Any, dialect: str) -> datetime.datetime: """Process result value. Args: value (Any): The value. dialect (str): The dialect. Returns: datetime.datetime: The datetime value with UTC timezone. """ if value is not None: # inject utc and then convert to local timezone local_tz = tzlocal.get_localzone() value = value.replace(tzinfo=pytz.utc).astimezone(local_tz) return value GenericDateTime = DateTime(timezone=True).with_variant(DateTimeUTC, "sqlite") """A DateTime variant that can be used with both PostgreSQL and SQLite3 and adds support to timezones in SQLite3. """ ================================================ FILE: src/stalker/exceptions.py ================================================ # -*- coding: utf-8 -*- """Errors for the system. This module contains the Errors in Stalker. """ class LoginError(Exception): """Raised when the login information is not correct.""" def __init__(self, value="") -> None: super(LoginError, self).__init__(value) self.value = value def __str__(self) -> str: """Return the string representation of this exception. Returns: str: The string representation of this exception. """ return self.value class CircularDependencyError(Exception): """Raised when there is circular dependencies within Tasks.""" def __init__(self, value="") -> None: super(CircularDependencyError, self).__init__(value) self.value = value def __str__(self) -> str: """Return the string representation of this exception. Returns: str: The string representation of this exception. """ return self.value class OverBookedError(Exception): """Raised when a resource is booked more than once for the same time period.""" def __init__(self, value="") -> None: super(OverBookedError, self).__init__(value) self.value = value def __str__(self) -> str: """Return the string representation of this exception. Returns: str: The string representation of this exception. """ return self.value class StatusError(Exception): """Raised when the status of an entity is not suitable for the desired action.""" def __init__(self, value="") -> None: super(StatusError, self).__init__(value) self.value = value def __str__(self) -> str: """Return the string representation of this exception. Returns: str: The string representation of this exception. """ return self.value class DependencyViolationError(Exception): """Raised when a TimeLog violates the dependency relation between tasks.""" def __init__(self, value="") -> None: super(DependencyViolationError, self).__init__(value) self.value = value def __str__(self) -> str: """Return the string representation of this exception. Returns: str: The string representation of this exception. """ return self.value ================================================ FILE: src/stalker/log.py ================================================ # -*- coding: utf-8 -*- """Logging related functions are situated here. This module allows registering any number of logger so that it is possible to update the logging level all together at runtime (without relaying on weird hacks). """ import logging logging.basicConfig() logging_level = logging.INFO loggers = [] def get_logger(name: str) -> logging.Logger: """Get a logger. Args: name (str): The name of the logger. Returns: logging.Logger: The logger. """ logger = logging.getLogger(name) register_logger(logger) return logger def register_logger(logger: logging.Logger) -> None: """Register logger. Args: logger (logging.Logger): A logging.Logger instance. Raises: TypeError: If the logger is not a logging.Logger instance. """ if not isinstance(logger, logging.Logger): raise TypeError( "logger should be a logging.Logger instance, not {}: '{}'".format( logger.__class__.__name__, logger ) ) if logger not in loggers: loggers.append(logger) logger.setLevel(logging_level) def set_level(level: int) -> None: """Update all registered loggers to the given level. Args: level (int): The logging level. The value should be valid with the logging library and should be one of [NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, FATAL, CRITICAL] of the logging library (or anything that is registered as a proper logging level). Raises: TypeError: If level is not an integer. ValueError: If level is not a valid value for the logging library. """ if not isinstance(level, int): raise TypeError( "level should be an integer value one of [0, 10, 20, 30, 40, 50] " "or [NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, FATAL, CRITICAL] " "of the logging library, not {}: '{}'".format( level.__class__.__name__, level ) ) level_names = logging._levelToName if level not in level_names: raise ValueError( "level should be an integer value one of [0, 10, 20, 30, 40, 50] " "or [NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, FATAL, CRITICAL] " "of the logging library, not {}.".format(level) ) for logger in loggers: logger.setLevel(level) ================================================ FILE: src/stalker/models/__init__.py ================================================ ================================================ FILE: src/stalker/models/asset.py ================================================ # -*- coding: utf-8 -*- """Asset related classes.""" import logging from typing import Any from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import log from stalker.models.mixins import CodeMixin, ReferenceMixin from stalker.models.task import Task logger: logging.Logger = log.get_logger(__name__) log.set_level(log.logging_level) class Asset(Task, CodeMixin): """The Asset class is the whole idea behind Stalker. *Assets* are containers of :class:`.Task` s. And :class:`.Task` s are the smallest meaningful part that should be accomplished to complete the :class:`.Project`. An example could be given as follows; you can create an asset for one of the characters in your project. Than you can divide this character asset in to :class:`.Task` s. These :class:`.Task` s can be defined by the type of the :class:`.Asset`, which is a :class:`.Type` object created specifically for :class:`.Asset` (ie. has its :attr:`.Type.target_entity_type` set to "Asset"), An :class:`.Asset` instance should be initialized with a :class:`.Project` instance (as the other classes which are mixed with the :class:`.TaskMixin`). And when a :class:`.Project` instance is given then the asset will append itself to the :attr:`.Project.assets` list. ..versionadded: 0.2.0: No more Asset to Shot connection: Assets now are not directly related to Shots. Instead a :class:`.Version` will reference the Asset and then it is easy to track which shots are referencing this Asset by querying with a join of Shot Versions referencing this Asset. """ __auto_name__ = False __strictly_typed__ = True __tablename__ = "Assets" __mapper_args__ = {"polymorphic_identity": "Asset"} asset_id: Mapped[int] = mapped_column( "id", ForeignKey("Tasks.id"), primary_key=True ) def __init__(self, code, **kwargs) -> None: kwargs["code"] = code super(Asset, self).__init__(**kwargs) CodeMixin.__init__(self, **kwargs) ReferenceMixin.__init__(self, **kwargs) def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object equals to this asset. """ return ( super(Asset, self).__eq__(other) and isinstance(other, Asset) and self.type == other.type ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Asset, self).__hash__() ================================================ FILE: src/stalker/models/auth.py ================================================ # -*- coding: utf-8 -*- """Authentication related classes and functions situated here.""" import base64 import copy import json import os import re from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union import pytz from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Table from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym, validates from sqlalchemy.schema import UniqueConstraint from stalker import defaults, log from stalker.db.declarative import Base from stalker.db.types import GenericDateTime from stalker.models.entity import Entity, SimpleEntity from stalker.models.mixins import ACLMixin from stalker.models.status import Status from stalker.utils import datetime_to_millis, millis_to_datetime if TYPE_CHECKING: # pragma: no cover from stalker.models.client import Client, ClientUser from stalker.models.department import Department, DepartmentUser from stalker.models.project import Project, ProjectUser from stalker.models.task import Task, TimeLog from stalker.models.ticket import Ticket from stalker.models.studio import Vacation logger = log.get_logger(__name__) LOGIN = "login" LOGOUT = "logout" class Permission(Base): """A class to hold permissions. Permissions in Stalker defines what one can do or do not. A Permission instance is composed by three attributes; access, action and class_name. Permissions for all the classes in SOM are generally created by Stalker when initializing the database. If you created any custom classes to extend SOM you are also responsible to create the Permissions for it by calling :meth:`stalker.db.register` and passing your class to it. See the :mod:`stalker.db` documentation for details. Example: Let say that you want to create a Permission specifying a Group of Users are allowed to create Projects:: .. code-block:: Python from stalker import db from stalker import db from stalker.models.auth import User, Group, Permission # first setup the db with the default database # # stalker.db.init() will create all the Actions possible with the # SOM classes automatically # # What is left to you is to create the permissions db.setup() user1 = User( name='Test User', login='test_user1', password='1234', email='testuser1@test.com' ) user2 = User( name='Test User', login='test_user2', password='1234', email='testuser2@test.com' ) group1 = Group(name='users') group1.users = [user1, user2] # get the permissions for the Project class project_permissions = Permission.query\ .filter(Permission.access='Allow')\ .filter(Permission.action='Create')\ .filter(Permission.class_name='Project')\ .first() # now we have the permission specifying the allowance of creating a # Project # to make group1 users able to create a Project we simply add this # Permission to the groups permission attribute group1.permissions.append(permission) # and persist this information in the database DBSession.add(group) DBSession.commit() Args: access (str): An Enum value which can have the one of the values of ``Allow`` or ``Deny``. action (str): An Enum value from the list ['Create', 'Read', 'Update', 'Delete', 'List']. Cannot be None. The list can be changed from stalker.config.Config.default_actions. class_name (str): The name of the class that this action is applied to. Cannot be None or an empty string. """ __tablename__ = "Permissions" __table_args__ = ( UniqueConstraint("access", "action", "class_name"), {"extend_existing": True}, ) id: Mapped[int] = mapped_column(primary_key=True) _access: Mapped[Optional[str]] = mapped_column( "access", Enum("Allow", "Deny", name="AccessNames") ) _action: Mapped[Optional[str]] = mapped_column( "action", Enum(*defaults.actions, name="AuthenticationActions") ) _class_name: Mapped[Optional[str]] = mapped_column("class_name", String(32)) def __init__(self, access: str, action: str, class_name: str) -> None: super(Permission, self).__init__() self._access = self._validate_access(access) self._action = self._validate_action(action) self._class_name = self._validate_class_name(class_name) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return hash(self.access + self.action + self.class_name) def _validate_access(self, access: str) -> str: """Validate the given access value. Args: access (str): The access value to be validated. Raises: TypeError: If the given access value is not a str. ValueError: If the access is not a one of ["Access", "Deny"]. Returns: str: The access value. """ if not isinstance(access, str): raise TypeError( f"{self.__class__.__name__}.access should be an instance of str, " f"not {access.__class__.__name__}: '{access}'" ) if access not in ["Allow", "Deny"]: raise ValueError( f'{self.__class__.__name__}.access should be "Allow" or "Deny" ' f"not {access}" ) return access def _access_getter(self) -> str: """Return the _access value. Returns: str: Returns the access value. """ return self._access access: Mapped[Optional[str]] = synonym( "_access", descriptor=property(_access_getter) ) def _validate_class_name(self, class_name: str) -> str: """Validate the given class_name value. Args: class_name (str): The class name. Raises: TypeError: If the class_name is not a str. Returns: str: The validated class_name. """ if not isinstance(class_name, str): raise TypeError( f"{self.__class__.__name__}.class_name should be an instance of str, " f"not {class_name.__class__.__name__}: '{class_name}'" ) return class_name def _class_name_getter(self) -> str: """Return the _class_name attribute value. Returns: str: The class name. """ return self._class_name class_name: Mapped[str] = synonym( "_class_name", descriptor=property(_class_name_getter) ) def _validate_action(self, action: str) -> str: """Validate the given action value. Args: action (str): The action value. Raises: TypeError: If the action is not a str value. ValueError: If the given action is not in the "defaults.actions" list. Returns: str: The validated action value. """ if not isinstance(action, str): raise TypeError( f"{self.__class__.__name__}.action should be an instance of str, " f"not {action.__class__.__name__}: '{action}'" ) if action not in defaults.actions: raise ValueError( f"{self.__class__.__name__}.action should be one of the values of " f"{defaults.actions} not '{action}'" ) return action def _action_getter(self) -> str: """Return the _action value. Returns: str: Returns the action value. """ return self._action action: Mapped[Optional[str]] = synonym( "_action", descriptor=property(_action_getter) ) def __eq__(self, other: Any) -> bool: """Check if the other Permission is equal to this one. Args: other (Any): The other object. Returns: bool: True if the other object is a Permission instance and has the same access, action and class_name attributes. """ return ( isinstance(other, Permission) and other.access == self.access and other.action == self.action and other.class_name == self.class_name ) class Group(Entity, ACLMixin): """Creates groups for users to be used in authorization system. A Group instance is nothing more than a list of :class:`.User` s created to be able to assign permissions in a group level. The Group class, as with the :class:`.User` class, is mixed with the :class:`.ACLMixin` which adds ability to hold :class:`.Permission` instances and serve ACLs to Pyramid. Args: name (str): The name of this group. users list: A list of :class:`.User` instances holding the desired users in this group. """ __auto_name__ = False __tablename__ = "Groups" __mapper_args__ = {"polymorphic_identity": "Group"} gid: Mapped[int] = mapped_column("id", ForeignKey("Entities.id"), primary_key=True) users: Mapped[Optional[List["User"]]] = relationship( "User", secondary="Group_Users", back_populates="groups", doc="""Users in this group. Accepts:class:`.User` instance. """, ) def __init__(self, name="", users=None, permissions=None, **kwargs) -> None: if users is None: users = [] if permissions is None: permissions = [] kwargs.update({"name": name}) super(Group, self).__init__(**kwargs) self.users = users self.permissions = permissions @validates("users") def _validate_users(self, key: str, user: "User") -> "User": """Validate the given user value. Args: key (str): The name of the validated column. user (User): The :class:`.User` instance. Raises: TypeError: If the given user is not a :class:`.User` instance. Returns: User: The validated :class:`.User` instance. """ if not isinstance(user, User): raise TypeError( f"{self.__class__.__name__}.users should only contain " "instances of stalker.models.auth.User, " f"not {user.__class__.__name__}: '{user}'" ) return user def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Group, self).__hash__() class User(Entity, ACLMixin): """The user class is designed to hold data about a User in the system. .. note:: .. versionadded 0.2.0: Task Watchers New to version 0.2.0 users can be assigned to a :class:`.Task` as a **Watcher**. Which can be used to inform the users in watchers list about the updates of certain Tasks. .. note:: .. versionadded 0.2.0: Vacations It is now possible to define Vacations per user. .. note:: .. versionadded 0.2.7: Resource Efficiency .. note:: .. versionadded 0.2.11: Users not have a :attr:`.rate` attribute. Args: rate: For future usage a rate attribute is added to the User to record the daily cost of this user as a resource. It should be either 0 or a positive integer or float value. Default is 0. efficiency : The efficiency is a multiplier for a user as a resource to a task and defines how much of the time spent for that particular task is counted as an actual effort. The default value is 1.0, lowest possible value is 0.0 and there is no upper limit. The efficiency of a resource can be used for three purposes. First you can use it as a crude way to model a team. A team of 5 people should have an efficiency of 5.0. Keep in mind that you cannot track the members of the team individually if you use this feature. They always act as a group. Another use is to model performance variations between your resources. Again, this is a fairly crude mechanism and should be used with care. A resource that isn't every good at some task might be pretty good at another. This can't be taken into account as the resource efficiency can only set globally for all tasks. One another and interesting use is to model the availability of passive resources like a meeting room or a workstation or something that needs to be free for a task to take place but does not contribute to a task as an active resource. All resources that do not contribute effort to the task, that is a passive resource, should have an efficiency of 0.0. Again a typical example would be a conference room. It's necessary for a meeting, but it does not contribute any work. email (str): holds the e-mail of the user, should be in [part1]@[part2] format. login (str): This is the login name of the user, it should be all lower case. Giving a string that has uppercase letters, it will be converted to lower case. It cannot be an empty string or None and it cannot contain any white space inside. departments (List[Department]): It is the departments that the user is a part of. It should be a list of Department objects. One user can be listed in multiple departments. password (str): it is the password of the user, can contain any character. Stalker doesn't store the raw passwords of the users. To check a stored password with a raw password use :meth:`.check_password` and to set the password you can use the :attr:`.password` property directly. groups (List[Group]): It is a list of :class:`.Group` instances that this user belongs to. tasks (List[Task]): it is a list of Task objects which holds the tasks that this user has been assigned to. last_login (datetime): it is a datetime object holds the last login date of the user (not implemented yet). """ __auto_name__ = False __tablename__ = "Users" __mapper_args__ = {"polymorphic_identity": "User"} user_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) departments = association_proxy( "department_role", "department", creator=lambda d: create_department_user(d) ) department_role: Mapped[Optional[List["DepartmentUser"]]] = relationship( back_populates="user", cascade="all, delete-orphan", primaryjoin="Users.c.id==Department_Users.c.uid", doc="""A list of :class:`.Department` s that this user is a part of""", ) companies = association_proxy( "company_role", "client", creator=lambda n: create_client_user(n) ) company_role: Mapped[Optional[List["ClientUser"]]] = relationship( back_populates="user", cascade="all, delete-orphan", primaryjoin="Users.c.id==Client_Users.c.uid", doc="""A list of :class:`.Client` s that this user is a part of.""", ) email: Mapped[str] = mapped_column( String(256), unique=True, nullable=False, doc="email of the user, accepts string", ) password: Mapped[str] = mapped_column( String(256), nullable=False, doc="""The password of the user. It is scrambled before it is stored. """, ) login: Mapped[str] = mapped_column( String(256), nullable=False, unique=True, doc="""The login name of the user. Cannot be empty. """, ) authentication_logs: Mapped[Optional[List["AuthenticationLog"]]] = relationship( primaryjoin="AuthenticationLogs.c.uid==Users.c.id", back_populates="user", cascade="all, delete-orphan", doc="""A list of :class:`.AuthenticationLog` instances which holds the login/logout info for this :class:`.User`. """, ) groups: Mapped[Optional[List["Group"]]] = relationship( secondary="Group_Users", back_populates="users", doc="""Permission groups that this users is a member of. Accepts :class:`.Group` object. """, ) projects = association_proxy( "project_role", "project", creator=lambda p: create_project_user(p) ) project_role: Mapped[Optional[List["ProjectUser"]]] = relationship( back_populates="user", cascade="all, delete-orphan", primaryjoin="Users.c.id==Project_Users.c.user_id", ) tasks: Mapped[Optional[List["Task"]]] = relationship( secondary="Task_Resources", back_populates="resources", doc=""":class:`.Task` s assigned to this user. It is a list of :class:`.Task` instances. """, ) watching: Mapped[Optional[List["Task"]]] = relationship( secondary="Task_Watchers", back_populates="watchers", doc=""":class:`.Tasks` s that this user is assigned as a watcher. It is a list of :class:`.Task` instances. """, ) responsible_of: Mapped[Optional[List["Task"]]] = relationship( secondary="Task_Responsible", primaryjoin="Users.c.id==Task_Responsible.c.responsible_id", secondaryjoin="Task_Responsible.c.task_id==Tasks.c.id", back_populates="_responsible", doc="""A list of :class:`.Task` instances that this user is responsible of.""", ) time_logs: Mapped[Optional[List["TimeLog"]]] = relationship( primaryjoin="TimeLogs.c.resource_id==Users.c.id", back_populates="resource", cascade="all, delete-orphan", doc="""A list of :class:`.TimeLog` instances which holds the time logs created for this :class:`.User`. """, ) vacations: Mapped[Optional[List["Vacation"]]] = relationship( primaryjoin="Vacations.c.user_id==Users.c.id", back_populates="user", cascade="all, delete-orphan", doc="""A list of :class:`.Vacation` instances which holds the vacations created for this :class:`.User` """, ) efficiency: Mapped[Optional[float]] = mapped_column(default=1.0) rate: Mapped[Optional[float]] = mapped_column(default=0.0) def __init__( self, name: Optional[str] = None, login: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, departments: Optional["Department"] = None, companies: Optional["Client"] = None, groups: Optional["Group"] = None, efficiency: float = 1.0, rate: float = 0.0, **kwargs: Optional[Dict[str, Any]], ) -> None: kwargs["name"] = name super(User, self).__init__(**kwargs) self.login = login if departments is None: departments = [] self.departments = departments if companies is None: companies = [] self.companies = companies self.email = email # to be able to mangle the password do it like this self.password = password if groups is None: groups = [] self.groups = groups self.tasks = [] self.efficiency = efficiency self.rate = rate def __repr__(self) -> str: """Return the representation of the current User. Returns: str: The str representation of this User. """ return f"<{self.name} ('{self.login}') (User)>" def __eq__(self, other: Any) -> bool: """Check if the other User is equal to this one. Args: other (Any): The other user instance. Returns: bool: If the other object is equal to this one, meaning that it is a User instance, has the same name, login and email values then returns True. """ return ( super(User, self).__eq__(other) and isinstance(other, User) and self.email == other.email and self.login == other.login and self.name == other.name ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(User, self).__hash__() @validates("login") def _validate_login(self, key: str, login: str) -> str: """Validate and format the given login value. Args: key (str): The name of the validated column. login (str): The login to be validated. Raises: TypeError: If the login is not a str. ValueError: If the login is an empty string after formatting. Returns: str: The validated and formatted login value. """ if login is None: raise TypeError(f"{self.__class__.__name__}.login cannot be None") login = self._format_login(login) # raise a ValueError if the login is an empty string after formatting if login == "": raise ValueError( f"{self.__class__.__name__}.login cannot be an empty string" ) logger.debug(f"name out: {login}") return login @validates("email") def _validate_email(self, key: str, email: str) -> str: """Validate the given email value. Args: key (str): The name of the validated column. email (str): The email to be validated. Raises: TypeError: If the given email is not a str. Returns: str: The validated email value. """ # check if email is an instance of string if not isinstance(email, str): raise TypeError( f"{self.__class__.__name__}.email should be an instance of str, not " f"{email.__class__.__name__}: '{email}'" ) return self._validate_email_format(email) def _validate_email_format(self, email: str) -> str: """Validate the email format. Args: email (str): The email value to be validated. Raises: ValueError: If the email doesn't have a "@" sign in it, or it has more than one "@" sign in it, or after formatting the account name part or the domain name part becomes an empty string. Returns: str: The validated email value. """ # split the mail from @ sign splits = email.split("@") len_splits = len(splits) # there should be one and only one @ sign if len_splits > 2: raise ValueError( f"check the formatting of {self.__class__.__name__}.email, " "there are more than one @ sign" ) if len_splits < 2: raise ValueError( f"check the formatting of {self.__class__.__name__}.email, " "there is no @ sign" ) if splits[0] == "": raise ValueError( f"check the formatting of {self.__class__.__name__}.email, " "the name part is missing" ) if splits[1] == "": raise ValueError( f"check the formatting {self.__class__.__name__}.email, " "the domain part is missing" ) return email @classmethod def _format_login(cls, login: str) -> str: """Format the given login value. Args: login (str): The login value. Returns: str: The formatted login value. """ # strip white spaces from start and end login = login.strip() # remove all the spaces login = login.replace(" ", "") # make it lowercase login = login.lower() # remove any illegal characters login = re.sub("[^\\(a-zA-Z0-9)]+", "", login) # remove any number at the beginning login = re.sub("^[0-9]+", "", login) return login @validates("password") def _validate_password(self, key: str, password: str) -> str: """Validate the given password value. Note: This function was updated to support both Python 2.7 and 3.5+. It will now explicitly convert the base64 bytes object into a string object. Args: key (str): The name of the validated column. password (str): The password value. Raises: TypeError: If the given password is None. ValueError: If the given password is an empty string. Returns: str: The mangled password. """ if password is None: raise TypeError(f"{self.__class__.__name__}.password cannot be None") if password == "": raise ValueError( f"{self.__class__.__name__}.password cannot be an empty string" ) # mangle the password mangled_password_bytes = base64.b64encode(password.encode("utf-8")) mangled_password_str = str(mangled_password_bytes.decode("utf-8")) return mangled_password_str def check_password(self, raw_password: str) -> bool: """Check the given raw_password. Check the given raw_password with the current User object's mangled password. Handles the encryption process behind the scene. Note: This function was updated to support both Python 2.7 and 3.5+. It will now compare the string (str) versions of the given raw_password and the current Users object encrypted password. Args: raw_password (str): The raw password. Returns: bool: If the given raw password matches the password stored in the db. """ mangled_password_str = str(self.password) raw_password_bytes = base64.b64encode(bytes(raw_password.encode("utf-8"))) raw_password_encrypted_str = str(raw_password_bytes.decode("utf-8")) return mangled_password_str == raw_password_encrypted_str @validates("groups") def _validate_groups(self, key: str, group: Group) -> Group: """Validate the given group value. Args: key (str): The name of the validated column. group (Group): The :class:`.Group` instance to be validated. Raises: TypeError: If the given group arg value is not a :class:`.Group` instance. Returns: Group: The validated :class:`.Group` instance. """ if not isinstance(group, Group): raise TypeError( f"Any group in {self.__class__.__name__}.groups should be an instance " "of stalker.models.auth.Group, " f"not {group.__class__.__name__}: '{group}'" ) return group @validates("tasks") def _validate_tasks(self, key: str, task: "Task") -> "Task": """Validate the given tasks attribute. Args: key (str): The name of the validated column. task (stalker.models.task.Task): The :class:`stalker.models.task.Task` instance to be validated. Raises: TypeError: If the given task arg value is not a :class:`stalker.models.task.Task` instance. Returns: Task: The validated :class:`stalker.models.task.Task` instance. """ from stalker.models.task import Task if not isinstance(task, Task): raise TypeError( f"Any element in {self.__class__.__name__}.tasks should be an instance " f"of stalker.models.task.Task, not {task.__class__.__name__}: '{task}'" ) return task @validates("watching") def _validate_watching(self, key: str, task: "Task") -> "Task": """Validate the given watching attribute. Args: key (str): The name of the validated column. task (stalker.models.task.Task): The :class:`stalker.models.task.Task` instance that the user will watch. Raises: TypeError: If the given task arg value is not a :class:`stalker.models.task.Task` instance. Returns: Task: The validated :class:`stalker.models.task.Task` instance. """ from stalker.models.task import Task if not isinstance(task, Task): raise TypeError( f"Any element in {self.__class__.__name__}.watching should be an " "instance of stalker.models.task.Task, " f"not {task.__class__.__name__}: '{task}'" ) return task @validates("vacations") def _validate_vacations(self, key: str, vacation: "Vacation") -> "Vacation": """Validate the given vacation value. Args: key (str): The name of the validated column. vacation (stalker.models.studio.Vacation): The :class:`stalker.models.studio.Vacation` instance. Raises: TypeError: If the given vacation argument value is not a :class:`stalker.models.vacation.Vacation` instance. Returns: Vacation: The validated vacation value. """ from stalker.models.studio import Vacation if not isinstance(vacation, Vacation): raise TypeError( f"All of the elements in {self.__class__.__name__}.vacations should be " "a stalker.models.studio.Vacation instance, " f"not {vacation.__class__.__name__}: '{vacation}'" ) return vacation @validates("efficiency") def _validate_efficiency( self, key: str, efficiency: Union[None, int, float] ) -> float: """Validate the given efficiency value. Args: key (str): The name of the validated column. efficiency (Union[None, int, float]): The efficiency of this User instance. This shows how efficient the user works. It is a number between 0-1. If None given, a default value of 1.0 will be used. Raises: TypeError: If the given efficiency is not one of [None, int, float]. ValueError: If the given efficiency is a negative value. Returns: float: The validated efficiency value. """ if efficiency is None: efficiency = 1.0 if not isinstance(efficiency, (int, float)): raise TypeError( f"{self.__class__.__name__}.efficiency should be a float number " "greater or equal to 0.0, " f"not {efficiency.__class__.__name__}: '{efficiency}'" ) if efficiency < 0: raise ValueError( f"{self.__class__.__name__}.efficiency should be a float number " f"greater or equal to 0.0, not {efficiency}" ) return float(efficiency) @validates("rate") def _validate_rate(self, key: str, rate: Union[int, float]) -> float: """Validate the given rate value. Args: key (str): The name of the validated column. rate (Union[int, float]): An int or float value representing the User hourly rate. Raises: TypeError: If the given rate is not an int or float. ValueError: If the given rate is a negative number. Returns: float: The validated rate value. """ if rate is None: rate = 0.0 if not isinstance(rate, (int, float)): raise TypeError( f"{self.__class__.__name__}.rate should be a float number greater or " f"equal to 0.0, not {rate.__class__.__name__}: '{rate}'" ) if rate < 0: raise ValueError( f"{self.__class__.__name__}.rate should be a float number greater or " f"equal to 0.0, not {rate}" ) return float(rate) @property def tickets(self) -> List["Ticket"]: """Return the list of :class:`.Ticket` s that this user has. Returns: List[Ticket]: The list of :class:`.Ticket` instances which this user is the owner of. """ # do it with sqlalchemy from stalker.models.ticket import Ticket return Ticket.query.filter(Ticket.owner == self).all() @property def open_tickets(self) -> List["Ticket"]: """Return the list of open :class:`.Ticket` s that this user has. Returns: List[Ticket]: A list of :class:`.Ticket` instances which are not closed and this user is assigned as the owner. """ from stalker import Ticket return ( Ticket.query.join(Status, Ticket.status) .filter(Ticket.owner == self) .filter(Status.code != "CLS") .all() ) @property def to_tjp(self) -> str: """Return a TaskJuggler compatible str representation of this User instance. Returns: str: The TaskJuggler compatible representation of this User instance. """ tab = " " indent = tab tjp = f'resource {self.tjp_id} "{self.tjp_id}" {{' tjp += f"\n{indent}efficiency {self.efficiency}" for vacation in self.vacations: tjp += "\n" tjp += "\n".join(f"{indent}{line}" for line in vacation.to_tjp.split("\n")) tjp += "\n}" return tjp class LocalSession(object): """A simple temporary session object which simple stores session data. This class will later be removed, it is here because we need a login window for the Qt user interfaces. On initialize it will load the SessionData from the users .strc folder """ def __init__(self) -> None: self.logged_in_user_id = None self.valid_to = None self.session_data = None self.load() def load(self) -> None: """Load the data from the saved local session.""" try: with open(LocalSession.session_file_full_path(), "r") as s: # try: json_object = json.load(s) valid_to = millis_to_datetime(json_object.get("valid_to")) if valid_to > datetime.now(pytz.utc): # fill __dict__ with the loaded one self.valid_to = valid_to self.logged_in_user_id = json_object.get("logged_in_user_id") except IOError: pass @property def logged_in_user(self) -> "User": """Return the logged-in user. Returns: User: The logged-in user. """ return User.query.filter_by(id=self.logged_in_user_id).first() def store_user(self, user: "User") -> None: """Store the given user instance. Args: user (User): The :class:`.User` instance. """ if user: self.logged_in_user_id = user.id def save(self) -> None: """Remember the data in user local file system.""" self.valid_to = datetime.now(pytz.utc) + timedelta(days=10) # serialize self dumped_data = json.dumps( { "valid_to": datetime_to_millis(self.valid_to), "logged_in_user_id": self.logged_in_user_id, }, ) logger.debug(f"dumped session data : {dumped_data}") self._write_data(dumped_data) def delete(self) -> None: """Remove the cache file.""" try: os.remove(self.session_file_full_path()) except OSError: pass @classmethod def session_file_full_path(cls) -> str: """Return the session file full path. Returns: str: The session file full path. """ return os.path.normpath( os.path.join( defaults.local_storage_path, defaults.local_session_data_file_name ) ) def _write_data(self, data: str) -> None: """Write the given data to the local session file. Args: data (str): The data to be written (generally serialized LocalSession class itself) """ file_full_path = self.session_file_full_path() # create the path first file_path = os.path.dirname(file_full_path) try: os.makedirs(file_path) except OSError: # dir exists pass finally: with open(file_full_path, "w") as data_file: data_file.write(data) class Role(Entity): """Defines a User role. .. versionadded 0.2.11: Roles When :class:`.User` s are assigned to a :class:`.Client`/:class:`.Department`, they also can be assigned to a role for that client/department. Also, because Users can be assigned to multiple clients/departments they can have different roles for each of this clients/departments. The duty of this class is to defined different roles that can be reused when required. So one can defined a **Lead** role and then assign a User to a department with its role is set to "lead". This essentially generalizes the previous implementation of now removed *Department.lead* attribute. """ __auto_name__ = False __tablename__ = "Roles" __mapper_args__ = {"polymorphic_identity": "Role"} role_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) def __init__(self, **kwargs) -> None: super(Role, self).__init__(**kwargs) def create_department_user(department: "Department") -> "DepartmentUser": """Create DepartmentUser instance on association proxy. Args: department (stalker.models.department.Department): The :class:`stalker.models.department.Department` instance. Returns: stalker.models.department.DepartmentUser: The :class:`stalker.models.department.DepartmentUser` instance. """ from stalker.models.department import DepartmentUser return DepartmentUser(department=department) def create_client_user(client: "Client") -> "ClientUser": """Create ClientUser instance on association proxy. Args: client (stalker.models.client.Client): The :class:`stalker.models.project.Project` instance. Returns: stalker.models.client.ClientUser: The :class:`stalker.models.client.ClientUser` instance. """ from stalker.models.client import ClientUser return ClientUser(client=client) def create_project_user(project: "Project") -> "ProjectUser": """Create ProjectUser instance on association proxy. Args: project (stalker.models.project.Project): The :class:`stalker.models.project.Project` instance. Returns: stalker.models.project.ProjectUser: The :class:`stalker.models.project.ProjectUser` instance. """ from stalker.models.project import ProjectUser return ProjectUser(project=project) # Group_Users Group_Users = Table( "Group_Users", Base.metadata, Column("uid", Integer, ForeignKey("Users.id"), primary_key=True), Column("gid", Integer, ForeignKey("Groups.id"), primary_key=True), ) class AuthenticationLog(SimpleEntity): """Keeps track of login/logout dates and the action (login or logout).""" __auto_name__ = True __tablename__ = "AuthenticationLogs" __mapper_args__ = {"polymorphic_identity": "AuthenticationLog"} log_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) user_id: Mapped[int] = mapped_column("uid", Integer, ForeignKey("Users.id")) user: Mapped[User] = relationship( primaryjoin="AuthenticationLogs.c.uid==Users.c.id", uselist=False, back_populates="authentication_logs", doc="The :class:`.User` instance that this AuthenticationLog is created for", ) action: Mapped[str] = mapped_column(Enum(LOGIN, LOGOUT, name="ActionNames")) date: Mapped[datetime] = mapped_column(GenericDateTime) def __init__(self, user=None, date=None, action=LOGIN, **kwargs) -> None: super(AuthenticationLog, self).__init__(**kwargs) self.user = user self.date = date self.action = action def __lt__(self, other: "AuthenticationLog") -> bool: """Make this object order-able. Args: other (.AuthenticationLog): The other :class:`.AuthenticationLog` instance. Returns: Tuple(str, str): The str key to be used for ordering. """ return self.date < other.date @validates("user") def __validate_user__(self, key: str, user: "User") -> "User": """Validate the given user argument value. Args: key (str): The name of the validated column. user (User): The :class:`.User` instance to be validated. Raises: TypeError: If the given user args is not a :class:`.User` instance. Returns: .User: The validated :class:`.User` instance. """ if not isinstance(user, User): raise TypeError( f"{self.__class__.__name__}.user should be a User instance, " f"not {user.__class__.__name__}: '{user}'" ) return user @validates("action") def __validate_action__(self, key: str, action: str) -> str: """Validate the given action argument value. Args: key (str): The name of the validated column. action (str): One of LOGIN or LOGOUT enum values. Raises: ValueError: If the given value is not one of LOGIN or LOGOUT. Returns: str: The validated action value. """ if action is None: action = copy.copy(LOGIN) if action not in [LOGIN, LOGOUT]: raise ValueError( f'{self.__class__.__name__}.action should be one of "login" or ' f'"logout", not "{action}"' ) return action @validates("date") def __validate_date__(self, key: str, date: datetime) -> datetime: """Validate the given date value. Args: key (str): The name of the validated column. date (datetime): The datetime instance. Raises: TypeError: If the given date is not a datetime instance. Returns: datetime: Returns the validated datetime instance. """ if date is None: date = datetime.now(pytz.utc) if not isinstance(date, datetime): raise TypeError( f"{self.__class__.__name__}.date should be a datetime.datetime " f"instance, not {date.__class__.__name__}: '{date}'" ) return date ================================================ FILE: src/stalker/models/budget.py ================================================ # -*- coding: utf-8 -*- """Budget related classes and functions are situated here.""" from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union from sqlalchemy import Column, Float, ForeignKey, Integer, String, Table from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.models.entity import Entity from stalker.models.mixins import ( AmountMixin, DAGMixin, ProjectMixin, StatusMixin, UnitMixin, ) if TYPE_CHECKING: # pragma: no cover from stalker.models.client import Client class Good(Entity, UnitMixin): """Manages commercial items that is served by the Studio. A Studio can define service prices or items that's been sold by the Studio by using a list of commercial items. .. note:: .. versionadded 0.2.20: Client Specific Goods Clients now can own a list of :class:`.Good` s attached to them. So one can define a list of :class:`.Good` s with special prices adjusted for a particular ``Client``, then get them back from the db by querying the :class:`.Good` s those have their ``client`` attribute set to that particular ``Client`` instance. Removing a ``Good`` from a :class:`.Client` will not delete it from the database, but deleting a :class:`.Client` will also delete the ``Good`` s attached to that particular :class:`.Client`. .. :: don't forget to update the Client documentation, which also has the same text. A Good has the following attributes Args: cost (Union[int, float]): The cost of this item to the Studio, so generally it is better to keep price of the related BudgetEntry bigger than this value to get profit by selling this item. msrp (Union[int, float]): The suggested retail price for this item. unit (str): The unit of this item. """ __auto_name__ = False __tablename__ = "Goods" __mapper_args__ = {"polymorphic_identity": "Good"} good_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) price_lists: Mapped["PriceList"] = relationship( secondary="PriceList_Goods", primaryjoin="Goods.c.id==PriceList_Goods.c.good_id", secondaryjoin="PriceList_Goods.c.price_list_id==PriceLists.c.id", back_populates="goods", doc="PriceLists that this good is related to.", ) cost: Mapped[Optional[float]] = mapped_column(default=0.0) msrp: Mapped[Optional[float]] = mapped_column(Float, default=0.0) unit: Mapped[Optional[str]] = mapped_column(String(64)) client_id: Mapped[Optional[int]] = mapped_column( "client_id", ForeignKey("Clients.id") ) client: Mapped["Client"] = relationship( primaryjoin="Goods.c.client_id==Clients.c.id", back_populates="goods", uselist=False, ) def __init__( self, cost: Union[int, float] = 0.0, msrp: Union[int, float] = 0.0, unit: str = "", client: Optional["Client"] = None, **kwargs, ) -> None: super(Good, self).__init__(**kwargs) UnitMixin.__init__(self, unit=unit) self.cost = cost self.msrp = msrp self.client = client @validates("cost") def _validate_cost(self, key: str, cost: Union[int, float]) -> Union[int, float]: """Validate the given cost value. Args: key (str): The name of the validated column. cost (Union[int, float]): The cost value to be validated. Raises: TypeError: If the given cost value is not an int or float. ValueError: If the given cost is a negative value. Returns: float: The validated cost value. """ if cost is None: cost = 0.0 if not isinstance(cost, (float, int)): raise TypeError( f"{self.__class__.__name__}.cost should be a non-negative number, " f"not {cost.__class__.__name__}: '{cost}'" ) if cost < 0.0: raise ValueError( f"{self.__class__.__name__}.cost should be a non-negative number" ) return cost @validates("msrp") def _validate_msrp(self, key: str, msrp: Union[int, float]) -> Union[int, float]: """Validate the given msrp value. Args: key (str): The name of the validated column. msrp (Union[int, float]): The msrp value to be validated. Raises: TypeError: If the given msrp value is not an int or float. ValueError: If the msrp is a negative value. Returns: float: The validated msrp value. """ if msrp is None: msrp = 0.0 if not isinstance(msrp, (float, int)): raise TypeError( f"{self.__class__.__name__}.msrp should be a non-negative number, " f"not {msrp.__class__.__name__}: '{msrp}'" ) if msrp < 0.0: raise ValueError( f"{self.__class__.__name__}.msrp should be a non-negative number" ) return msrp @validates("client") def _validate_client(self, key: str, client: "Client") -> "Client": """Validate the given client value. Args: key (str): The name of the validated column. client (Client): The client value to be validated. Raises: TypeError: If the given client arg value is not a :class:`stalker.models.client.Client` instance. Returns: Client: The validated :class:`stalker.models.client.Client` instance. """ if client is not None: from stalker.models.client import Client if not isinstance(client, Client): raise TypeError( f"{self.__class__.__name__}.client attribute should be a " "stalker.models.client.Client instance, " f"not {client.__class__.__name__}: '{client}'" ) return client class PriceList(Entity): """Contains CommercialItems to create a list of items that is sold by the Studio. You can create different lists for items sold in this studio. Args: goods (List[Good]): A list of :class:`.Good` instances to be put into this :class:`.PriceList` instance. """ __auto_name__ = False __tablename__ = "PriceLists" __mapper_args__ = {"polymorphic_identity": "PriceList"} price_list_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) goods: Mapped[Optional[List["Good"]]] = relationship( secondary="PriceList_Goods", primaryjoin="PriceLists.c.id==PriceList_Goods.c.price_list_id", secondaryjoin="PriceList_Goods.c.good_id==Goods.c.id", back_populates="price_lists", doc="Goods in this list.", ) def __init__( self, goods: Optional[List["Good"]] = None, **kwargs: Dict[str, Any], ) -> None: super(PriceList, self).__init__(**kwargs) if goods is None: goods = [] self.goods = goods @validates("goods") def _validate_goods(self, key: str, good: "Good") -> "Good": """Validate the given good value. Args: key (str): The name of the validated column. good (Good): The good value to be validated. Raises: TypeError: If the given good arg value is not a :class:`.Good` instance. Returns: Good: The validated :class:`.Good` instance. """ if not isinstance(good, Good): raise TypeError( f"{self.__class__.__name__}.goods should only contain " "instances of stalker.model.budget.Good, " f"not {good.__class__.__name__}: '{good}'" ) return good PriceList_Goods = Table( "PriceList_Goods", Base.metadata, Column("price_list_id", Integer, ForeignKey("PriceLists.id"), primary_key=True), Column("good_id", Integer, ForeignKey("Goods.id"), primary_key=True), ) class Budget(Entity, ProjectMixin, DAGMixin, StatusMixin): """Manages project budgets. Budgets manager :class:`.Project` budgets. You can create entries as instances of :class:`.BudgetEntry` class. """ __auto_name__ = False __tablename__ = "Budgets" __mapper_args__ = {"polymorphic_identity": "Budget"} budget_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) __id_column__ = "budget_id" entries: Mapped[Optional[List["BudgetEntry"]]] = relationship( primaryjoin="BudgetEntries.c.budget_id==Budgets.c.id", cascade="all, delete-orphan", ) invoices: Mapped[Optional[List["Invoice"]]] = relationship( primaryjoin="Invoices.c.budget_id==Budgets.c.id", cascade="all, delete-orphan", ) def __init__(self, **kwargs: Dict[str, Any]) -> None: super(Budget, self).__init__(**kwargs) ProjectMixin.__init__(self, **kwargs) DAGMixin.__init__(self, **kwargs) StatusMixin.__init__(self, **kwargs) @validates("entries") def _validate_entry(self, key: str, entry: "BudgetEntry") -> "BudgetEntry": """Validate the given entry value. Args: key (str): The name of the validated column. entry (BudgetEntry): The entry value to be validated. Raises: TypeError: If the given entry value is not a :class:`.BudgetEntry` instance. Returns: BudgetEntry: The validated BudgetEntry instance. """ if not isinstance(entry, BudgetEntry): raise TypeError( f"{self.__class__.__name__}.entries should only contain " "instances of BudgetEntry, " f"not {entry.__class__.__name__}: '{entry}'" ) return entry class BudgetEntry(Entity, AmountMixin, UnitMixin): """Manages entries in a Budget. With BudgetEntries one can manage project budget entries one by one. Each entry shows one component of a bigger budget. Entries are generally a reflection of a :class:`.Good` instance and shows how many of that Good has been included in this Budget, and what was the discounted price of that Good. Args: budget (Budget): The :class:`.Budget` that this entry is a part of. good (Good): Stores a :class:`.Good` instance to carry all the cost/msrp/unit data from. price (float): The decided price of this entry. This is generally bigger than the :attr:`.cost` and should be also bigger than :attr:`.msrp` but the person that is editing the budget which this entry is related to can decide to do a discount on this entry and give a different price. This attribute holds the proposed final price. realized_total (float): This attribute is for holding the realized price of this entry. It can be the same number of the :attr:`.price` multiplied by the :attr:`.amount` or can be something else that reflects the reality. Generally it is for calculating the "service" cost/profit. amount (float): Defines the amount of :class:`Good` that is in consideration for this entry. """ __auto_name__ = True __tablename__ = "BudgetEntries" __mapper_args__ = {"polymorphic_identity": "BudgetEntry"} entry_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) budget_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Budgets.id")) budget: Mapped["Budget"] = relationship( primaryjoin="BudgetEntries.c.budget_id==Budgets.c.id", back_populates="entries", uselist=False, ) good_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Goods.id")) good: Mapped["Good"] = relationship( primaryjoin="BudgetEntries.c.good_id==Goods.c.id", uselist=False ) cost: Mapped[Optional[float]] = mapped_column(default=0.0) msrp: Mapped[Optional[float]] = mapped_column(default=0.0) price: Mapped[Optional[float]] = mapped_column(default=0.0) realized_total: Mapped[Optional[float]] = mapped_column(default=0.0) def __init__( self, budget: Optional[Budget] = None, good: Optional[Good] = None, price: Union[float, int] = 0, realized_total: Union[float, int] = 0, amount: Union[float, int] = 0.0, **kwargs: Dict[str, Any], ) -> None: super(BudgetEntry, self).__init__(**kwargs) self.budget = budget self.good = good self.cost = good.cost self.msrp = good.msrp kwargs["unit"] = good.unit kwargs["amount"] = amount AmountMixin.__init__(self, **kwargs) UnitMixin.__init__(self, **kwargs) self.price = price self.realized_total = realized_total @validates("budget") def _validate_budget(self, key: str, budget: "Budget") -> "Budget": """Validate the given budget value. Args: key (str): The name of the validated column. budget (Budget): The budget that needs to be validated. Raises: TypeError: If the given budget is not a :class:`.Budget` instance. Returns: Budget: The validated :class:`.Budget` instance. """ if not isinstance(budget, Budget): raise TypeError( f"{self.__class__.__name__}.budget should be a Budget instance, " f"not {budget.__class__.__name__}: '{budget}'" ) return budget @validates("cost") def _validate_cost(self, key: str, cost: Union[float, int]) -> float: """Validate the given cost value. Args: key (str): The name of the validated column. cost (Union[int, float]): The cost value to be validated. Raises: TypeError: If the given cost value is not an int or float. Returns: float: The validated cost value. """ if cost is None: cost = 0.0 if not isinstance(cost, (int, float)): raise TypeError( f"{self.__class__.__name__}.cost should be a number, " f"not {cost.__class__.__name__}: '{cost}'" ) return float(cost) @validates("msrp") def _validate_msrp(self, key: str, msrp: Union[float, int]) -> float: """Validate the given msrp value. Args: key (str): The name of the validated column. msrp (Union[int, float]): The msrp value to be validated. Raises: TypeError: If the given msrp value is not an int or float. Returns: float: The validated msrp value. """ if msrp is None: msrp = 0.0 if not isinstance(msrp, (int, float)): raise TypeError( f"{self.__class__.__name__}.msrp should be a number, " f"not {msrp.__class__.__name__}: '{msrp}'" ) return float(msrp) @validates("price") def _validate_price(self, key: str, price: Union[float, int]) -> float: """Validate the given price value. Args: key (str): The name of the validated column. price (Union[int, float]): The price value to be validated. Raises: TypeError: If the given price value is not an int or float. Returns: float: The validated price value. """ if price is None: price = 0.0 if not isinstance(price, (int, float)): raise TypeError( f"{self.__class__.__name__}.price should be a number, " f"not {price.__class__.__name__}: '{price}'" ) return float(price) @validates("realized_total") def _validate_realized_total( self, key: str, realized_total: Union[float, int] ) -> float: """Validate the given realized_total value. Args: key (str): The name of the validated column. realized_total (Union[int, float]): The realized_total value to be validated. Raises: TypeError: If the given realized_total value is not an int or float. Returns: float: The validated realized_total value. """ if realized_total is None: realized_total = 0.0 if not isinstance(realized_total, (int, float)): raise TypeError( f"{self.__class__.__name__}.realized_total should be a number, " f"not {realized_total.__class__.__name__}: '{realized_total}'" ) return float(realized_total) @validates("good") def _validate_good(self, key: str, good: "Good") -> "Good": """Validate the given good value. Args: key (str): The name of the validated column. good (Good): The good to be validated. Raises: TypeError: If the given good is not a :class:`.Good` instance. Returns: Good: Returns the validated :class:`.Good` instance. """ if not isinstance(good, Good): raise TypeError( f"{self.__class__.__name__}.good should be a " "stalker.models.budget.Good instance, " f"not {good.__class__.__name__}: '{good}'" ) return good class Invoice(Entity, AmountMixin, UnitMixin): """Holds information about invoices. Invoices are part of :class:`.Budgets`. The main purpose of invoices are to track the :class:`.Payment` s. It is a very primitive entity. It is by no means designed to hold real financial information (at least for now). Args: client (Client): The :class:`.Client` instance that shows the payer for this invoice. budget (Budget): The :class:`.Budget` instance that owns this invoice. amount (Union[int, float]): The amount of this invoice. Without the :attr:`.Invoice.unit` attribute it is meaningless. This cannot be skipped. unit (str): The unit of the issued amount. This cannot be skipped. """ __auto_name__ = True __tablename__ = "Invoices" __mapper_args__ = {"polymorphic_identity": "Invoice"} invoice_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) budget_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Budgets.id")) budget: Mapped[Optional["Budget"]] = relationship( primaryjoin="Invoices.c.budget_id==Budgets.c.id", back_populates="invoices", uselist=False, ) client_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Clients.id")) client: Mapped[Optional["Client"]] = relationship( primaryjoin="Invoices.c.client_id==Clients.c.id", uselist=False ) payments: Mapped[Optional[List["Payment"]]] = relationship( primaryjoin="Payments.c.invoice_id==Invoices.c.id", cascade="all, delete-orphan", ) def __init__( self, budget: Optional["Budget"] = None, client: Optional["Client"] = None, amount: Union[float, int] = 0, unit: Optional[str] = None, **kwargs, ) -> None: super(Invoice, self).__init__(**kwargs) AmountMixin.__init__(self, amount=amount) UnitMixin.__init__(self, unit=unit) self.budget = budget self.client = client @validates("budget") def _validate_budget(self, key: str, budget: "Budget") -> "Budget": """Validate the given budget value. Args: key (str): The name of the validated column. budget (Budget): The :class:`.Budget` instance to be validated. Raises: TypeError: If the given budget arg value is not a :class:`.Budget` instance. Returns: Budget: The validated :class:`.Budget` instance. """ if not isinstance(budget, Budget): raise TypeError( f"{self.__class__.__name__}.budget should be a Budget instance, " f"not {budget.__class__.__name__}: '{budget}'" ) return budget @validates("client") def _validate_client(self, key: str, client: "Client") -> "Client": """Validate the given client value. Args: key (str): The name of the validated column. client (Client): The :class:`stalker.models.client.Client` instance to be validated. Raises: TypeError: If the ``client`` is not a :class:`stalker.models.client.Client` instance. Returns: Client: The validated :class:`stalker.models.client.Client` instance. """ from stalker.models.client import Client if not isinstance(client, Client): raise TypeError( f"{self.__class__.__name__}.client should be a Client instance, " f"not {client.__class__.__name__}: '{client}'" ) return client class Payment(Entity, AmountMixin, UnitMixin): """Holds information about the payments. Each payment should be related with an :class:`.Invoice` instance. Use the :attr:`.type` attribute to diversify payments (ex. "Advance"). Args: invoice (Invoice): The :class:`.Invoice` instance that this payment is related to. This cannot be skipped. amount (Union[int, float]): The amount value. unit (Optional[str]): The unit of this mixed in class. """ __auto_name__ = True __tablename__ = "Payments" __mapper_args__ = {"polymorphic_identity": "Payment"} payment_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) invoice_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Invoices.id")) invoice: Mapped[Optional["Invoice"]] = relationship( primaryjoin="Payments.c.invoice_id==Invoices.c.id", back_populates="payments", uselist=False, ) def __init__( self, invoice: Optional["Invoice"] = None, amount: Union[int, float] = 0, unit: Optional[str] = None, **kwargs, ) -> None: super(Payment, self).__init__(**kwargs) AmountMixin.__init__(self, amount=amount) UnitMixin.__init__(self, unit=unit) self.invoice = invoice @validates("invoice") def _validate_invoice(self, key: str, invoice: "Invoice") -> "Invoice": """Validate the invoice value. Args: key (str): The name of the validated column. invoice (Invoice): The :class:`.Invoice` instance to validate. Raises: TypeError: The :class:`.Invoice` instance to be validated. Returns: Invoice: The validated :class:`.Invoice` instance. """ if not isinstance(invoice, Invoice): raise TypeError( f"{self.__class__.__name__}.invoice should be an Invoice instance, " f"not {invoice.__class__.__name__}: '{invoice}'" ) return invoice ================================================ FILE: src/stalker/models/client.py ================================================ # -*- coding: utf-8 -*- """Client related classes and functions are situated here.""" from typing import Any, Dict, List, Optional, TYPE_CHECKING from sqlalchemy import ForeignKey from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker import log from stalker.db.declarative import Base from stalker.models.entity import Entity from stalker.models.project import create_project_client if TYPE_CHECKING: # pragma: no cover from stalker.models.auth import Role, User from stalker.models.budget import Good from stalker.models.project import Project, ProjectClient logger = log.get_logger(__name__) class Client(Entity): """The Client (e.g. a company) which users may be part of. The information that a Client object holds is like: * The users of the client * The projects affiliated with the client * and all the other things those are inherited from the Entity class .. note:: .. versionadded 0.2.20: Client Specific Goods Clients now can own a list of :class:`.Good` s attached to them. So one can define a list of class:`.Good` s with special prices adjusted for a particular ``Client``, then get them back from the db by querying the :class:`.Good` s those have their ``client`` attribute set to that particular ``Client`` instance. Removing a ``Good`` from a :class:`.Client` will not delete it from the database, but deleting a :class:`.Client` will also delete the ``Good`` s attached to that particular :class:`.Client`. .. :: don't forget to update the Good documentation, which also has the same text. Two Client object considered the same if they have the same name. So creating a client object needs the following parameters: Args: users (:class:`.User`): It can be an empty list, so one client can be created without any user in it. But this parameter should be a list of User objects. projects (List[Project]): it can be an empty list, so one client can be created without any project in it. But this parameter should be a list of Project objects. """ __auto_name__ = False __tablename__ = "Clients" __mapper_args__ = {"polymorphic_identity": "Client"} client_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) users = association_proxy("user_role", "user", creator=lambda n: ClientUser(user=n)) user_role: Mapped[Optional[List["ClientUser"]]] = relationship( back_populates="client", cascade="all, delete-orphan", primaryjoin="Clients.c.id==Client_Users.c.cid", doc="""List of users representing the members of this client.""", ) projects = association_proxy( "project_role", "project", creator=lambda p: create_project_client(p) ) project_role: Mapped[Optional[List["ProjectClient"]]] = relationship( back_populates="client", cascade="all, delete-orphan", primaryjoin="Clients.c.id==Project_Clients.c.client_id", ) goods: Mapped[Optional[List["Good"]]] = relationship( "Good", back_populates="client", cascade="all", # do not include "delete-orphan" we want to keep goods # if they are detached on purpose primaryjoin="Clients.c.id==Goods.c.client_id", ) def __init__( self, users: Optional[List["User"]] = None, projects: Optional[List["Project"]] = None, **kwargs: Optional[Dict[str, Any]], ) -> None: super(Client, self).__init__(**kwargs) if users is None: users = [] if projects is None: projects = [] self.users = users self.projects = projects def __eq__(self, other: Any) -> bool: """Check if the other Client is equal to this once. Args: other (Any): The other Client instance. Returns: bool: Returns True, if other object is a Client instance and equal to this one. """ return super(Client, self).__eq__(other) and isinstance(other, Client) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Client, self).__hash__() @property def to_tjp(self) -> str: """Return a TaskJuggler compatible str representation of this Client instance. Returns: str: The TaskJuggler compatible representation of this Client instance. """ return "" @validates("goods") def _validate_good(self, key: str, good: "Good") -> "Good": """Validate the given good value. Args: key (str): The name of the validated column. good (Good): The good value to be validated. Raises: TypeError: If the given good is not a :class:`stalker.models.budget.Good` instance. Returns: Good: The validated good value. """ from stalker.models.budget import Good if not isinstance(good, Good): raise TypeError( f"{self.__class__.__name__}.goods should only " "contain instances of stalker.models.budget.Good, " f"not {good.__class__.__name__}: '{good}'" ) return good class ClientUser(Base): """The association object used in Client-to-User relation. Args: client (Client): The client which the user is affiliated with. user (User): A :class:`.User` instance. """ __tablename__ = "Client_Users" user_id: Mapped[int] = mapped_column( "uid", ForeignKey("Users.id"), primary_key=True ) user: Mapped["User"] = relationship( back_populates="company_role", primaryjoin="ClientUser.user_id==User.user_id", ) client_id: Mapped[int] = mapped_column( "cid", ForeignKey("Clients.id"), primary_key=True ) client: Mapped["Client"] = relationship( back_populates="user_role", primaryjoin="ClientUser.client_id==Client.client_id", ) role_id: Mapped[Optional[int]] = mapped_column("rid", ForeignKey("Roles.id")) role: Mapped[Optional["Role"]] = relationship( primaryjoin="ClientUser.role_id==Role.role_id" ) def __init__(self, client=None, user=None, role=None): self.user = user self.client = client self.role = role @validates("client") def _validate_client(self, key: str, client: "Client") -> "Client": """Validate the given client value. Args: key (str): The name of the validated column. client (Client): The client instance to be validated. Raises: TypeError: If the given client value is not a :class:`.Client` instance. Returns: Client: The validated client instance. """ if client is not None: if not isinstance(client, Client): raise TypeError( f"{self.__class__.__name__}.client should be instance of " "stalker.models.client.Client, " f"not {client.__class__.__name__}: '{client}'" ) return client @validates("user") def _validate_user(self, key: str, user: "User") -> "User": """Validate the given user value. Args: key (str): The name of the validated column. user (User): The user instance to validate. Raises: TypeError: If the given user value is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated user value. """ if user is not None: from stalker.models.auth import User if not isinstance(user, User): raise TypeError( f"{self.__class__.__name__}.user should be an instance of " "stalker.models.auth.User, " f"not {user.__class__.__name__}: '{user}'" ) return user @validates("role") def _validate_role(self, key: str, role: "Role") -> "Role": """Validate the given role instance. Args: key (str): The name of the validated column. role (Role): The role value to be validated. Raises: TypeError: If the given role value is not a :class:`stalker.models.auth.Role` instance. Returns: Role: The validated :class:`stalker.models.auth.Role` instance. """ if role is not None: from stalker.models.auth import Role if not isinstance(role, Role): raise TypeError( f"{self.__class__.__name__}.role should be a " "stalker.models.auth.Role instance, " f"not {role.__class__.__name__}: '{role}'" ) return role ================================================ FILE: src/stalker/models/department.py ================================================ # -*- coding: utf-8 -*- """Department related classes and functions are situated here.""" from typing import Any, Dict, List, Optional, Union from sqlalchemy import ForeignKey from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.log import get_logger from stalker.models.auth import Role, User from stalker.models.entity import Entity logger = get_logger(__name__) class Department(Entity): """The departments that forms the studio itself. The information that a Department object holds is like: * The members of the department * and all the other things those are inherited from the AuditEntity class Two Department object considered the same if they have the same name, the the users list is not important, a "Modeling" department should of course be the same with another department which has the name "Modeling" again. so creating a department object needs the following parameters: Args: users (List[User]): it can be an empty list, so one department can be created without any member in it. But this parameter should be a list of User objects. """ __auto_name__ = False __tablename__ = "Departments" __mapper_args__ = {"polymorphic_identity": "Department"} department_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) users = association_proxy( "user_role", "user", creator=lambda u: DepartmentUser(user=u) ) user_role: Mapped[Optional[List["DepartmentUser"]]] = relationship( back_populates="department", cascade="all, delete-orphan", primaryjoin="Departments.c.id==Department_Users.c.did", doc="""List of users representing the members of this department.""", ) def __init__( self, users: Optional[List[User]] = None, **kwargs: Optional[Dict[str, Any]] ) -> None: super(Department, self).__init__(**kwargs) if users is None: users = [] self.users = users def __eq__(self, other: Any) -> bool: """Check if the other is equal to this one. Args: other (Any): The other Department instance. Returns: bool: True if the other object is also a Department and all the attributes are equal. """ return super(Department, self).__eq__(other) and isinstance(other, Department) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Department, self).__hash__() @validates("user_role") def _validate_user_role( self, key: str, user_role: "DepartmentUser" ) -> "DepartmentUser": """Validate the given user_role value. Args: key (str): The name of the validated column. user_role (DepartmentUser): The user_role value to be validated. Returns: DepartmentUser: The validated user_role value. """ return user_role @property def to_tjp(self) -> str: """Output a TaskJuggler compatible representation. Returns: str: The TaskJuggler compatible representation. """ tab = " " indent = tab tjp = f'resource {self.tjp_id} "{self.tjp_id}" {{' for resource in self.users: tjp += "\n" tjp += "\n".join(f"{indent}{line}" for line in resource.to_tjp.split("\n")) tjp += "\n}" return tjp # DEPARTMENTS_USERS class DepartmentUser(Base): """The association object used in Department-to-User relation.""" __tablename__ = "Department_Users" user_id: Mapped[int] = mapped_column( "uid", ForeignKey("Users.id"), primary_key=True ) user: Mapped["User"] = relationship( back_populates="department_role", primaryjoin="DepartmentUser.user_id==User.user_id", uselist=False, ) department_id: Mapped[int] = mapped_column( "did", ForeignKey("Departments.id"), primary_key=True ) department: Mapped[Department] = relationship( back_populates="user_role", primaryjoin="DepartmentUser.department_id==Department.department_id", uselist=False, ) role_id: Mapped[Optional[int]] = mapped_column("rid", ForeignKey("Roles.id")) role: Mapped[Role] = relationship( primaryjoin="DepartmentUser.role_id==Role.role_id" ) def __init__(self, department=None, user=None, role=None): self.department = department self.user = user self.role = role @validates("department") def _validate_department( self, key: str, department: Union[None, Department] ) -> Union[None, Department]: """Validate the given department value. Args: key (str): The name of the validated column. department (Department): The department value to be validated. Raises: TypeError: If the given user value is not a :class:`.Department` instance. Returns: Department: The validated department value. """ if department is not None: # check if it is instance of Department object if not isinstance(department, Department): raise TypeError( f"{self.__class__.__name__}.department should be a " "stalker.models.department.Department instance, " f"not {department.__class__.__name__}: '{department}'" ) return department @validates("user") def _validate_user( self, key: str, user: Union[None, "User"] ) -> Union[None, "User"]: """Validate the given user value. Args: key (str): The name of the validated column. user (User): The user value to be validated. Raises: TypeError: If the given user value is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated user value. """ if user is not None: if not isinstance(user, User): raise TypeError( f"{self.__class__.__name__}.user should be a " "stalker.models.auth.User instance, " f"not {user.__class__.__name__}: '{user}'" ) return user @validates("role") def _validate_role(self, key: str, role: Union[None, Role]) -> Union[None, Role]: """Validate the given role instance. Args: key (str): The name of the validated column. role (Union[None, Role]): The role value to be validated. Raises: TypeError: If the given role value is not a :class:`stalker.models.auth.Role` instance. Returns: Union[None, Role]: The validated role value. """ if role is not None: if not isinstance(role, Role): raise TypeError( f"{self.__class__.__name__}.role should be a " "stalker.models.auth.Role instance, " f"not {role.__class__.__name__}: '{role}'" ) return role ================================================ FILE: src/stalker/models/entity.py ================================================ # -*- coding: utf-8 -*- """SimpleEntity, Entity, EntityGroup and other related functions are situated here.""" import functools import re import uuid from datetime import datetime from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union import pytz from sqlalchemy import Column, ForeignKey, Integer, String, Table, Text from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.db.types import GenericDateTime from stalker.log import get_logger logger = get_logger(__name__) if TYPE_CHECKING: # pragma: no cover from stalker.models.auth import User from stalker.models.file import File from stalker.models.note import Note from stalker.models.tag import Tag from stalker.models.type import Type class SimpleEntity(Base): """The base class of all the others. The ``SimpleEntity`` is the starting point of the Stalker Object Model, it starts by adding the basic information about an entity which are :attr:`.name`, :attr:`.description`, the audit information like :attr:`.created_by`, :attr:`.updated_by`, :attr:`.date_created`, :attr:`.date_updated` and a couple of naming attributes like :attr:`.nice_name` and last but not least the :attr:`.type` attribute which is very important for entities that needs a type. .. versionadded: 0.2.2.3 :attr:`.html_style` and :attr:`.html_class` attributes: SimpleEntity instances now have two new attributes called :attr:`.html_style` and :attr:`.html_class` which can be used to store html styles and html classes per entity. (Hint: Can be used to colorize different type of Tasks in different colors or different statused tasks in different classes etc.) .. note:: For derived classes if the :attr:`.SimpleEntity.type` needed to be specifically specified, that is it cannot be None or nothing else then a :class:`.Type` instance, set the ``strictly_typed`` class attribute to True:: class NewClass(SimpleEntity): __strictly_typed__ = True This will ensure that the derived class always have a proper :attr:`.SimpleEntity.type` attribute and cannot be initialized without one. Two SimpleEntities considered to be equal if they have the same :attr:`.name`, the other attributes doesn't matter. .. versionadded:: 0.2.0 Name attribute can be skipped. Starting from version 0.2.0 the ``name`` attribute can be skipped. For derived classes use the ``__auto_name__`` class attribute to control auto naming behavior. Args: name (str): A string value that holds the name of this entity. It should not contain any white space at the beginning and at the end of the string. Valid characters are [a-zA-Z0-9_/S]. Advanced:: For classes derived from the SimpleEntity, if an automatic name is desired, the ``__auto_name__`` class attribute can be set to True. Then Stalker will automatically generate an uuid4 sequence for the name attribute. description (str): A string attribute that holds the description of this entity object, it could be an empty string, and it could not again have white spaces at the beginning and at the end of the string, again any given objects will be converted to strings generic_text (str): A string attribute that holds any text based information that should be affiliated with this entity, it could be an empty string, and it could not again have white spaces at the beginning and at the end of the string, again any given objects will be converted to strings. created_by (User): The :class:`.User` who has created this object. updated_by (User): The :class:`.User` who has updated this object lastly. The created_by and updated_by attributes point the same object if this object is just created. date_created (datetime): The date that this object is created. date_updated (datetime): The date that this object is updated lastly. For newly created entities this is equal to date_created and the date_updated cannot point a date which is before date_created. type (Type): The type of the current SimpleEntity. Used across several places in Stalker. Can be None. The default value is None. """ # auto generate name values __auto_name__ = True __strictly_typed__ = False # TODO: Allow the user to specify the formatting of the name attribute with # a formatter function __name_formatter__ = None __tablename__ = "SimpleEntities" id: Mapped[int] = mapped_column(primary_key=True) entity_type: Mapped[str] = mapped_column(String(128), nullable=False) __mapper_args__ = { "polymorphic_on": entity_type, "polymorphic_identity": "SimpleEntity", } name: Mapped[str] = mapped_column( String(256), nullable=False, doc="Name of this object" ) description: Mapped[Optional[str]] = mapped_column( Text, doc="Description of this object." ) created_by_id: Mapped[Optional[int]] = mapped_column( ForeignKey("Users.id", use_alter=True, name="SimpleEntities_created_by_id_fkey"), doc="The id of the :class:`.User` who has created this entity.", ) created_by: Mapped[Optional["User"]] = relationship( backref="entities_created", primaryjoin="SimpleEntity.created_by_id==User.user_id", doc="The :class:`.User` who has created this object.", ) updated_by_id: Mapped[Optional[int]] = mapped_column( "updated_by_id", ForeignKey("Users.id", use_alter=True, name="SimpleEntities_updated_by_id_fkey"), nullable=True, doc="The id of the :class:`.User` who has updated this entity.", ) updated_by: Mapped[Optional["User"]] = relationship( backref="entities_updated", primaryjoin="SimpleEntity.updated_by_id==User.user_id", post_update=True, doc="The :class:`.User` who has updated this object.", ) date_created: Mapped[Optional[datetime]] = mapped_column( GenericDateTime, default=functools.partial(datetime.now, pytz.utc), doc="""A :class:`datetime` instance showing the creation date and time of this object.""", ) date_updated: Mapped[Optional[datetime]] = mapped_column( GenericDateTime, default=functools.partial(datetime.now, pytz.utc), doc="""A :class:`datetime` instance showing the update date and time of this object.""", ) type_id: Mapped[Optional[int]] = mapped_column( ForeignKey("Types.id", use_alter=True, name="SimpleEntities_type_id_fkey"), doc="""The id of the :class:`.Type` of this entity. Mainly used by SQLAlchemy to create a Many-to-One relates between SimpleEntities and Types. """, ) type: Mapped[Optional["Type"]] = relationship( primaryjoin="SimpleEntities.c.type_id==Types.c.id", post_update=True, doc="""The type of the object. It is a :class:`.Type` instance with a proper :attr:`.Type.target_entity_type`. """, ) generic_data: Mapped[Optional[List["SimpleEntity"]]] = relationship( secondary="SimpleEntity_GenericData", primaryjoin="SimpleEntities.c.id==" "SimpleEntity_GenericData.c.simple_entity_id", secondaryjoin="SimpleEntity_GenericData.c.other_simple_entity_id==" "SimpleEntities.c.id", post_update=True, doc="This attribute can hold any kind of data which exists in SOM.", ) generic_text: Mapped[Optional[str]] = mapped_column( "generic_text", Text, doc="This attribute can hold any text." ) thumbnail_id: Mapped[Optional[int]] = mapped_column( ForeignKey( "Files.id", use_alter=True, name="SimpleEntities_thumbnail_id_fkey", ) ) thumbnail: Mapped[Optional["File"]] = relationship( primaryjoin="SimpleEntities.c.thumbnail_id==Files.c.id", post_update=True, ) html_style: Mapped[Optional[str]] = mapped_column( String(64), nullable=True, default="" ) html_class: Mapped[Optional[str]] = mapped_column( String(64), nullable=True, default="" ) stalker_version: Mapped[Optional[str]] = mapped_column(String(256)) def __init__( self, name: Optional[str] = None, description: Optional[str] = "", generic_text: str = "", type: Optional["Type"] = None, created_by: Optional["User"] = None, updated_by: Optional["User"] = None, date_created: Optional[datetime] = None, date_updated: Optional[datetime] = None, thumbnail: Optional["File"] = None, html_style: Optional[str] = "", html_class: Optional[str] = "", **kwargs: Optional[Dict[str, Any]], ) -> None: # noqa: W0613 # name and nice_name self._nice_name = "" self.name = name self.description = description self.created_by = created_by self.updated_by = updated_by if date_created is None: date_created = datetime.now(pytz.utc) if date_updated is None: date_updated = date_created self.date_created = date_created self.date_updated = date_updated self.type = type self.thumbnail = thumbnail self.generic_text = generic_text self.html_style = html_style self.html_class = html_class import stalker self.stalker_version = stalker.__version__ def __repr__(self) -> str: """Return the str representation of this SimpleEntity. Returns: str: The str representation of this SimpleEntity. """ return f"<{self.name} ({self.entity_type})>" def __eq__(self, other: Any) -> bool: """Check equality. Args: other (Any): An object to check the equality of. Returns: bool: If the other is a SimpleEntity and its name equals to this one. """ from stalker.db.session import DBSession with DBSession.no_autoflush: return isinstance(other, SimpleEntity) and self.name == other.name def __ne__(self, other: Any) -> bool: """Check inequality. This uses the __eq__ operator to get the inequality. Args: other (Any): An object to check the inequality of. Returns: bool: True if other is not equal to this instance. """ return not self.__eq__(other) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return hash("{}:{}:{}".format(self.id, self.name, self.entity_type)) @validates("description") def _validate_description(self, key: str, description: str) -> str: """Validate the given description value. Args: key (str): The name of the validated column. description (str): The description value to be validated. Raises: TypeError: If the description is not None and not a str. Returns: str: The validated description value. """ if description is None: description = "" if not isinstance(description, str): raise TypeError( "{}.description should be a string, not {}: '{}'".format( self.__class__.__name__, description.__class__.__name__, description ) ) return description @validates("generic_text") def _validate_generic_text(self, key: str, generic_text: str) -> str: """Validate the given generic_text value. Args: key (str): The name of the validated column. generic_text (str): The generic_text value to be validated. Raises: TypeError: If the generic_text is not None and not a str. Returns: str: The validated generic_text value. """ if generic_text is None: generic_text = "" if not isinstance(generic_text, str): raise TypeError( "{}.generic_text should be a string, not {}: '{}'".format( self.__class__.__name__, generic_text.__class__.__name__, generic_text, ) ) return generic_text @validates("name") def _validate_name(self, key: str, name: str) -> str: """Validate the name value. Args: key (str): The name of the validated column. name (str): The name value to be validated. Raises: TypeError: If the name is not a str. ValueError: If the name becomes an empty str after formatting. Returns: str: The validated name value. """ if self.__auto_name__: if name is None or name == "": # generate a uuid4 name = "{}_{}".format( self.__class__.__name__, uuid.uuid4().urn.split(":")[2], ) # it is None if name is None: raise TypeError(f"{self.__class__.__name__}.name cannot be None") if not isinstance(name, str): raise TypeError( f"{self.__class__.__name__}.name should be a string, " f"not {name.__class__.__name__}: '{name}'" ) name = self._format_name(name) # it is empty if name == "": raise ValueError( f"{self.__class__.__name__}.name cannot be an empty string" ) # also set the nice_name self._nice_name = self._format_nice_name(name) return name @classmethod def _format_name(cls, name: str) -> str: """Format the name value. Args: name (str): The name value. Returns: str: The formatted name value. """ # remove unnecessary characters from the string name = name.strip() # remove multiple spaces name = re.sub(r"\s+", " ", name) return name @classmethod def _format_nice_name(cls, nice_name: str) -> str: """Format the given nice name value. Args: nice_name (str): The nice_name value to be formatted. Returns: str: The formatted nice name. """ # remove unnecessary characters from the string nice_name = nice_name.strip() nice_name = re.sub(r"([^a-zA-Z0-9\s_\-@]+)", "", nice_name).strip() # remove all the characters which are not alphabetic from the start of # the string nice_name = re.sub(r"(^[^a-zA-Z0-9]+)", "", nice_name) # remove multiple spaces nice_name = re.sub(r"\s+", " ", nice_name) # # replace camel case letters # nice_name = re.sub(r"(.+?[a-z]+)([A-Z])", r"\1_\2", nice_name) # replace white spaces and dashes with underscore nice_name = re.sub("([ -])+", r"_", nice_name) # remove multiple underscores nice_name = re.sub(r"(_+)", r"_", nice_name) return nice_name @property def nice_name(self) -> str: """Nice name of this object. It has the same value with the name (contextually) but with a different format like, all the white spaces replaced by underscores ("_"), all the CamelCase form will be expanded by underscore (_) characters, and it is always lower case. Returns: str: The nice name value. """ # also set the nice_name # if self._nice_name is None or self._nice_name == "": self._nice_name = self._format_nice_name(self.name) return self._nice_name @validates("created_by") def _validate_created_by( self, key: str, created_by: Union[None, "User"] ) -> Union[None, "User"]: """Validate the given created_by value. Args: key (str): The name of the validated column. created_by (Union[None, User]): The created_by value to be validated. Raises: TypeError: If the created_by value is not None and not a :class:`stalker.models.auth.User` instance. Returns: Union[None, User]: The validated created_by value. """ from stalker.models.auth import User if created_by is not None: if not isinstance(created_by, User): raise TypeError( f"{self.__class__.__name__}.created_by should be a " "stalker.models.auth.User instance, " f"not {created_by.__class__.__name__}: '{created_by}'" ) return created_by @validates("updated_by") def _validate_updated_by( self, key: str, updated_by: Union[None, "User"] ) -> Union[None, "User"]: """Validate the given updated_by value. Args: key (str): The name of the validated column. updated_by (Union[None, User]): The updated_by value to be validated. Raises: TypeError: If the updated_by value is not None and not a :class:`stalker.models.auth.User` instance. Returns: Union[None, User]: The validated updated_by value. """ from stalker.models.auth import User if updated_by is None: # set it to what created_by attribute has updated_by = self.created_by if updated_by is not None: if not isinstance(updated_by, User): raise TypeError( f"{self.__class__.__name__}.updated_by should be a " "stalker.models.auth.User instance, " f"not {updated_by.__class__.__name__}: '{updated_by}'" ) return updated_by @validates("date_created") def _validate_date_created(self, key: str, date_created: datetime) -> datetime: """Validate the given date_created value. Args: key (str): The name of the validated column. date_created (datetime): The value to be validated. Raises: TypeError: If the given date_created value is None or not a datetime instance. Returns: datetime: The validated date_created value. """ if date_created is None: raise TypeError(f"{self.__class__.__name__}.date_created cannot be None") if not isinstance(date_created, datetime): raise TypeError( f"{self.__class__.__name__}.date_created should be a " "datetime.datetime instance, " f"not {date_created.__class__.__name__}: '{date_created}'" ) return date_created @validates("date_updated") def _validate_date_updated(self, key: str, date_updated: datetime) -> datetime: """Validate the given date_updated. Args: key (str): The name of the validated column. date_updated (datetime): The date_updated to be validated. Raises: TypeError: If the date_updated value is ``None`` or date_updated is not a ``datetime`` instance. ValueError: If the date_updated is before than the date_created. Returns: datetime: The validated datetime_updated value. """ # it is None if date_updated is None: raise TypeError(f"{self.__class__.__name__}.date_updated cannot be None") # it is not a datetime instance if not isinstance(date_updated, datetime): raise TypeError( f"{self.__class__.__name__}.date_updated should be a " "datetime.datetime instance, " f"not {date_updated.__class__.__name__}: '{date_updated}'" ) # lower than date_created if date_updated < self.date_created: raise ValueError( "{class_name}.date_updated could not be set to a date before " "{class_name}.date_created, try setting the ``date_created`` " "first.".format(class_name=self.__class__.__name__) ) return date_updated @validates("type") def _validate_type(self, key: str, type_: "Type") -> "Type": """Validate the given type value. Args: key (str): The name of the validated column. type_ (Type): The type value to be validated. Raises: TypeError: If this class is a strictly typed class and the type_ is not None and not a Type instance. Returns: Type: The validated type_ value. """ if self.__strictly_typed__ or type_ is not None: from stalker.models.type import Type if not isinstance(type_, Type): raise TypeError( f"{self.__class__.__name__}.type must be a " "stalker.models.type.Type instance, " f"not {type_.__class__.__name__}: '{type_}'" ) return type_ @validates("thumbnail") def _validate_thumbnail(self, key: str, thumb: "File") -> "File": """Validate the given thumb value. Args: key (str): The name of the validated column. thumb (File): The thumb value to be validated. Raises: TypeError: If the given thumb value is not None and not a File instance. Returns: Union[None, File]: The validated thumb value. """ if thumb is not None: from stalker import File if not isinstance(thumb, File): raise TypeError( f"{self.__class__.__name__}.thumbnail should be a " "stalker.models.file.File instance, " f"not {thumb.__class__.__name__}: '{thumb}'" ) return thumb @property def tjp_id(self) -> str: """Return TaskJuggler compatible id. Returns: str: The TaskJuggler compatible id. """ return f"{self.__class__.__name__}_{self.id}" @property def to_tjp(self) -> str: """Render a TaskJuggler compliant str used for TaskJuggler integration. Needs to be overridden in inherited classes. Raises: NotImplementedError: Always. """ raise NotImplementedError( f"This property is not implemented in {self.__class__.__name__}" ) @validates("html_style") def _validate_html_style(self, key: str, html_style: str) -> str: """Validate the given html_style value. Args: key (str): The name of the validated column. html_style (str): The html_style to be validated. Raises: TypeError: If the given html_style is not a str. Returns: str: The validated html_style value. """ if html_style is None: html_style = "" if not isinstance(html_style, str): raise TypeError( f"{self.__class__.__name__}.html_style should be a str, " f"not {html_style.__class__.__name__}: '{html_style}'" ) return html_style @validates("html_class") def _validate_html_class(self, key: str, html_class: str) -> str: """Validate the given html_class value. Args: key (str): The name of the validated column. html_class (str): The html_class to be validated. Raises: TypeError: If the html_class is not a str. Returns: str: The validated html_class value. """ if html_class is None: html_class = "" if not isinstance(html_class, str): raise TypeError( f"{self.__class__.__name__}.html_class should be a str, " f"not {html_class.__class__.__name__}: '{html_class}'" ) return html_class class Entity(SimpleEntity): """Another base data class that adds tags and notes to the attributes list. This is the entity class which is derived from the SimpleEntity and adds only tags to the list of parameters. Two Entities considered equal if they have the same name. It doesn't matter if they have different tags or notes. Args: tags (List[Tag]): A list of :class:`.Tag` objects related to this entity. Tags could be an empty list, or when omitted it will be set to an empty list. notes (List[Note]): A list of :class:`.Note` instances. Can be an empty list, or when omitted it will be set to an empty list, when set to None it will be converted to an empty list. """ __auto_name__ = True __tablename__ = "Entities" __mapper_args__ = {"polymorphic_identity": "Entity"} entity_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) tags: Mapped[Optional[List["Tag"]]] = relationship( "Tag", secondary="Entity_Tags", backref="entities", doc="""A list of tags attached to this object. It is a list of :class:`.Tag` instances which shows the tags of this object""", ) notes: Mapped[Optional[List["Note"]]] = relationship( "Note", secondary="Entity_Notes", backref="entities", doc="""All the :class:`.Notes` s attached to this entity. It is a list of :class:`.Note` instances or an empty list, setting it to None will raise a TypeError. """, ) def __init__( self, tags: Optional[List["Tag"]] = None, notes=None, **kwargs ) -> None: super(Entity, self).__init__(**kwargs) if tags is None: tags = [] if notes is None: notes = [] self.tags = tags self.notes = notes @validates("notes") def _validate_notes(self, key: str, note: "Note") -> "Note": """Validate the given note value. Args: key (str): The name of the validated column. note (Note): The note value to be validated. Raises: TypeError: If the given note value is not a Note instance. Returns: Note: The validated note value. """ from stalker.models.note import Note if not isinstance(note, Note): raise TypeError( f"{self.__class__.__name__}.note should be a stalker.models.note.Note " f"instance, not {note.__class__.__name__}: '{note}'" ) return note @validates("tags") def _validate_tags(self, key: str, tag: "Tag") -> "Tag": """Validate the given tag value. Args: key (str): The name of the validated column. tag (Tag): The tag value to be validated. Raises: TypeError: If the given tag value is not a Tag instance. Returns: Tag: The validated tag value. """ from stalker.models.tag import Tag if not isinstance(tag, Tag): raise TypeError( f"{self.__class__.__name__}.tag should be a stalker.models.tag.Tag " f"instance, not {tag.__class__.__name__}: '{tag}'" ) return tag def __eq__(self, other: Any) -> bool: """Check if the other object is equal to this one. Args: other (Any): An object. Returns: bool: True if the other object is also an Entity instance and has the same basic attribute values. """ return super(Entity, self).__eq__(other) and isinstance(other, Entity) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Entity, self).__hash__() class EntityGroup(Entity): """Groups a wide variety of objects together to let one easily reach them. :class:`.EntityGroup` helps to group different types of entities together to let one easily reach to them. """ __auto_name__ = True __tablename__ = "EntityGroups" __mapper_args__ = {"polymorphic_identity": "EntityGroup"} entity_group_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) entities: Mapped[Optional[List[SimpleEntity]]] = relationship( secondary="EntityGroup_Entities", post_update=True, backref="entity_groups", doc="All the :class:`.SimpleEntity`s grouped in this EntityGroup.", ) def __init__( self, entities: Optional[List[Entity]] = None, **kwargs: Optional[Dict[str, Any]], ) -> None: super(Entity, self).__init__(**kwargs) if entities is None: entities = [] self.entities = entities @validates("entities") def _validate_entities(self, key: str, entity: SimpleEntity) -> SimpleEntity: """Validate the given entity value. Args: key (str): The name of the validated column. entity (SimpleEntity): The entity value to be validated. Raises: TypeError: If the entity is not a SimpleEntity instance. Returns: SimpleEntity: The validated entity value. """ if not isinstance(entity, SimpleEntity): raise TypeError( f"{self.__class__.__name__}.entities should be a list of " f"SimpleEntities, not {entity.__class__.__name__}: '{entity}'" ) return entity def __eq__(self, other: Any) -> bool: """Check if the other is equal to this instance. Args: other (Any): The other EntityGroup to check the equality of. Returns: bool: True if the other is also a EntityGroup instance and has the same attribute values. """ return ( super(EntityGroup, self).__eq__(other) and isinstance(other, EntityGroup) and self.entities == other.entities ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(EntityGroup, self).__hash__() # Entity Tags Entity_Tags = Table( "Entity_Tags", Base.metadata, Column( "entity_id", Integer, ForeignKey("Entities.id"), primary_key=True, ), Column( "tag_id", Integer, ForeignKey("Tags.id"), primary_key=True, ), ) # Entity Notes Entity_Notes = Table( "Entity_Notes", Base.metadata, Column( "entity_id", Integer, ForeignKey("Entities.id"), primary_key=True, ), Column( "note_id", Integer, ForeignKey("Notes.id"), primary_key=True, ), ) # SimpleEntity Generic Data SimpleEntity_GenericData = Table( "SimpleEntity_GenericData", Base.metadata, Column( "simple_entity_id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ), Column( "other_simple_entity_id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True, ), ) # EntityGroup Entities EntityGroup_Entities = Table( "EntityGroup_Entities", Base.metadata, Column("entity_group_id", Integer, ForeignKey("EntityGroups.id"), primary_key=True), Column( "other_entity_id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ), ) ================================================ FILE: src/stalker/models/enum.py ================================================ # -*- coding: utf-8 -*- """Enum classes are situated here.""" from enum import Enum, IntEnum from typing import Union from sqlalchemy import Enum as saEnum, Integer, TypeDecorator 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"] ) -> "ScheduleConstraint": """Validate and return type enum from an input int or str value. Args: constraint (Union[str, ScheduleConstraint]): Input `constraint` 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 a ScheduleConstraint enum value or an " "int or a str, " f"not {constraint.__class__.__name__}: '{constraint}'" ) if isinstance(constraint, str): constraint_name_lut = dict( [ (c.name.lower(), c.name.title() if c.name != "NONE" else "NONE") for c 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 a ScheduleConstraint enum value or " "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.""" cache_ok = True 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: int, dialect: str) -> ScheduleConstraint: """Return a ScheduleConstraint. Args: value (int): The integer value. dialect (str): The name of the dialect. Returns: ScheduleConstraint: ScheduleConstraint created from the DB data. """ return ScheduleConstraint.to_constraint(value) class TimeUnit(Enum): """The time unit enum.""" Minute = "min" Hour = "h" Day = "d" Week = "w" Month = "m" Year = "y" def __str__(self) -> str: """Return the string representation. Returns: str: The string representation. """ return str(self.value) @classmethod def to_unit(cls, unit: Union[str, "TimeUnit"]) -> "TimeUnit": """Convert the given unit value to a TimeUnit enum. Args: unit (Union[str, TimeUnit]): The value to convert to a TimeUnit. Raises: TypeError: Input value type is invalid. ValueError: Input value is invalid. Returns: TimeUnit: The enum. """ if not isinstance(unit, (str, TimeUnit)): raise TypeError( "unit should be a TimeUnit enum value or one of {}, " "not {}: '{}'".format( [u.name.title() for u in cls] + [u.value for u in cls], unit.__class__.__name__, unit, ) ) if isinstance(unit, str): unit_name_lut = dict([(u.name.lower(), u.name) for u in cls]) unit_name_lut.update(dict([(u.value.lower(), u.name) for u in cls])) unit_lower_case = unit.lower() if unit_lower_case not in unit_name_lut: raise ValueError( "unit should be a TimeUnit enum value or one of {}, " "not '{}'".format( [u.name.title() for u in cls] + [u.value for u in cls], unit ) ) return cls.__members__[unit_name_lut[unit_lower_case]] return unit class TimeUnitDecorator(TypeDecorator): """Store TimeUnit as an str and restore as TimeUnit.""" cache_ok = True impl = saEnum(*[u.value for u in TimeUnit], name="TimeUnit") def process_bind_param(self, value: TimeUnit, dialect: str) -> str: """Return the str value of the TimeUnit. Args: value (TimeUnit): The TimeUnit value. dialect (str): The name of the dialect. Returns: str: The value of the TimeUnit. """ # just return the value return value.value def process_result_value(self, value: str, dialect: str) -> TimeUnit: """Return a TimeUnit. Args: value (str): The string value to convert to TimeUnit. dialect (str): The name of the dialect. Returns: TimeUnit: The TimeUnit which is created from the DB data. """ return TimeUnit.to_unit(value) class ScheduleModel(Enum): """The schedule model enum.""" Effort = "effort" Duration = "duration" Length = "length" def __str__(self) -> str: """Return the string representation. Returns: str: The string representation. """ return str(self.value) @classmethod def to_model(cls, model: Union[str, "ScheduleModel"]) -> "ScheduleModel": """Convert the given model value to a ScheduleModel enum. Args: model (Union[str, ScheduleModel]): The value to convert to a ScheduleModel. Raises: TypeError: Input value type is invalid. ValueError: Input value is invalid. Returns: ScheduleModel: The enum. """ if not isinstance(model, (str, ScheduleModel)): raise TypeError( "model should be a ScheduleModel enum value or one of {}, " "not {}: '{}'".format( [m.name.title() for m in cls] + [m.value for m in cls], model.__class__.__name__, model, ) ) if isinstance(model, str): model_name_lut = dict([(m.name.lower(), m.name) for m in cls]) model_name_lut.update(dict([(m.value.lower(), m.name) for m in cls])) model_lower_case = model.lower() if model_lower_case not in model_name_lut: raise ValueError( "model should be a ScheduleModel enum value or one of {}, " "not '{}'".format( [m.name.title() for m in cls] + [m.value for m in cls], model ) ) return cls.__members__[model_name_lut[model_lower_case]] return model class ScheduleModelDecorator(TypeDecorator): """Store ScheduleModel as a str and restore as ScheduleModel.""" cache_ok = True impl = saEnum(*[m.value for m in ScheduleModel], name="ScheduleModel") def process_bind_param(self, value, dialect) -> str: """Return the str value of the ScheduleModel. Args: value (ScheduleModel): The ScheduleModel value. dialect (str): The name of the dialect. Returns: str: The value of the ScheduleModel. """ # just return the value return value.value def process_result_value(self, value: str, dialect: str) -> ScheduleModel: """Return a ScheduleModel. Args: value (str): The string value to convert to ScheduleModel. dialect (str): The name of the dialect. Returns: ScheduleModel: The ScheduleModel created from the DB data. """ return ScheduleModel.to_model(value) class DependencyTarget(Enum): """The dependency target enum.""" OnStart = "onstart" OnEnd = "onend" def __str__(self) -> str: """Return the string representation. Returns: str: The string representation. """ return str(self.value) @classmethod def to_target(cls, target: Union[str, "DependencyTarget"]) -> "DependencyTarget": """Convert the given target value to a DependencyTarget enum. Args: target (Union[str, DependencyTarget]): The value to convert to a DependencyTarget. Raises: TypeError: Input value type is invalid. ValueError: Input value is invalid. Returns: DependencyTarget: The enum. """ if not isinstance(target, (str, DependencyTarget)): raise TypeError( "target should be a DependencyTarget enum value or one of {}, " "not {}: '{}'".format( [t.name for t in cls] + [t.value for t in cls], target.__class__.__name__, target, ) ) if isinstance(target, str): target_name_lut = dict([(t.name.lower(), t.name) for t in cls]) target_name_lut.update(dict([(t.value.lower(), t.name) for t in cls])) target_lower_case = target.lower() if target_lower_case not in target_name_lut: raise ValueError( "target should be a DependencyTarget enum value or one of {}, " "not '{}'".format( [t.name for t in cls] + [t.value for t in cls], target ) ) return cls.__members__[target_name_lut[target_lower_case]] return target class DependencyTargetDecorator(TypeDecorator): """Store DependencyTarget as an enum and restore as DependencyTarget.""" cache_ok = True impl = saEnum(*[m.value for m in DependencyTarget], name="TaskDependencyTarget") def process_bind_param(self, value, dialect) -> str: """Return the str value of the DependencyTarget. Args: value (DependencyTarget): The DependencyTarget value. dialect (str): The name of the dialect. Returns: str: The value of the DependencyTarget. """ # just return the value return value.value def process_result_value(self, value: str, dialect: str) -> DependencyTarget: """Return a DependencyTarget. Args: value (str): The string value to convert to DependencyTarget. dialect (str): The name of the dialect. Returns: DependencyTarget: The DependencyTarget created from str. """ return DependencyTarget.to_target(value) class TraversalDirection(IntEnum): """The traversal direction enum.""" DepthFirst = 0 BreadthFirst = 1 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_direction( cls, direction: Union[int, str, "TraversalDirection"] ) -> "TraversalDirection": """Convert the given direction value to a TraversalDirection enum. Args: direction (Union[int, str, TraversalDirection]): The value to convert to a TraversalDirection. Raises: TypeError: Input value type is invalid. ValueError: Input value is invalid. Returns: TraversalDirection: The enum. """ if not isinstance(direction, (int, str, TraversalDirection)): raise TypeError( "direction should be a TraversalDirection enum value " "or one of {}, not {}: '{}'".format( [d.name for d in cls] + [d.value for d in cls], direction.__class__.__name__, direction, ) ) if isinstance(direction, str): direction_name_lut = dict([(d.name.lower(), d.name) for d in cls]) direction_name_lut.update(dict([(d.value, d.name) for d in cls])) direction_lower_case = direction.lower() if direction_lower_case not in direction_name_lut: raise ValueError( "direction should be a TraversalDirection enum value or " "one of {}, not '{}'".format( [d.name for d in cls] + [d.value for d in cls], direction, ) ) return cls.__members__[direction_name_lut[direction_lower_case]] return direction ================================================ FILE: src/stalker/models/file.py ================================================ # -*- coding: utf-8 -*- """File related classes and utility functions are situated here.""" import os from typing import Any, Dict, Generator, List, Optional, Union from sqlalchemy import ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, validates from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.enum import TraversalDirection from stalker.models.mixins import ReferenceMixin from stalker.utils import walk_hierarchy logger = get_logger(__name__) class File(Entity, ReferenceMixin): """Holds data about files or file sequences. Files are all about giving some external information to the current entity (external to the database, so it can be something on the :class:`.Repository` or in the Web or anywhere that the server can reach). The type of the file (general, file, folder, web page, image, image sequence, video, movie, sound, text etc.) can be defined by a :class:`.Type` instance (you can also use multiple :class:`.Tag` instances to add more information, and to filter them back). Again it is defined by the needs of the studio. For sequences of files the file name should be in "%h%p%t %R" format in PySeq_ formatting rules. There are three secondary attributes (properties to be more precise) ``path``, ``filename`` and ``extension``. These attributes are derived from the :attr:`.full_path` attribute and they modify it. Path It is the path part of the full_path. Filename It is the filename part of the full_path, also includes the extension, so changing the filename also changes the extension part. Extension It is the extension part of the full_path. It also includes the extension separator ('.' for most of the file systems). .. versionadded:: 1.1.0 Inputs or references can now be tracked per File instance through the :attr:`.File.references` attribute. So, that all the references can be tracked per individual file instance. Args: full_path (str): The full path to the File, it can be a path to a folder or a file in the file system, or a web page. For file sequences use "%h%p%t %R" format, for more information see `PySeq Documentation`_. It can be set to empty string (or None which will be converted to an empty string automatically). .. _PySeq: http://packages.python.org/pyseq/ .. _PySeq Documentation: http://packages.python.org/pyseq/ """ __auto_name__ = True __tablename__ = "Files" __mapper_args__ = {"polymorphic_identity": "File"} file_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) # this is a limit for most original_filename: Mapped[Optional[str]] = mapped_column(String(256)) # file systems full_path: Mapped[Optional[str]] = mapped_column( Text, doc="The full path of the url to the file." ) created_with: Mapped[Optional[str]] = mapped_column(String(256)) def __init__( self, full_path: Optional[str] = "", original_filename: Optional[str] = "", references: Optional[List["File"]] = None, created_with: Optional[str] = None, **kwargs: Optional[Dict[str, Any]], ) -> None: super(File, self).__init__(**kwargs) ReferenceMixin.__init__(self, references=references) self.full_path = full_path self.original_filename = original_filename self.created_with = created_with @validates("full_path") def _validate_full_path(self, key: str, full_path: Union[None, str]) -> str: """Validate the given full_path value. Args: key (str): The name of the validated column. full_path (str): The full_path value to be validated. Raises: TypeError: If the given full_path is not a str. Returns: str: The validated full_path value. """ if full_path is None: full_path = "" if not isinstance(full_path, str): raise TypeError( f"{self.__class__.__name__}.full_path should be a str, " f"not {full_path.__class__.__name__}: '{full_path}'" ) return self._format_path(full_path) @validates("created_with") def _validate_created_with( self, key: str, created_with: Union[None, str] ) -> Union[None, str]: """Validate the given created_with value. Args: key (str): The name of the validated column. created_with (str): The name of the application used to create this File. Raises: TypeError: If the given created_with attribute is not None and not a string. Returns: Union[None, str]: The validated created with value. """ if created_with is not None and not isinstance(created_with, str): raise TypeError( "{}.created_with should be an instance of str, not {}: '{}'".format( self.__class__.__name__, created_with.__class__.__name__, created_with, ) ) return created_with @validates("original_filename") def _validate_original_filename( self, key: str, original_filename: Union[None, str] ) -> str: """Validate the given original_filename value. Args: key (str): The name of the validated column. original_filename (str): The original filename value to be validated. Raises: TypeError: If the given original_filename value is not a str. Returns: str: The validated original_filename value. """ filename_from_path = os.path.basename(self.full_path) if original_filename is None: original_filename = filename_from_path if original_filename == "": original_filename = filename_from_path if not isinstance(original_filename, str): raise TypeError( f"{self.__class__.__name__}.original_filename should be a str, " f"not {original_filename.__class__.__name__}: '{original_filename}'" ) return original_filename @staticmethod def _format_path(path: Union[bytes, str]) -> str: """Format the path to internal format. The path is using the Linux forward slashes for path separation. Args: path (Union[bytes, str]): The path value to be formatted. Returns: str: The formatted path value. """ if isinstance(path, bytes): path = path.decode("utf-8") return path.replace("\\", "/") @property def path(self) -> str: """Return the path part of the full_path. Returns: str: The path part of the full_path value. """ return os.path.split(self.full_path)[0] @path.setter def path(self, path: str) -> None: """Set the path part of the full_path attribute. Args: path (str): The new path value. Raises: TypeError: If the given path value is not a str. ValueError: If the given path is an empty str. """ if path is None: raise TypeError(f"{self.__class__.__name__}.path cannot be set to None") if not isinstance(path, str): raise TypeError( f"{self.__class__.__name__}.path should be a str, " f"not {path.__class__.__name__}: '{path}'" ) if path == "": raise ValueError( f"{self.__class__.__name__}.path cannot be an empty string" ) self.full_path = self._format_path(os.path.join(path, self.filename)) @property def filename(self) -> str: """Return the filename part of the full_path attribute. Returns: str: The filename part of the full_path attribute. """ return os.path.split(self.full_path)[1] @filename.setter def filename(self, filename: Union[None, str]) -> None: """Set the filename part of the full_path attr. Args: filename (Union[None, str]): The new filename. Raises: TypeError: If the given filename is not a str. """ if filename is None: filename = "" if not isinstance(filename, str): raise TypeError( f"{self.__class__.__name__}.filename should be a str, " f"not {filename.__class__.__name__}: '{filename}'" ) self.full_path = self._format_path(os.path.join(self.path, filename)) @property def extension(self) -> str: """Return the extension value. Returns: str: The extension extracted from the full_path value. """ return os.path.splitext(self.full_path)[1] @extension.setter def extension(self, extension: Union[None, str]) -> None: """Set the extension value. Args: extension (Union[None, str]): The new extension value. Raises: TypeError: If the given extension value is not a str. """ if extension is None: extension = "" if not isinstance(extension, str): raise TypeError( f"{self.__class__.__name__}.extension should be a str, " f"not {extension.__class__.__name__}: '{extension}'" ) if extension != "": if not extension.startswith(os.path.extsep): extension = os.path.extsep + extension self.filename = os.path.splitext(self.filename)[0] + extension @property def absolute_full_path(self) -> str: """Return the absolute full path of the file. Returns: str: The absolute full path of the file. """ return os.path.normpath(os.path.expandvars(self.full_path)) @property def absolute_path(self) -> str: """Return the absolute path of the file. Returns: str: The absolute path of the file. """ return os.path.dirname(self.absolute_full_path) def walk_references( self, method: Union[int, str, TraversalDirection] = TraversalDirection.DepthFirst, ) -> Generator[None, "File", None]: """Walk the references of this file. Args: method (Union[int, str, TraversalDirection]): The walk method defined by the :class:`.TraversalDirection` enum. Yields: File: Yield the File instances. """ for v in walk_hierarchy(self, "references", method=method): yield v def __eq__(self, other: Any) -> bool: """Check if the other is equal to this File. Args: other (Any): The other object to be checked for equality. Returns: bool: If the other object is a File instance and has the same full_path and type value. """ return ( super(File, self).__eq__(other) and isinstance(other, File) and self.full_path == other.full_path and self.type == other.type ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(File, self).__hash__() ================================================ FILE: src/stalker/models/format.py ================================================ # -*- coding: utf-8 -*- """Image format related classes and utility functions are situated here.""" from typing import Any, Optional, Union from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column, validates from stalker.log import get_logger from stalker.models.entity import Entity logger = get_logger(__name__) class ImageFormat(Entity): """Common image formats for the :class:`.Project` s. Args: width (Union[int, float]): The width of the format, it cannot be zero or negative, if a float number is given it will be converted to integer. height (Union[int, float]): The height of the format, it cannot be zero or negative, if a float number is given it will be converted to integer. pixel_aspect ((Union[int, float])): The pixel aspect ratio of the current ImageFormat object, it cannot be zero or negative, and if given as an integer it will be converted to a float, the default value is 1.0. print_resolution (Union[int, float]): The print resolution of the ImageFormat given as DPI (dot-per-inch). It cannot be zero or negative. """ __auto_name__ = False __tablename__ = "ImageFormats" __mapper_args__ = {"polymorphic_identity": "ImageFormat"} imageFormat_id: Mapped[int] = mapped_column( "id", Integer, ForeignKey("Entities.id"), primary_key=True, ) width: Mapped[Optional[int]] = mapped_column( doc="""The width of this format. * the width should be set to a positive non-zero integer * integers are also accepted but will be converted to float * for improper inputs the object will raise an exception. """, ) height: Mapped[Optional[int]] = mapped_column( doc="""The height of this format * the height should be set to a positive non-zero integer * integers are also accepted but will be converted to float * for improper inputs the object will raise an exception. """, ) pixel_aspect: Mapped[Optional[float]] = mapped_column( default=1.0, doc="""The pixel aspect ratio of this format. * the pixel_aspect should be set to a positive non-zero float * integers are also accepted but will be converted to float * for improper inputs the object will raise an exception """, ) print_resolution: Mapped[Optional[float]] = mapped_column( default=300.0, doc="""The print resolution of this format * it should be set to a positive non-zero float or integer * integers are also accepted but will be converted to float * for improper inputs the object will raise an exception. """, ) def __init__( self, width: Union[int, float], height: Union[int, float], pixel_aspect: Optional[Union[int, float]] = 1.0, print_resolution: Optional[Union[int, float]] = 300, **kwargs, ) -> None: super(ImageFormat, self).__init__(**kwargs) self.width = width self.height = height self.pixel_aspect = pixel_aspect self.print_resolution = print_resolution # self._device_aspect = 1.0 @validates("width") def _validate_width(self, key: str, width: Union[int, float]) -> int: """Validate the given width. Args: key (str): The name of the validated column. width (Union[int, float]): The width value to be validated. Raises: TypeError: If the width is not an int or float. ValueError: If the width is 0 or a negative value. Returns: int: The validated width value. """ if not isinstance(width, (int, float)): raise TypeError( f"{self.__class__.__name__}.width should be an instance of int or " f"float, not {width.__class__.__name__}: '{width}'" ) if width <= 0: raise ValueError( f"{self.__class__.__name__}.width cannot be zero or negative" ) return int(width) @validates("height") def _validate_height(self, key: str, height: Union[int, float]) -> int: """Validate the given height. Args: key (str): The name of the validated column. height (Union[int, float]): The height value to be validated. Raises: TypeError: If the height is not an int or float. ValueError: If the height is 0 or a negative value. Returns: int: The validated height value. """ if not isinstance(height, (int, float)): raise TypeError( f"{self.__class__.__name__}.height should be an instance of int or " f"float, not {height.__class__.__name__}: '{height}'" ) if height <= 0: raise ValueError( f"{self.__class__.__name__}.height cannot be zero or negative" ) return int(height) @validates("pixel_aspect") def _validate_pixel_aspect( self, key: str, pixel_aspect: Union[int, float] ) -> float: """Validate the given pixel aspect. Args: key (str): The name of the validated column. pixel_aspect (Union[int, float]): The pixel_aspect value to be validated. Raises: TypeError: If the given pixel_aspect value is not an int for float. ValueError: If the pixel_aspect is 0 or a negative value. Returns: float: The validated pixel_aspect value. """ if not isinstance(pixel_aspect, (int, float)): raise TypeError( f"{self.__class__.__name__}.pixel_aspect should be an instance of int " f"or float, not {pixel_aspect.__class__.__name__}: '{pixel_aspect}'" ) if pixel_aspect <= 0: raise ValueError( f"{self.__class__.__name__}.pixel_aspect cannot be zero or a negative " "value" ) return float(pixel_aspect) @validates("print_resolution") def _validate_print_resolution( self, key: str, print_resolution: Union[int, float] ) -> float: """Validate the print resolution value. Args: key (str): The name of the validated column. print_resolution (Union[int, float]): The print_resolution value to be validated. Raises: TypeError: If the given print_resolution is not an int or float. ValueError: If the print_resolution is 0 or negative value. Returns: float: The validated print_resolution value. """ if not isinstance(print_resolution, (int, float)): raise TypeError( f"{self.__class__.__name__}.print_resolution should be an instance of " "int or float, " f"not {print_resolution.__class__.__name__}: '{print_resolution}'" ) if print_resolution <= 0: raise ValueError( f"{self.__class__.__name__}.print_resolution cannot be zero or negative" ) return float(print_resolution) @property def device_aspect(self) -> float: """Return the device aspect. Because the device_aspect is calculated from the width/height*pixel formula, this property is read-only. Returns: float: The device aspect ratio. """ return float(self.width) / float(self.height) * self.pixel_aspect def __eq__(self, other: Any) -> bool: """Check if other is equal to this ImageFormat. Args: other (Any): The object to check the equality of. Returns: bool: True if the other is an ImageFormat instance and the width, height and pixel_aspect values are all equal. """ return ( super(ImageFormat, self).__eq__(other) and isinstance(other, ImageFormat) and self.width == other.width and self.height == other.height and self.pixel_aspect == other.pixel_aspect ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(ImageFormat, self).__hash__() ================================================ FILE: src/stalker/models/message.py ================================================ # -*- coding: utf-8 -*- """The Message related classes and functions are situated here.""" from typing import Any, Dict from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import StatusMixin logger = get_logger(__name__) class Message(Entity, StatusMixin): """The base of the messaging system in Stalker. Messages are one of the ways to collaborate in Stalker. The model of the messages is taken from the e-mail system. So it is pretty similar to an e-mail message. Args: from (User): The :class:`.User` object sending the message. to (User): The list of :class:`.User` s to receive this message. subject (str): The subject of the message. body (str): tThe body of the message. in_reply_to (Message): The :class:`.Message` object which this message is a reply to. replies (Message): The list of :class:`.Message` objects which are the direct replies of this message. attachments (SimpleEntity): A list of :class:`.SimpleEntity` objects attached to this message (so anything can be attached to a message). """ __auto_name__ = True __tablename__ = "Messages" __mapper_args__ = {"polymorphic_identity": "Message"} message_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) def __init__(self, **kwargs: Dict[str, Any]) -> None: super(Message, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) ================================================ FILE: src/stalker/models/mixins.py ================================================ # -*- coding: utf-8 -*- """Mixins are situated here.""" import datetime from typing import ( Any, Dict, Generator, List, Optional, TYPE_CHECKING, Tuple, Type, Union, ) from typing_extensions import Self import pytz from sqlalchemy import ( Column, Float, ForeignKey, Integer, Interval, String, Table, ) from sqlalchemy.exc import OperationalError, UnboundExecutionError from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import ( Mapped, backref, mapped_column, relationship, synonym, validates, ) from stalker import defaults from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.db.types import GenericDateTime from stalker.log import get_logger from stalker.models.enum import ( ScheduleConstraint, ScheduleConstraintDecorator, ScheduleModel, ScheduleModelDecorator, TimeUnit, TimeUnitDecorator, TraversalDirection, ) from stalker.utils import check_circular_dependency, make_plural, walk_hierarchy if TYPE_CHECKING: # pragma: no cover from stalker.models.auth import Permission from stalker.models.project import Project from stalker.models.status import Status, StatusList from stalker.models.file import File from stalker.models.studio import WorkingHours logger = get_logger(__name__) def create_secondary_table( primary_cls_name: str, secondary_cls_name: str, primary_cls_table_name: str, secondary_cls_table_name: str, secondary_table_name: Optional[str] = None, ) -> Table: """Create any secondary table. Args: primary_cls_name (str): The primary class name. secondary_cls_name (str): The secondary class name. primary_cls_table_name (str): The primary class table name. secondary_cls_table_name (str): The secondary class table name. secondary_table_name (Union[None, str]): Optional secondary table name. Raises: TypeError: If primary_cls_name is not a str. TypeError: If secondary_cls_name is not a str. TypeError: If primary_cls_table_name is not a str. TypeError: If secondary_cls_table_name is not a str. TypeError: If secondary_table_name is not a str. ValueError: If primary_cls_name is an empty str. ValueError: If secondary_cls_name is an empty str. ValueError: If primary_cls_table_name is an empty str. ValueError: If secondary_cls_table_name is an empty str. ValueError: If secondary_table_name is an empty str. Returns: Table: The secondary table. """ # validate data # primary_cls_name if not isinstance(primary_cls_name, str): raise TypeError( "primary_cls_name should be a str containing the primary class name, " f"not {primary_cls_name.__class__.__name__}: '{primary_cls_name}'" ) if primary_cls_name == "": raise ValueError( "primary_cls_name should be a str containing the primary class name, " f"not: '{primary_cls_name}'" ) # secondary_cls_name if not isinstance(secondary_cls_name, str): raise TypeError( "secondary_cls_name should be a str containing the secondary class name, " f"not {secondary_cls_name.__class__.__name__}: '{secondary_cls_name}'" ) if secondary_cls_name == "": raise ValueError( "secondary_cls_name should be a str containing the secondary class name, " f"not: '{secondary_cls_name}'" ) # primary_cls_table_name if not isinstance(primary_cls_table_name, str): raise TypeError( "primary_cls_table_name should be a str containing the primary class " f"table name, not {primary_cls_table_name.__class__.__name__}: " f"'{primary_cls_table_name}'" ) if primary_cls_table_name == "": raise ValueError( "primary_cls_table_name should be a str containing the primary class " f"table name, not: '{primary_cls_table_name}'" ) # secondary_cls_table_name if not isinstance(secondary_cls_table_name, str): raise TypeError( "secondary_cls_table_name should be a str containing the secondary class " f"table name, not {secondary_cls_table_name.__class__.__name__}: " f"'{secondary_cls_table_name}'" ) if secondary_cls_table_name == "": raise ValueError( "secondary_cls_table_name should be a str containing the secondary class " f"table name, not: '{secondary_cls_table_name}'" ) # secondary_table_name if secondary_table_name is not None and not isinstance(secondary_table_name, str): raise TypeError( "secondary_table_name should be a str containing the secondary table " "name, or it can be None or an empty string to let Stalker to auto " f"generate one, not {secondary_table_name.__class__.__name__}: " f"'{secondary_table_name}'" ) plural_secondary_cls_name = make_plural(secondary_cls_name) # use the given class_name and the class_table if not secondary_table_name: secondary_table_name = f"{primary_cls_name}_{plural_secondary_cls_name}" # check if the table is already defined if secondary_table_name not in Base.metadata: secondary_table = Table( secondary_table_name, Base.metadata, Column( f"{primary_cls_name.lower()}_id", Integer, ForeignKey(f"{primary_cls_table_name}.id"), primary_key=True, ), Column( f"{secondary_cls_name.lower()}_id", Integer, ForeignKey(f"{secondary_cls_table_name}.id"), primary_key=True, ), ) else: secondary_table = Base.metadata.tables[secondary_table_name] return secondary_table class TargetEntityTypeMixin(object): """Adds target_entity_type attribute to mixed in class. Args: target_entity_type (Union[str, type]): The target entity type which this class is designed for. Should be a class or a class name. For example:: from stalker import SimpleEntity, TargetEntityTypeMixin, Project class A(SimpleEntity, TargetEntityTypeMixin): __tablename__ = "As" __mapper_args__ = {"polymorphic_identity": "A"} def __init__(self, **kwargs): super(A, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, **kwargs) a_obj = A(target_entity_type=Project) The ``a_obj`` will only be accepted by :class:`.Project` instances. You cannot assign it to any other class which accepts a :class:`.Type` instance. To control the mixed-in class behavior add these class variables to the mixed in class: __nullable_target__ : controls if the target_entity_type can be nullable or not. Default is False. __unique_target__ : controls if the target_entity_type should be unique, so there is only one object for one type. Default is False. """ __nullable_target__ = False __unique_target__ = False @declared_attr def _target_entity_type(cls) -> Mapped[str]: """Create the _target_entity_type attribute as a declared attribute. Returns: Column: The Column related to the _target_entity_type attribute. """ return mapped_column( "target_entity_type", String(128), nullable=cls.__nullable_target__, unique=cls.__unique_target__, ) def __init__(self, target_entity_type: Optional[str] = None, **kwargs) -> None: self._target_entity_type = self._validate_target_entity_type(target_entity_type) def _validate_target_entity_type(self, target_entity_type: Union[str, Type]) -> str: """Validate the given target_entity_type value. Args: target_entity_type (Union[str, type]): The target_entity_type that this entity is valid for. Raises: TypeError: If the given target_entity_type value is None. ValueError: If the given target_entity_type value is an empty str. Returns: str: The validated target_entity_type value. """ # it cannot be None if target_entity_type is None: raise TypeError( f"{self.__class__.__name__}.target_entity_type cannot be None" ) # check if it is a class if isinstance(target_entity_type, type): target_entity_type = target_entity_type.__name__ if target_entity_type == "": raise ValueError( f"{self.__class__.__name__}.target_entity_type cannot be empty" ) return target_entity_type def _target_entity_type_getter(self) -> str: """Return the _target_entity_type attribute value. Returns: str: The _target_entity_type attribute value. """ return self._target_entity_type @declared_attr def target_entity_type(cls) -> Mapped[str]: """Create the target_entity_type attribute as a declared attribute. Returns: SynonymProperty: The target_entity_type property. """ return synonym( "_target_entity_type", descriptor=property( fget=cls._target_entity_type_getter, doc="""The entity type which this object is valid for. Usually it is set to the TargetClass directly. """, ), ) class StatusMixin(object): """Makes the mixed in object statusable. This mixin adds status and status_list attributes to the mixed in class. Any object that needs a status and a corresponding status list can include this mixin. When mixed with a class which don't have an __init__ method, the mixin supplies one, and in this case the parameters below must be defined. Args: status_list (StatusList): this attribute holds a status list object, which shows the possible statuses that this entity could be in. This attribute cannot be empty or None. Giving a StatusList object, the StatusList.target_entity_type should match the current class. .. versionadded:: 0.1.2.a4 The status_list argument now can be skipped or can be None if there is an active database connection and there is a suitable :class:`.StatusList` instance in the database whom :attr:`.StatusList.target_entity_type` attribute is set to the current mixed-in class name. status (Status): It is a :class:`.Status` instance which shows the current status of the statusable object. Integer values are also accepted, which shows the index of the desired status in the ``status_list`` attribute of the current statusable object. If a :class:`.Status` instance is supplied, it should also be present in the ``status_list`` attribute. If set to None then the first :class:`.Status` instance in the ``status_list`` will be used. .. versionadded:: 0.2.0 Status attribute as Status instance: It is now possible to set the status of the instance by a :class:`.Status` instance directly. And the :attr:`.StatusMixin.status` will return a proper :class:`.Status` instance. """ def __init__( self, status: Union[None, "Status"] = None, status_list: Union[None, "StatusList"] = None, **kwargs: Dict[str, Any], ) -> None: self.status_list = status_list self.status = status @declared_attr def status_id(cls) -> Mapped[int]: """Create the status_id attribute as a declared attribute. Returns: Column: The Column related to the status_id attribute. """ return mapped_column( "status_id", ForeignKey("Statuses.id"), nullable=False, # This is set to nullable=True but it is impossible to set the # status to None by using this Declarative approach. # # This is done in that way cause SQLAlchemy was flushing the data # (AutoFlush) preliminarily while checking if the given Status was # in the related StatusList, and it was complaining about the # status cannot be null ) @declared_attr def status(cls) -> Mapped["Status"]: """Create the status attribute as a declared attribute. Returns: relationship: The relationship object related to the status attribute. """ return relationship( "Status", primaryjoin=f"{cls.__name__}.status_id==Status.status_id", doc="""The current status of the object. It is a :class:`.Status` instance which is one of the Statuses stored in the ``status_list`` attribute of this object. """, ) @declared_attr def status_list_id(cls) -> Mapped[int]: """Create the status_list_id attribute as a declared attribute. Returns: Column: The Column related to the status_list_id attribute. """ return mapped_column( "status_list_id", ForeignKey("StatusLists.id"), nullable=False ) @declared_attr def status_list(cls) -> Mapped["StatusList"]: """Create the status_list attribute as a declared attribute. Returns: relationship: The relationship object related to the status_list attribute. """ return relationship( "StatusList", primaryjoin=f"{cls.__name__}.status_list_id==StatusList.status_list_id", ) @validates("status_list") def _validate_status_list( self, key: str, status_list: Union[None, "StatusList"] ) -> "StatusList": """Validate the given status_list value. Args: key (str): The name of the validated column. status_list (Union[None, StatusList]): The status_list value to be validated. Raises: TypeError: If the given status_list value is not a StatusList instance. Returns: StatusList: The validated status_list value. """ from stalker.models.status import StatusList super_names = [mro.__name__ for mro in self.__class__.__mro__] if status_list is None: # check if there is a db setup and try to get the appropriate # StatusList from the database # disable autoflush to prevent premature class initialization with DBSession.no_autoflush: try: # try to get a StatusList with the target_entity_type is # matching the class name status_list = StatusList.query.filter( StatusList.target_entity_type.in_(super_names) ).first() except (UnboundExecutionError, OperationalError): # it is not mapped just skip it pass # if it is still None if status_list is None: # there is no db so raise an error because there is no way # to get an appropriate StatusList raise TypeError( f"{self.__class__.__name__} instances cannot be initialized without a " "stalker.models.status.StatusList instance, please pass a " "suitable StatusList " f"(StatusList.target_entity_type={self.__class__.__name__}) with the " "'status_list' argument" ) else: # it is not an instance of status_list if not isinstance(status_list, StatusList): raise TypeError( f"{self.__class__.__name__}.status_list should be an instance of " "stalker.models.status.StatusList, " f"not {status_list.__class__.__name__}: '{status_list}'" ) # check if the entity_type matches to the # StatusList.target_entity_type if status_list.target_entity_type not in super_names: raise TypeError( "The given StatusLists' target_entity_type is " f"{status_list.target_entity_type}, " "whereas the entity_type of this object is " f"{self.__class__.__name__}" ) return status_list @validates("status") def _validate_status(self, key: str, status: "Status") -> "Status": """Validate the given status value. Args: key (str): The name of the validated column. status (Status): The status value to be validated. Raises: TypeError: If the given status value is not a Status instance or an int. ValueError: If the status is a negative int value or the given int value is equal or bigger than the length of the `StatusList.statuses` or if the given Status instance is not in the `StatusList.statuses` list. Returns: Status: The validated status value. """ from stalker.models.status import Status # it is set to None if status is None: with DBSession.no_autoflush: status = self.status_list.statuses[0] # it is not an instance of status or int if not isinstance(status, (Status, int)): raise TypeError( f"{self.__class__.__name__}.status must be an instance of " "stalker.models.status.Status or an integer showing the index of the " f"Status object in the {self.__class__.__name__}.status_list, " f"not {status.__class__.__name__}: '{status}'" ) if isinstance(status, int): # if it is not in the correct range: if status < 0: raise ValueError( f"{self.__class__.__name__}.status must be a non-negative integer" ) if status >= len(self.status_list.statuses): raise ValueError( f"{self.__class__.__name__}.status cannot be bigger than the " "length of the status_list" ) # get the status instance out of the status_list instance status = self.status_list[status] # check if the given status is in the status_list if status not in self.status_list: raise ValueError( f"The given Status instance for {self.__class__.__name__}.status is " f"not in the {self.__class__.__name__}.status_list, please supply a " "status from that list." ) return status class DateRangeMixin(object): """Adds date range info to the mixed in class. Adds date range information like ``start``, ``end`` and ``duration``. These attributes will be used in TaskJuggler. Because ``effort`` is only meaningful if there are some ``resources`` this attribute has been left special for :class:`.Task` class. The ``length`` has not been implemented because of its rare use. The preceding order for the attributes is as follows:: start > end > duration So if all of the parameters are given only the ``start`` and the ``end`` will be used and the ``duration`` will be calculated accordingly. In any other conditions the missing parameter will be calculated from the following table: +-------+-----+----------+----------------------------------------+ | start | end | duration | DEFAULTS | +=======+=====+==========+========================================+ | | | | start = datetime.datetime.now(pytz.utc)| | | | | | | | | | duration = datetime.timedelta(days=10) | | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | | | duration = datetime.timedelta(days=10) | | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | X | | duration = end - start | +-------+-----+----------+----------------------------------------+ | X | | X | end = start + duration | +-------+-----+----------+----------------------------------------+ | X | X | X | duration = end - start | +-------+-----+----------+----------------------------------------+ | | X | X | start = end - duration | +-------+-----+----------+----------------------------------------+ | | X | | duration = datetime.timedelta(days=10) | | | | | | | | | | start = end - duration | +-------+-----+----------+----------------------------------------+ | | | X | start = datetime.datetime.now(pytz.utc)| | | | | | | | | | end = start + duration | +-------+-----+----------+----------------------------------------+ Only the ``start``, ``end`` will be stored. The ``duration`` attribute is the direct difference of the the ``start`` and ``end`` attributes, so there is no need to store it. But if will be used in calculation of the start and end values. The start and end attributes have a ``computed`` companion. Which are the return values from TaskJuggler. so for ``start`` there is the ``computed_start`` and for ``end`` there is the ``computed_end`` attributes. The date attributes can be managed with timezones. Follow the Python idioms shown in the `documentation of datetime`_ .. _documentation of datetime: https://docs.python.org/library/datetime.html Args: start (datetime.datetime): the start date of the entity, should be a datetime.datetime instance, the start is the pin point for the date calculation. In any condition if the start is available then the value will be preserved. If start passes the end the end is also changed to a date to keep the timedelta between dates. The default value is datetime.datetime.now(pytz.utc) end (datetime.datetime): the end of the entity, should be a datetime.datetime instance, when the start is changed to a date passing the end, then the end is also changed to a later date so the timedelta between the dates is kept. duration (datetime.timedelta): The duration of the entity. It is a :class:`datetime.timedelta` instance. The default value is read from he :class:`.Config` class. See the table above for the initialization rules. """ def __init__( self, start: Optional[datetime.datetime] = None, end: Optional[datetime.datetime] = None, duration: Optional[datetime.timedelta] = None, **kwargs: Dict[str, Any], ) -> None: self._start, self._end, self._duration = self._validate_dates( start, end, duration ) @declared_attr def _end(cls) -> Mapped[Optional[datetime.datetime]]: return mapped_column("end", GenericDateTime) def _end_getter(self) -> datetime.datetime: """Return the date that the entity should be delivered. The end can be set to a datetime.timedelta and in this case it will be calculated as an offset from the start and converted to datetime.datetime again. Setting the start to a date passing the end will also set the end, so the timedelta between them is preserved, default value is 10 days. Returns: datetime.datetime: The end datetime. """ with DBSession.no_autoflush: return self._end def _end_setter(self, end: datetime.datetime) -> None: """Set the end attribute value. Args: end (datetime.datetime): The end datetime value. """ self._start, self._end, self._duration = self._validate_dates( self.start, end, self.duration ) @declared_attr def end(cls) -> Mapped[Optional[datetime.datetime]]: """Create the end attribute as a declared attribute. Returns: SynonymProperty: The end property. """ return synonym("_end", descriptor=property(cls._end_getter, cls._end_setter)) @declared_attr def _start(cls) -> Mapped[Optional[datetime.datetime]]: """Create the start attribute as a declared attribute. Returns: Column: The Column related to the start attribute. """ return mapped_column("start", GenericDateTime) def _start_getter(self) -> datetime.datetime: """Return the date that this entity should start. Also effects the :attr:`.DateRangeMixin.end` attribute value in certain conditions, if the :attr:`.DateRangeMixin.start` is set to a time passing the :attr:`.DateRangeMixin.end` it will also offset the :attr:`.DateRangeMixin.end` to keep the :attr:`.DateRangeMixin.duration` value fixed. :attr:`.DateRangeMixin.start` should be an instance of class:`datetime.datetime` and the default value is :func:`datetime.datetime.now(pytz.utc)`. Returns: datetime.datetime: The start datetime value. """ with DBSession.no_autoflush: return self._start def _start_setter(self, start: datetime.datetime) -> None: """Set the start attribute. Args: start (datetime.datetime): The start date and time. """ self._start, self._end, self._duration = self._validate_dates( start, self.end, self.duration ) @declared_attr def start(cls) -> Mapped[Optional[datetime.datetime]]: """Create the start attribute as a declared attribute. Returns: SynonymProperty: The start property. """ return synonym( "_start", descriptor=property( cls._start_getter, cls._start_setter, ), ) @declared_attr def _duration(cls) -> Mapped[Optional[datetime.timedelta]]: """Create the duration attribute as a declared attribute. Returns: Column: The Column related to the duration attribute. """ return mapped_column("duration", Interval) def _duration_getter(self) -> datetime.timedelta: """Return the duration value. Returns: datetime.timedelta: The duration value. """ with DBSession.no_autoflush: return self._duration def _duration_setter(self, duration: datetime.timedelta) -> None: """Set the duration value. Args: duration (datetime.timedelta): The duration value. """ if duration is not None: if isinstance(duration, datetime.timedelta): # set the end to None # to make it recalculated self._start, self._end, self._duration = self._validate_dates( self.start, None, duration ) else: # use the end self._start, self._end, self._duration = self._validate_dates( self.start, self.end, duration ) else: self._start, self._end, self._duration = self._validate_dates( self.start, self.end, duration ) @declared_attr def duration(self) -> Mapped[Optional[datetime.timedelta]]: """Return the duration attr as a synonym. Returns: SynonymProperty: The duration property. """ return synonym( "_duration", descriptor=property( self._duration_getter, self._duration_setter, doc="""Duration of the entity. It is a datetime.timedelta instance. Showing the difference of the :attr:`.start` and the :attr:`.end`. If edited it changes the :attr:`.end` attribute value.""", ), ) def _validate_dates( self, start: datetime.datetime, end: datetime.datetime, duration: datetime.timedelta, ) -> Tuple[datetime.datetime, datetime.datetime, datetime.timedelta]: # noqa: C901 """Update the date values. Args: start (datetime.datetime): The start datetime value. end (datetime.datetime): The end datetime value. duration (datetime.timedelta): The duration value. Returns: Tuple(datetime.datetime, datetime.datetime, datetime.timedelta): The validated and calculated start, end dates and duration value. """ # logger.debug(f"start : {start}") # logger.debug(f"end : {end}") # logger.debug(f"duration : {duration}") if not isinstance(start, datetime.datetime): start = None if not isinstance(end, datetime.datetime): end = None if not isinstance(duration, datetime.timedelta): duration = None # check start if start is None: # try to calculate the start from end and duration if end is None: # set the defaults start = datetime.datetime.now(pytz.utc) if duration is None: # set the defaults duration = defaults.timing_resolution end = start + duration else: if duration is None: duration = defaults.timing_resolution # try: start = end - duration # except OverflowError: # end is datetime.datetime.min # start = end # check end if end is None: if duration is None: duration = defaults.timing_resolution end = start + duration if end < start: # check duration if duration is None or duration < datetime.timedelta(1): duration = datetime.timedelta(1) # try: end = start + duration # except OverflowError: # start is datetime.datetime.max # end = start # round the dates to the timing_resolution rounded_start = self.round_time(start) rounded_end = self.round_time(end) rounded_duration = rounded_end - rounded_start if rounded_duration < defaults.timing_resolution: rounded_duration = defaults.timing_resolution rounded_end = rounded_start + rounded_duration return rounded_start, rounded_end, rounded_duration @declared_attr def computed_start(cls) -> Mapped[Optional[datetime.datetime]]: """Create the computed_start attribute as a declared attribute. Returns: Column: The Column related to the computed_start attribute. """ return mapped_column("computed_start", GenericDateTime) @declared_attr def computed_end(cls) -> Mapped[Optional[datetime.datetime]]: """Create the computed_end attribute as a declared attribute. Returns: Column: The Column related to the computed_end attribute. """ return mapped_column("computed_end", GenericDateTime) @property def computed_duration(self) -> datetime.timedelta: """Calculate the computed duration. The computed_duration is calculated as the difference of computed_start and computed_end if there are computed_start and computed_end otherwise returns None. Returns: Union[None, datetime.timedelta]: None if one of computed_start or computed_end value is None else the difference as datetime.timedelta instance. """ return ( self.computed_end - self.computed_start if self.computed_end and self.computed_start else None ) @classmethod def round_time(cls, dt: datetime.datetime) -> datetime.datetime: """Round the given datetime object to the defaults.timing_resolution. Use the :class:`stalker.defaults.timing_resolution` as the closest number of seconds to round to. Based on Thierry Husson's answer in `Stackoverflow`_ _`Stackoverflow` : https://stackoverflow.com/a/10854034/1431079 Args: dt (datetime.datetime): The datetime object, defaults to now. Returns: datetime.datetime: The rounded datetime.datetime instance. """ # to be compatible with python 2.6 use the following instead of # total_seconds() timing_resolution = defaults.timing_resolution trs = timing_resolution.days * 86400 + timing_resolution.seconds # convert to seconds epoch = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) diff = dt - epoch diff_in_seconds = diff.days * 86400 + diff.seconds return epoch + datetime.timedelta( seconds=(diff_in_seconds + trs * 0.5) // trs * trs ) @property def total_seconds(self) -> float: """Return the duration as seconds. Returns: float: The calculated total seconds value. """ return self.duration.days * 86400 + self.duration.seconds @property def computed_total_seconds(self) -> float: """Return the computed_total_seconds as seconds. Returns: float: The computed_total_seconds value. """ return self.computed_duration.days * 86400 + self.computed_duration.seconds class ProjectMixin(object): """Allows connecting a :class:`.Project` to the mixed in object. This also forces a ``all, delete-orphan`` cascade, so when a :class:``.Project`` instance is deleted then all the class instances that are inherited from ``ProjectMixin`` will also be deleted. Meaning that, a class which also derives from ``ProjectMixin`` will not be able to exists without a project (``delete-orphan`` case). Args: project (Project): A :class:`.Project` instance holding the project which this object is related to. It cannot be None, or anything other than a :class:`.Project` instance. """ # # add this lines for Sphinx # __tablename__ = "ProjectMixins" @declared_attr def project_id(cls) -> Mapped[Optional[int]]: """Create the project_id attribute as a declared attribute. Returns: Column: The Column related to the project_id attribute. """ return mapped_column( "project_id", Integer, ForeignKey("Projects.id"), # cannot use nullable cause a Project object needs # insert itself as the project and it needs post_update # thus nullable should be True ) @declared_attr def project(cls) -> Mapped[Optional["Project"]]: """Create the project attribute as a declared attribute. Returns: relationship: The relationship object related to the project attribute. """ backref_table_name = cls.__tablename__.lower() doc = """The :class:`.Project` instance that this object belongs to.""" return relationship( "Project", primaryjoin=f"{cls.__tablename__}.c.project_id==Projects.c.id", post_update=True, # for project itself uselist=False, backref=backref(backref_table_name, cascade="all, delete-orphan"), doc=doc, ) def __init__( self, project: Optional["Project"] = None, **kwargs: Dict[str, Any] ) -> None: self.project = project @validates("project") def _validate_project(self, key: str, project: "Project") -> "Project": """Validate the given project value. Args: key (str): The name of the validated column. project (Project): The project value to be validated. Raises: TypeError: If the project is None or not a Project instance. Returns: Project: The validated project value. """ from stalker.models.project import Project if project is None: raise TypeError( f"{self.__class__.__name__}.project cannot be None it must be an " "instance of stalker.models.project.Project" ) if not isinstance(project, Project): raise TypeError( f"{self.__class__.__name__}.project should be an instance of " "stalker.models.project.Project instance, " f"not {project.__class__.__name__}: '{project}'" ) return project class ReferenceMixin(object): """Adds reference capabilities to the mixed in class. References are :class:`stalker.models.file.File` instances or anything derived from it, which adds information to the attached objects. The aim of the References are generally to give more info to direct the evolution of the object. Args: references (File): A list of :class:`.File` instances. """ # add this lines for Sphinx # __tablename__ = "ReferenceMixins" def __init__( self, references: Optional[List["File"]] = None, **kwargs: Dict[str, Any] ) -> None: if references is None: references = [] self.references = references @declared_attr def references(cls) -> Mapped[Optional[List["File"]]]: """Create the references attribute as a declared attribute. Returns: relationship: The relationship object related to the references attribute. """ primary_cls_name = f"{cls.__name__}" secondary_cls_name = "Reference" primary_cls_table_name = f"{cls.__tablename__}" secondary_cls_table_name = "Files" secondary_table_name = f"{cls.__name__}_References" # get secondary table secondary_table = create_secondary_table( primary_cls_name=primary_cls_name, secondary_cls_name=secondary_cls_name, primary_cls_table_name=primary_cls_table_name, secondary_cls_table_name=secondary_cls_table_name, secondary_table_name=secondary_table_name, ) # return the relationship return relationship( secondary=secondary_table, primaryjoin=f"{primary_cls_table_name}.c.id=={secondary_table_name}.c.{primary_cls_name.lower()}_id", secondaryjoin=f"{secondary_table_name}.c.{secondary_cls_name.lower()}_id=={secondary_cls_table_name}.c.id", doc="""A list of :class:`.File` instances given as a reference for this entity. """, ) @validates("references") def _validate_references(self, key: str, reference: "File") -> "File": """Validate the given reference. Args: key (str): The name of the validated column. reference (File): The reference value to be validated. Raises: TypeError: If the reference is not a File instance. Returns: File: The validated reference value. """ from stalker.models.file import File # all items should be instance of stalker.models.entity.Entity if not isinstance(reference, File): raise TypeError( f"{self.__class__.__name__}.references should only contain " "instances of stalker.models.file.File, " f"not {reference.__class__.__name__}: '{reference}'" ) return reference class ACLMixin(object): """A Mixin for adding ACLs to mixed in class. Access control lists or ACLs are used to determine if the given resource has the permission to access the given data. It is based on Pyramids Authorization system but organized to fit in Stalker style. The ACLMixin adds an attribute called ``permissions`` and a property called ``__acl__`` to be able to pass the permission data to Pyramid framework. """ @declared_attr def permissions(cls) -> Mapped[List["Permission"]]: """Create the permissions attribute as a declared attribute. Returns: relationship: The relationship object related to the permissions attribute. """ # get the secondary table secondary_table = create_secondary_table( cls.__name__, "Permission", cls.__tablename__, "Permissions" ) return relationship("Permission", secondary=secondary_table) @validates("permissions") def _validate_permissions(self, key: str, permission: "Permission") -> "Permission": """Validate the given permission value. Args: key (str): The name of the validated column. permission (Permission): The permission value to be validated. Raises: TypeError: If the given permission value is not a Permission instance. Returns: Permission: The validated permission value. """ from stalker.models.auth import Permission if not isinstance(permission, Permission): raise TypeError( f"{self.__class__.__name__}.permissions should be all instances of " "stalker.models.auth.Permission, " f"not {permission.__class__.__name__}: '{permission}'" ) return permission @property def __acl__(self) -> List[Tuple[str, str, str]]: """Return Pyramid friendly ACL list. The ACL list is composed by the: * Permission.access (Ex: 'Allow' or 'Deny') * The Mixed in class name and the object name (Ex: 'User:eoyilmaz') * The Action and the target class name (Ex: 'Create_Asset') Thus, a list of tuple is returned as follows:: __acl__ = [ ('Allow', 'User:eoyilmaz', 'Create_Asset'), ] For the last example user eoyilmaz can grant access to views requiring 'Add_Project' permission. Returns: List[Tuple[str, str, str]]: A list of tuples containing the ACL . """ return [ ( perm.access, f"{self.__class__.__name__}:{self.name}", f"{perm.action}_{perm.class_name}", ) for perm in self.permissions ] class CodeMixin(object): """Adds code info to the mixed in class. .. versionadded:: 0.2.0 The code attribute of the SimpleEntity is now introduced as a separate mixin. To let it be used by the classes it is really needed. The CodeMixin just adds a new field called ``code``. It is a very simple attribute and is used for simplifying long names (like Project.name etc.). Contrary to previous implementations the code attribute is not formatted in any way, so care needs to be taken if the code attribute is going to be used in filesystem as file and directory names. Args: code (str): The code attribute is a string, cannot be empty or cannot be None. """ def __init__( self, code: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: logger.debug(f"code: {code}") self.code = code @declared_attr def code(cls) -> Mapped[str]: """Create the code attribute as a declared attribute. Returns: Column: The Column related to the code attribute. """ return mapped_column( "code", String(256), nullable=False, doc="""The code name of this object. It accepts strings. Cannot be None.""", ) @validates("code") def _validate_code(self, key: str, code: str) -> str: """Validate the given code attribute. Args: key (str): The name of the validated column. code (str): The code value to be validated. Raises: TypeError: If the given code is not a str. ValueError: If the given code value is an empty str. Returns: str: The validated code value. """ logger.debug(f"validating code value of: {code}") if code is None: raise TypeError(f"{self.__class__.__name__}.code cannot be None") if not isinstance(code, str): raise TypeError( f"{self.__class__.__name__}.code should be a string, " f"not {code.__class__.__name__}: '{code}'" ) if code == "": raise ValueError( f"{self.__class__.__name__}.code cannot be an empty string" ) return code class WorkingHoursMixin(object): """Set working hours for the mixed in class. Generally is meaningful for users, departments and studio. Args: working_hours (WorkingHours): A :class:`.WorkingHours` instance showing the working hours settings. """ def __init__( self, working_hours: Optional["WorkingHours"] = None, **kwargs: Dict[str, Any] ) -> None: self.working_hours = working_hours @declared_attr def working_hours_id(cls) -> Mapped[Optional[int]]: """Create the working_hours_id attribute as a declared attribute. Returns: Column: The Column related to the working_hours_id attribute. """ return mapped_column("working_hours_id", Integer, ForeignKey("WorkingHours.id")) @declared_attr def working_hours(cls) -> Mapped[Optional["WorkingHours"]]: """Create the working_hours attribute as a declared attribute. Returns: relationship: The relationship object related to the working_hours attribute. """ return relationship( "WorkingHours", primaryjoin=f"{cls.__name__}.working_hours_id==WorkingHours.working_hours_id", ) @validates("working_hours") def _validate_working_hours( self, key, wh: Union[None, "WorkingHours"] ) -> "WorkingHours": """Validate the given working hours value. Args: key (str): The name of the validated column. wh (WorkingHours): The working hours value to be validated. Raises: TypeError: If the working hours is not None and not a WorkingHours instance. Returns: WorkingHours: The validated WorkingHours value. """ from stalker.models.studio import WorkingHours if wh is None: wh = WorkingHours() # without any argument this will use the # default.working_hours settings elif not isinstance(wh, WorkingHours): raise TypeError( f"{self.__class__.__name__}.working_hours should be a " "stalker.models.studio.WorkingHours instance, " f"not {wh.__class__.__name__}: '{wh}'" ) return wh class ScheduleMixin(object): """Add schedule info to the mixed in class. Add attributes like schedule_timing, schedule_unit and schedule_model attributes to the mixed in class. Use the ``__default_schedule_attr_name__`` attribute to customize the column names. """ # some default values that can be overridden in Mixed in classes __default_schedule_attr_name__ = "schedule" __default_schedule_timing__ = defaults.timing_resolution.seconds / 60 __default_schedule_unit__ = TimeUnit.Hour __default_schedule_model__ = ScheduleModel.Effort def __init__( self, schedule_timing: Optional[float] = None, schedule_unit: TimeUnit = TimeUnit.Hour, schedule_model: Optional[ScheduleModel] = ScheduleModel.Effort, schedule_constraint: ScheduleConstraint = ScheduleConstraint.NONE, **kwargs: Dict[str, Any], ) -> None: self.schedule_constraint = schedule_constraint self.schedule_model = schedule_model self.schedule_timing = schedule_timing self.schedule_unit = schedule_unit @declared_attr def schedule_timing(cls) -> Mapped[Optional[float]]: """Create the schedule_timing attribute as a declared attribute. Returns: Column: The Column related to the schedule_timing attribute. """ return mapped_column( f"{cls.__default_schedule_attr_name__}_timing", Float, nullable=True, default=0, doc="""It is the value of the {attr} timing. It is a float value. The timing value can either be as Work Time or Calendar Time defined by the {attr}_model attribute. So when the {attr}_model is `duration` then the value of this attribute is in Calendar Time, and if the {attr}_model is either `length` or `effort` then the value is considered as Work Time. """.format( attr=cls.__default_schedule_attr_name__ ), ) @declared_attr def schedule_unit(cls) -> Mapped[Optional[TimeUnit]]: """Create the schedule_unit attribute as a declared attribute. Returns: Column: The Column related to the schedule_unit attribute. """ return mapped_column( f"{cls.__default_schedule_attr_name__}_unit", TimeUnitDecorator(), nullable=True, default=TimeUnit.Hour, doc=f"It is the unit of the {cls.__default_schedule_attr_name__} " "timing. It is a TimeUnit enum value.", ) @declared_attr def schedule_model(cls) -> Mapped[ScheduleModel]: """Create the schedule_model attribute as a declared attribute. Returns: Column: The Column related to the schedule_model attribute. """ return mapped_column( f"{cls.__default_schedule_attr_name__}_model", ScheduleModelDecorator(), default=ScheduleModel.Effort, nullable=False, doc="""Defines the schedule model which is used by **TaskJuggler** while scheduling this Projects. It is handled as a ScheduleModel enum value which has three possible values; **effort**, **duration**, **length**. :attr:`.ScheduleModel.Effort` is the default value. Each value causes this task to be scheduled in different ways: ======== ========================================================== effort If the :attr:`.schedule_model` attribute is set to **"effort"** then the start and end date values are calculated so that a resource should spent this much of work time to complete a Task. For example, a task with :attr:`.schedule_timing` of 4 days, needs 4 working days. So it can take 4 working days to complete the Task, but it doesn't mean that the task duration will be 4 days. If the resource works overtime then the task will be finished before 4 days or if the resource will not be available (due to a vacation or task coinciding to a weekend day) then the task duration can be much more bigger than required effort. duration The duration of the task will exactly be equal to :attr:`.schedule_timing` regardless of the resource availability. So the difference between :attr:`.start` and :attr:`.end` attribute values are equal to :attr:`.schedule_timing`. Essentially making the task duration in calendar days instead of working days. length In this model the duration of the task will exactly be equal to the given length value in working days regardless of the resource availability. So a task with the :attr:`.schedule_timing` is set to 4 days will be completed in 4 working days. But again it will not be always 4 calendar days due to the weekends or non working days. ======== ========================================================== """, ) @declared_attr def schedule_constraint(cls) -> Mapped[ScheduleConstraint]: """Create the schedule_constraint attribute as a declared attribute. Returns: Column: The Column related to the schedule_constraint attribute. """ return mapped_column( f"{cls.__default_schedule_attr_name__}_constraint", ScheduleConstraintDecorator(), default=0, nullable=False, doc="""A ScheduleConstraint value showing the constraint schema for this task. Possible values are: ===== =============== 0 Constrain None 1 Constrain Start 2 Constrain End 3 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 :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 :att:`.ScheduleConstraint.Both`. """, ) @validates("schedule_constraint") def _validate_schedule_constraint( self, key: str, schedule_constraint: Union[None, int, str], ) -> ScheduleConstraint: """Validate the given schedule_constraint value. Args: key (str): The name of the validated column. schedule_constraint (Union[None, int, str]): The value to be validated. Returns: ScheduleConstraint: The validated schedule_constraint value. """ if schedule_constraint is None: schedule_constraint = ScheduleConstraint.NONE schedule_constraint = ScheduleConstraint.to_constraint(schedule_constraint) return schedule_constraint @validates("schedule_model") def _validate_schedule_model( self, key: str, schedule_model: Union[None, str, ScheduleModel] ) -> ScheduleModel: """Validate the given schedule_model value. Args: key (str): The name of the validated column. schedule_model (Union[None, str]): The schedule_model value to be validated. Returns: ScheduleModel: The validated schedule_model value. """ if schedule_model is None: schedule_model = self.__default_schedule_model__ else: schedule_model = ScheduleModel.to_model(schedule_model) return schedule_model @validates("schedule_unit") def _validate_schedule_unit( self, key: str, schedule_unit: Union[None, str, TimeUnit] ) -> TimeUnit: """Validate the given schedule_unit. Args: key (str): The name of the validated column. schedule_unit (Union[None, str, TimeUnit]): The schedule_unit value to be validated. Returns: TimeUnit: The validated schedule_unit value. """ if schedule_unit is None: schedule_unit = self.__default_schedule_unit__ schedule_unit = TimeUnit.to_unit(schedule_unit) return schedule_unit @validates("schedule_timing") def _validate_schedule_timing( self, key: str, schedule_timing: Union[None, int, float], ) -> float: """Validate the given schedule_timing. Args: key (str): The name of the validated column. schedule_timing (Union[None, int, float]): The schedule_timing value to be validated. Raises: TypeError: If the given schedule_timing is not an int or float. Returns: float: The validated schedule_timing value. """ if schedule_timing is None: schedule_timing = self.__default_schedule_timing__ self.schedule_unit = self.__default_schedule_unit__ if not isinstance(schedule_timing, (int, float)): raise TypeError( "{cls}.{attr}_timing should be an integer or float " "number showing the value of the {attr} timing of this " "{cls}, not {timing_class}: '{timing}'".format( cls=self.__class__.__name__, attr=self.__default_schedule_attr_name__, timing_class=schedule_timing.__class__.__name__, timing=schedule_timing, ) ) return schedule_timing @classmethod def least_meaningful_time_unit( cls, seconds: int, as_work_time: bool = True ) -> Tuple[int, TimeUnit]: """Return the least meaningful time unit that corresponds to the given seconds. So if: as_work_time == True seconds % (1 year work time as seconds) == 0 --> 'y' else: seconds % (1 month work time as seconds) == 0 --> 'm' else: seconds % (1 week work time as seconds) == 0 --> 'w' else: seconds % (1 day work time as seconds) == 0 --> 'd' else: seconds % (1 hour work time as seconds) == 0 --> 'h' else: seconds % (1 minute work time as seconds) == 0 --> 'min' else: raise RuntimeError as_work_time == False seconds % (1 years as seconds) == 0 --> 'y' else: seconds % (1 month as seconds) == 0 --> 'm' else: seconds % (1 week as seconds) == 0 --> 'w' else: seconds % (1 day as seconds) == 0 --> 'd' else: seconds % (1 hour as seconds) == 0 --> 'h' else: seconds % (1 minutes as seconds) == 0 --> 'min' else: raise RuntimeError Args: seconds (int): An integer showing the total seconds to be converted. as_work_time (bool): Should the input be considered as work time or calendar time. Returns: int, TimeUnit: Returns one integer and a TimeUnit enum value, showing the timing value and the unit. """ minutes = 60 hour = 3600 day = 86400 week = 604800 month = 2419200 year = 31536000 day_wt = defaults.daily_working_hours * 3600 week_wt = defaults.weekly_working_days * day_wt month_wt = 4 * week_wt year_wt = int(defaults.yearly_working_days) * day_wt if as_work_time: logger.debug("calculating in work time") if seconds % year_wt == 0: # noqa: S001 return seconds // year_wt, TimeUnit.Year elif seconds % month_wt == 0: # noqa: S001 return seconds // month_wt, TimeUnit.Month elif seconds % week_wt == 0: # noqa: S001 return seconds // week_wt, TimeUnit.Week elif seconds % day_wt == 0: # noqa: S001 return seconds // day_wt, TimeUnit.Day else: logger.debug("calculating in calendar time") # noqa: S001 if seconds % year == 0: # noqa: S001 return seconds // year, TimeUnit.Year elif seconds % month == 0: # noqa: S001 return seconds // month, TimeUnit.Month elif seconds % week == 0: # noqa: S001 return seconds // week, TimeUnit.Week elif seconds % day == 0: # noqa: S001 return seconds // day, TimeUnit.Day # in either case if seconds % hour == 0: # noqa: S001 return seconds // hour, TimeUnit.Hour # at this point we understand that it has a residual of less then one # minute so return in minutes return seconds // minutes, TimeUnit.Minute @classmethod def to_seconds( cls, timing: float, unit: Union[None, str, TimeUnit], model: Union[str, ScheduleModel], ) -> Union[None, float]: """Convert the schedule values to seconds. Depending on to the schedule_model the value will differ. So if the schedule_model is 'effort' or 'length' then the schedule_time and schedule_unit values are interpreted as work time, if the schedule_model is 'duration' then the schedule_time and schedule_unit values are considered as calendar time. Args: timing (float): The timing value. unit (Union[None, str, TimeUnit]): The unit value, a TimeUnit enum value or one of ['min', 'h', 'd', 'w', 'm', 'y', 'Minute', 'Hour', 'Day', 'Week', 'Month', 'Year']. model (str): The schedule model, preferably a ScheduleModel enum value or one of 'effort', 'length' or 'duration'. Returns: Union[None, float]: The converted seconds value. """ if not unit: return None unit = TimeUnit.to_unit(unit) lut = { TimeUnit.Minute: 60, TimeUnit.Hour: 3600, TimeUnit.Day: 86400, TimeUnit.Week: 604800, TimeUnit.Month: 2419200, TimeUnit.Year: 31536000, } if model in [ScheduleModel.Effort, ScheduleModel.Length]: day_wt = defaults.daily_working_hours * 3600 week_wt = defaults.weekly_working_days * day_wt month_wt = 4 * week_wt year_wt = int(defaults.yearly_working_days) * day_wt lut = { TimeUnit.Minute: 60, TimeUnit.Hour: 3600, TimeUnit.Day: day_wt, TimeUnit.Week: week_wt, TimeUnit.Month: month_wt, TimeUnit.Year: year_wt, } return timing * lut[unit] @classmethod def to_unit( cls, seconds: int, unit: Union[None, str, TimeUnit], model: Union[str, ScheduleModel], ) -> float: """Convert the ``seconds`` value to the given ``unit``. Depending on to the ``schedule_model`` the value will differ. So if the ``schedule_model`` is 'effort' or 'length' then the ``seconds`` and ``schedule_unit`` values are interpreted as work time, if the ``schedule_model`` is :attr:`ScheduleModel.Duration` then the ``seconds`` and ``schedule_unit`` values are considered as calendar time. Args: seconds (int): The seconds to convert. unit (Union[None, str, TimeUnit]): The unit value, a TimeUnit enum value one of ['min', 'h', 'd', 'w', 'm', 'y', 'Minute', 'Hour', 'Day', 'Week', 'Month', 'Year'] or a TimeUnit enum value. model (Union[str, ScheduleModel]): The schedule model, either a ScheduleModel enum value or one of 'effort', 'length' or 'duration'. Returns: float: The seconds converted to the given unit considering the given model. """ if unit is None: return None unit = TimeUnit.to_unit(unit) model = ScheduleModel.to_model(model) lut = { TimeUnit.Minute: 60, TimeUnit.Hour: 3600, TimeUnit.Day: 86400, TimeUnit.Week: 604800, TimeUnit.Month: 2419200, TimeUnit.Year: 31536000, } if model in [ScheduleModel.Effort, ScheduleModel.Length]: day_wt = defaults.daily_working_hours * 3600 week_wt = defaults.weekly_working_days * day_wt month_wt = 4 * week_wt year_wt = int(defaults.yearly_working_days) * day_wt lut = { TimeUnit.Minute: 60, TimeUnit.Hour: 3600, TimeUnit.Day: day_wt, TimeUnit.Week: week_wt, TimeUnit.Month: month_wt, TimeUnit.Year: year_wt, } return seconds / lut[unit] @property def schedule_seconds(self) -> float: """Return the schedule values as seconds. Depending on to the schedule_model the value will differ. So if the schedule_model is 'effort' or 'length' then the schedule_time and schedule_unit values are interpreted as work time, if the schedule_model is 'duration' then the schedule_time and schedule_unit values are considered as calendar time. Returns: float: The schedule_seconds as seconds. """ return self.to_seconds( self.schedule_timing, self.schedule_unit, self.schedule_model ) class DAGMixin(object): """DAG mixin adds attributes required for parent/child relationship. Create a parent/child or a directed acyclic graph (DAG) relation on the mixed in class by introducing two new attributes called parent and children. Please set the ``__id_column__`` attribute to the id column of the mixed in class to be able to use this mixin:: .. code-block: python class MixedInClass(SomeBaseClass, DAGMixin): id : Mapped[int] = mapped_column('id', primary_key=True) __id_column__ = id Use the :attr:``.__dag_cascade__`` to control the cascade behavior. """ __dag_cascade__ = "all, delete" @declared_attr def parent_id(cls) -> Mapped[Optional[int]]: """Create the parent_id attribute as a declared attribute. Returns: Column: The Column related to the parent_id attribute. """ return mapped_column( "parent_id", Integer, ForeignKey(f"{cls.__tablename__}.id") ) @declared_attr def parent(cls) -> Mapped[Self]: """Create the parent attribute as a declared attribute. Returns: relationship: The relationship object related to the parent attribute. """ return relationship( cls.__name__, remote_side=[getattr(cls, cls.__id_column__)], primaryjoin="{ct}.c.parent_id=={ct}.c.id".format(ct=cls.__tablename__), back_populates="children", post_update=True, doc="""A :class:`{c}` instance which is the parent of this {c}. In Stalker it is possible to create a hierarchy of {c}. """.format( c=cls.__name__ ), ) @declared_attr def children(cls) -> Mapped[List[Self]]: """Create the children attribute as a declared attribute. Returns: relationship: The relationship object related to the parent attribute. """ return relationship( cls.__name__, primaryjoin="{ct}.c.id=={ct}.c.parent_id".format(ct=cls.__tablename__), back_populates="parent", post_update=True, cascade=cls.__dag_cascade__, doc="""Other :class:`Budget` instances which are the children of this one. This attribute along with the :attr:`.parent` attribute is used in creating a DAG hierarchy of tasks. """, ) def __init__(self, parent: Optional[Self] = None, **kwargs: Dict[str, Any]) -> None: self.parent = parent @validates("parent") def _validate_parent( self, key: str, parent: Union[None, Self] ) -> Union[None, Self]: """Validate the given parent value. Args: key (str): The name of the validated column. parent (object): The parent object to be validated. Raises: TypeError: If the parent is not None and not deriving from the same class with this instance. Returns: Union[None, Self]: The validated parent value. """ if parent is None: return parent if not isinstance(parent, self.__class__): raise TypeError( "{cls}.parent should be an instance of {cls} class or " "derivative, not {parent_cls}: '{parent}'".format( cls=self.__class__.__name__, parent_cls=parent.__class__.__name__, parent=parent, ) ) check_circular_dependency(self, parent, "children") return parent @validates("children") def _validate_children(self, key: str, child: Self) -> Self: """Validate the given child. Args: key (str): The name of the validated column. child (Self): The child value to be validated. Raises: TypeError: If any of the child objects are not deriving from the same class as this one. Returns: Self: The validated child instance. """ if not isinstance(child, self.__class__): raise TypeError( "{cls}.children should only contain instances of {cls} " "(or derivative), not {child_cls}: '{child}'".format( cls=self.__class__.__name__, child_cls=child.__class__.__name__, child=child, ) ) return child @property def is_root(self) -> bool: """Return True if the Task has no parent. Returns: bool: True if the Task has no parent. """ return not bool(self.parent) @property def is_container(self) -> bool: """Return True if the Task has children Tasks. Returns: bool: True if the Task has children Tasks. """ with DBSession.no_autoflush: return bool(len(self.children)) @property def is_leaf(self) -> bool: """Return True if the Task has no children Tasks. Returns: bool: True if the Task has no children Tasks. """ return not self.is_container @property def parents(self) -> List[Self]: """Return all of the parents of this mixed in class starting from the root. Returns: List[Self]: List of tasks showing the parent of this Task. """ parents = [] entity = self.parent # TODO: make this a generator while entity: parents.append(entity) entity = entity.parent parents.reverse() return parents def walk_hierarchy( self, method: Union[int, str, TraversalDirection] = TraversalDirection.DepthFirst, ) -> Generator[None, Self, None]: """Walk the hierarchy of this task. Args: method (Union[int, str, TraversalDirection]): The walk method defined by the :class:`.TraversalDirection` enum value. The default is :attr:`.TraversalDirection.DepthFirst`. Yields: Task: The child Task. """ for c in walk_hierarchy(self, "children", method=method): yield c class AmountMixin(object): """Adds ``amount`` attribute to the mixed in class. Args: amount (Union[int, float]): The amount value. """ def __init__(self, amount: Union[int, float] = 0, **kwargs: Dict[str, Any]) -> None: self.amount = amount @declared_attr def amount(cls) -> Mapped[Optional[float]]: """Create the amount attribute as a declared attribute. Returns: Column: The Column related to the amount attribute. """ return mapped_column(Float, default=0.0) @validates("amount") def _validate_amount(self, key: str, amount: Union[int, float]) -> float: """Validate the given amount value. Args: key (str): The name of the validated column. amount (Union[int, float]): The amount value to be validated. Raises: TypeError: If the given amount value is not a int or float. Returns: float: The validated amount value. """ if amount is None: amount = 0.0 if not isinstance(amount, (int, float)): raise TypeError( f"{self.__class__.__name__}.amount should be a number, " f"not {amount.__class__.__name__}: '{amount}'" ) return float(amount) class UnitMixin(object): """Adds ``unit`` attribute to the mixed in class. Args: unit (str): The unit of this mixed in class. """ def __init__(self, unit: str = "", **kwargs: Dict[str, Any]) -> None: self.unit = unit @declared_attr def unit(cls) -> Mapped[Optional[str]]: """Create the unit attribute as a declared attribute. Returns: Column: The Column related to the unit attribute. """ return mapped_column(String(64)) @validates("unit") def _validate_unit(self, key: str, unit: Union[None, str]) -> str: """Validate the given unit value. Args: key (str): The name of the validated column. unit (str): The unit value to be validated. Raises: TypeError: If the given unit is not a str. Returns: str: The validated unit value. """ if unit is None: unit = "" if not isinstance(unit, str): raise TypeError( f"{self.__class__.__name__}.unit should be a string, " f"not {unit.__class__.__name__}: '{unit}'" ) return unit ================================================ FILE: src/stalker/models/note.py ================================================ # -*- coding: utf-8 -*- """Note class lies here.""" from typing import Any, Dict, Optional from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, synonym from stalker.log import get_logger from stalker.models.entity import SimpleEntity logger = get_logger(__name__) class Note(SimpleEntity): """Notes for any of the SOM objects. To leave notes in Stalker use the Note class. Args: content (str): The content of the note. attached_to (Entity): The object that this note is attached to. """ __auto_name__ = True __tablename__ = "Notes" __mapper_args__ = {"polymorphic_identity": "Note"} note_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True, ) content: Mapped[Optional[str]] = synonym( "description", doc="""The content of this :class:`.Note` instance. Content is a string representing the content of this Note, can be an empty. """, ) def __init__(self, content: str = "", **kwargs: Dict[str, Any]) -> None: super(Note, self).__init__(**kwargs) self.content = content def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Note instance and has the same content. """ return ( super(Note, self).__eq__(other) and isinstance(other, Note) and self.content == other.content ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Note, self).__hash__() ================================================ FILE: src/stalker/models/project.py ================================================ # -*- coding: utf-8 -*- """Project related classes and functions are situated here.""" from typing import Any, List, Optional, TYPE_CHECKING, Union from sqlalchemy import Float, ForeignKey from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import CodeMixin, DateRangeMixin, ReferenceMixin, StatusMixin from stalker.models.status import Status if TYPE_CHECKING: # pragma: no cover from stalker.models.asset import Asset from stalker.models.auth import Role, User from stalker.models.client import Client from stalker.models.format import ImageFormat from stalker.models.repository import Repository from stalker.models.sequence import Sequence from stalker.models.shot import Shot from stalker.models.structure import Structure from stalker.models.task import Task from stalker.models.ticket import Ticket logger = get_logger(__name__) class ProjectRepository(Base): """The association object for Project to Repository instances.""" __tablename__ = "Project_Repositories" project_id: Mapped[int] = mapped_column( ForeignKey("Projects.id"), primary_key=True, ) project: Mapped["Project"] = relationship( back_populates="repositories_proxy", primaryjoin="Project.project_id==ProjectRepository.project_id", ) repository_id: Mapped[int] = mapped_column( ForeignKey("Repositories.id"), primary_key=True, ) repository: Mapped["Repository"] = relationship( primaryjoin="ProjectRepository.repository_id==Repository.repository_id", ) position: Mapped[Optional[int]] = mapped_column() def __init__( self, project: Optional["Project"] = None, repository: Optional["Repository"] = None, position: Optional[int] = None, ) -> None: self.project = project self.repository = repository self.position = position @validates("project") def _validate_project(self, key: str, project: "Project") -> "Project": """Validate the given project value. Args: key (str): The name of the validated column. project (Project): The project value to be validated. Returns: project: The validated project value. """ # TODO: Why we are not validating the Project here? # Is it already validated somewhere else? return project @validates("repository") def _validate_repository( self, key: str, repository: Union[None, "Repository"], ) -> Union[None, "Repository"]: """Validate the given repository value. Args: key (str): The name of the validated column. repository (Repository): The repository to be validated. Raises: TypeError: If the repository is not a Repository instance. Returns: Repository: The repository value. """ if repository is not None: from stalker.models.repository import Repository if not isinstance(repository, Repository): raise TypeError( f"{self.__class__.__name__}.repositories should be a list of " "stalker.models.repository.Repository instances or " f"derivatives, not {repository.__class__.__name__}: '{repository}'" ) return repository class Project(Entity, ReferenceMixin, StatusMixin, DateRangeMixin, CodeMixin): """All the information about a Project in Stalker is hold in this class. Project is one of the main classes that will direct the others. A project in Stalker is a gathering point. It is mixed with :class:`.ReferenceMixin`, :class:`.StatusMixin`, :class:`.DateRangeMixin` and :class:`.CodeMixin` to give reference, status, schedule and code attribute. Please read the individual documentation of each of the mixins. **Project Users** The :attr:`.Project.users` attribute lists the users in this project. UIs like task creation for example will only list these users as available resources for this project. **TaskJuggler Integration** Stalker uses TaskJuggler for scheduling the project tasks. The :attr:`.Project.to_tjp` attribute generates a tjp compliant string which includes the project definition, the tasks of the project, the resources in the project including the vacation definitions and all the time logs recorded for the project. For custom attributes or directives that needs to be passed to TaskJuggler you can use the :attr:`.Project.custom_tjp` attribute which will be attached to the generated tjp file (inside the "project" directive). To manage all the studio projects at once (schedule them at once please use :class:`.Studio`). **Repositories** .. versionadded:: 0.2.13 Multiple Repositories per Project Starting with v0.2.13 Project instances can have multiple Repositories, which allows the project files to be placed in more than one repository according to the need of the studio pipeline. One great advantage of having multiple repositories is to be able to place Published versions in to another repository which is placed on to a faster server. Also, the :attr:`.repositories` attribute is not a read-only attribute anymore. **Clients** .. versionadded:: 0.2.15 Multiple Clients per Project It is now possible to attach multiple :class:`.Client` instances to one :class:`.Project` allowing to hold complex Projects to Client relations by using the :attr:`.ProjectClient.role` attribute of the :class:`.ProjectClient` class. **Deleting a Project** Deleting a :class:`.Project` instance will cascade the delete operation to all the :class:`.Task` s related to that particular Project and it will cascade the delete operation to :class:`.TimeLog` s, :class:`.Version` s, :class:`.File` s and :class:`.Review` s etc.. So one can delete a :class:`.Project` instance without worrying about the non-project related data like :class:`.User` s or :class:`.Department` s to be deleted. Args: clients (List[Client]): The clients which the project is affiliated with. Default value is an empty list. image_format (ImageFormat): The output image format of the project. Default value is None. fps (float): The FPS of the project, it should be a integer or float number, or a string literal which can be correctly converted to a float. Default value is 25.0. type (Type): The type of the project. Default value is None. structure (Structure): The structure of the project. Default value is None. repositories (List[Repository]): A list of :class:`.Repository` instances that the project files are going to be stored in. You cannot create a project without specifying the repositories argument and passing a :class:`.Repository` to it. Default value is None which raises a TypeError. is_stereoscopic (bool): a bool value, showing if the project is going to be a stereo 3D project, anything given as the argument will be converted to True or False. Default value is False. users (List[User]): A list of :class:`.User` s holding the users in this project. This will create a reduced or grouped list of studio workers and will make it easier to define the resources for a Task related to this project. The default value is an empty list. """ __auto_name__ = False __tablename__ = "Projects" project_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) __mapper_args__ = { "polymorphic_identity": "Project", "inherit_condition": project_id == Entity.entity_id, } clients = association_proxy( "client_role", "client", creator=lambda n: ProjectClient(client=n) ) client_role: Mapped[Optional[List["ProjectClient"]]] = relationship( back_populates="project", cascade="all, delete-orphan", cascade_backrefs=False, primaryjoin="Projects.c.id==Project_Clients.c.project_id", ) tasks: Mapped[Optional[List["Task"]]] = relationship( primaryjoin="Tasks.c.project_id==Projects.c.id", cascade="all, delete-orphan", ) users = association_proxy( "user_role", "user", creator=lambda n: ProjectUser(user=n) ) user_role: Mapped[Optional[List["ProjectUser"]]] = relationship( back_populates="project", cascade="all, delete-orphan", cascade_backrefs=False, primaryjoin="Projects.c.id==Project_Users.c.project_id", ) repositories_proxy: Mapped[Optional[List["ProjectRepository"]]] = relationship( back_populates="project", cascade="all, delete-orphan", cascade_backrefs=False, order_by="ProjectRepository.position", primaryjoin="Projects.c.id==Project_Repositories.c.project_id", collection_class=ordering_list("position"), doc="""The :class:`.Repository` that this project files should reside. Should be a list of :class:`.Repository` instances. """, ) repositories = association_proxy( "repositories_proxy", "repository", creator=lambda n: ProjectRepository(repository=n), ) structure_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Structures.id")) structure: Mapped[Optional["Structure"]] = relationship( primaryjoin="Project.structure_id==Structure.structure_id", doc="""The structure of the project. Should be an instance of :class:`.Structure` class""", ) image_format_id: Mapped[Optional[int]] = mapped_column( ForeignKey("ImageFormats.id") ) image_format: Mapped[Optional["ImageFormat"]] = relationship( primaryjoin="Projects.c.image_format_id==ImageFormats.c.id", doc="""The :class:`.ImageFormat` of this project. This value defines the output image format of the project, should be an instance of :class:`.ImageFormat`. """, ) fps: Mapped[Optional[float]] = mapped_column( Float(precision=3), doc="""The fps of the project. It is a float value, any other types will be converted to float. The default value is 25.0. """, ) is_stereoscopic: Mapped[Optional[bool]] = mapped_column( doc="""True if the project is a stereoscopic project""" ) tickets: Mapped[Optional[List["Ticket"]]] = relationship( primaryjoin="Tickets.c.project_id==Projects.c.id", cascade="all, delete-orphan", ) def __init__( self, name: Optional[str] = None, code: Optional[str] = None, clients: Optional[List["Client"]] = None, repositories: Optional[List["Repository"]] = None, structure: Optional["Structure"] = None, image_format: Optional["ImageFormat"] = None, fps: float = 25.0, is_stereoscopic: bool = False, users: Optional[List["User"]] = None, **kwargs, ) -> None: # a projects project should be self # initialize the project argument to self kwargs["project"] = self kwargs["name"] = name super(Project, self).__init__(**kwargs) # call the mixin __init__ methods ReferenceMixin.__init__(self, **kwargs) StatusMixin.__init__(self, **kwargs) DateRangeMixin.__init__(self, **kwargs) self.code = code if users is None: users = [] self.users = users if repositories is None: repositories = [] self.repositories = repositories self.structure = structure if clients is None: clients = [] self.clients = clients self._sequences = [] self._assets = [] self.image_format = image_format self.fps = fps self.is_stereoscopic = bool(is_stereoscopic) def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Project and equal as an Entity. """ return super(Project, self).__eq__(other) and isinstance(other, Project) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Project, self).__hash__() @validates("fps") def _validate_fps(self, key: str, fps: Union[int, float]) -> float: """Validate the given fps value. Args: key (str): The name of the validated column. fps (Union[int, float]): The fps value to be validated. Raises: TypeError: If the fps value is not an int or float. ValueError: If the fps is 0 or a negative number. Returns: float: The validated fps value. """ if not isinstance(fps, (int, float)): raise TypeError( f"{self.__class__.__name__}.fps should be a positive float or int, " f"not {fps.__class__.__name__}: '{fps}'" ) fps = float(fps) if fps <= 0: raise ValueError( f"{self.__class__.__name__}.fps should be a positive float or int, " f"not {fps}" ) return float(fps) @validates("image_format") def _validate_image_format( self, key: str, image_format: Union[None, "ImageFormat"] ) -> Union[None, "ImageFormat"]: """Validate the given image format. Args: key (str): The name of the validated column. image_format (Union[None, ImageFormat]): The image_format value to be validated. Raises: TypeError: If the given image format is not a ImageFormat instance. Returns: Union[None, ImageFormat]: The validated image_format value. """ from stalker.models.format import ImageFormat if image_format is not None and not isinstance(image_format, ImageFormat): raise TypeError( f"{self.__class__.__name__}.image_format should be an instance of " "stalker.models.format.ImageFormat, " f"not {image_format.__class__.__name__}: '{image_format}'" ) return image_format @validates("structure") def _validate_structure( self, key: str, structure: Union[None, "Structure"], ) -> Union[None, "Structure"]: """Validate the given structure value. Args: key (str): The name of the validated column. structure (Structure): The structure to be validated. Raises: TypeError: If the given structure is not a Structure instance. Returns: Structure: The validated Structure value. """ from stalker.models.structure import Structure if structure is not None and not isinstance(structure, Structure): raise TypeError( "{}.structure should be an instance of " "stalker.models.structure.Structure, not {}: '{}'".format( self.__class__.__name__, structure.__class__.__name__, structure ) ) return structure @validates("is_stereoscopic") def _validate_is_stereoscopic( self, key: str, is_stereoscopic: bool, ) -> bool: """Validate the is_stereoscopic value. Args: key (str): The name of the validated column. is_stereoscopic (bool): The is_stereoscopic value to be validated. Returns: bool: The bool representation of the is_stereoscopic value. """ return bool(is_stereoscopic) @property def root_tasks(self) -> List["Task"]: """Return a list of Tasks which have no parents. Returns: List[Task]: The list of root :class:`Task`s in this project. """ from stalker.models.task import Task from stalker.db.session import DBSession # TODO: add a fallback method with DBSession.no_autoflush: return ( Task.query.filter(Task.project == self) .filter(Task.parent == None) # noqa: E711 .all() ) @property def assets(self) -> List["Asset"]: """Return the assets in this project. Returns: List[Asset]: The list of :class:`Asset`s in this project. """ from stalker.models.asset import Asset from stalker.db.session import DBSession # TODO: add a fallback method with DBSession.no_autoflush: return Asset.query.filter(Asset.project == self).all() @property def sequences(self) -> List["Sequence"]: """Return the sequences in this project. Returns: List[Sequence]: List of :class:`Sequence`s in this project. """ # sequences are tasks, use self.tasks from stalker.models.sequence import Sequence return Sequence.query.filter(Sequence.project == self).all() @property def shots(self) -> List["Shot"]: """Return the shots in this project. Returns: List[Shot]: List of :class:`Shot`s in this project. """ # shots are tasks, use self.tasks from stalker.models.shot import Shot return Shot.query.filter(Shot.project == self).all() @property def to_tjp(self) -> str: """Return the TaskJuggler compatible representation of this project. Returns: str: The TaskJuggler compatible representation of this project. """ tab = " " indent = tab tjp = f'task {self.tjp_id} "{self.tjp_id}" {{' for task in self.root_tasks: tjp += "\n" tjp += "\n".join(f"{indent}{line}" for line in task.to_tjp.split("\n")) tjp += "\n}" return tjp @property def is_active(self) -> bool: """Return True if this project is active, False otherwise. This is a predicate for `Project.active` attribute. Returns: bool: True if the project is active, False otherwise. """ with DBSession.no_autoflush: wip = Status.query.filter_by(code="WIP").first() return self.status == wip @property def total_logged_seconds(self) -> int: """Return the total TimeLog seconds recorded in child tasks. Returns: int: The total amount of logged seconds in the child tasks. """ total_logged_seconds = 0 for task in self.root_tasks: total_logged_seconds += task.total_logged_seconds logger.debug(f"project.total_logged_seconds: {total_logged_seconds}") return total_logged_seconds @property def schedule_seconds(self) -> int: """Return the total amount of schedule timing of the child tasks in seconds. Returns: int: The total amount of schedule timing of the child tasks in seconds. """ schedule_seconds = 0 for task in self.root_tasks: schedule_seconds += task.schedule_seconds logger.debug(f"project.schedule_seconds: {schedule_seconds}") return schedule_seconds @property def percent_complete(self) -> float: """Return the percent_complete value. The percent_complete value is based on the total_logged_seconds and schedule_seconds of the root tasks. Returns: float: The percent_complete value. """ total_logged_seconds = self.total_logged_seconds schedule_seconds = self.schedule_seconds if schedule_seconds > 0: return total_logged_seconds / schedule_seconds * 100 else: return 0 @property def open_tickets(self) -> List["Ticket"]: """Return the list of open :class:`.Ticket` s in this project. Returns: List[Ticket]: A list of :class:`.Ticket` instances which has a status of `Open` and created in this project. """ from stalker import Ticket, Status return ( Ticket.query.join(Status, Ticket.status) .filter(Ticket.project == self) .filter(Status.code != "CLS") .all() ) @property def repository(self) -> "Repository": """Return the first repository in the `project.repositories` or None. Compatibility attribute for pre v0.2.13 systems. Returns: Union[None, Repository]: The Repository instance if there are any or None. """ if self.repositories: return self.repositories[0] else: return None class ProjectUser(Base): """The association object used in User-to-Project relation.""" __tablename__ = "Project_Users" user_id: Mapped[int] = mapped_column( "user_id", ForeignKey("Users.id"), primary_key=True ) user: Mapped["User"] = relationship( back_populates="project_role", cascade_backrefs=False, primaryjoin="ProjectUser.user_id==User.user_id", ) project_id: Mapped[int] = mapped_column(ForeignKey("Projects.id"), primary_key=True) project: Mapped[Project] = relationship( back_populates="user_role", cascade_backrefs=False, primaryjoin="ProjectUser.project_id==Project.project_id", ) role_id: Mapped[Optional[int]] = mapped_column("rid", ForeignKey("Roles.id")) role: Mapped[Optional["Role"]] = relationship( "Role", cascade_backrefs=False, primaryjoin="ProjectUser.role_id==Role.role_id" ) rate: Mapped[Optional[float]] = mapped_column(default=0.0) def __init__( self, project: Optional[Project] = None, user: Optional["User"] = None, role: Optional["Role"] = None, ) -> None: self.user = user self.project = project self.role = role if self.user: # don't need to validate rate # as it is already validated on the User side self.rate = user.rate @validates("user") def _validate_user( self, key: str, user: Union[None, "User"], ) -> Union[None, "User"]: """Validate the given user value. Args: key (str): The name of the validated column. user (User): The user value to be validated. Raises: TypeError: If the given user is not a User instance. Returns: User: The validated user value. """ if user is not None: from stalker.models.auth import User if not isinstance(user, User): raise TypeError( f"{self.__class__.__name__}.user should be a " "stalker.models.auth.User instance, " f"not {user.__class__.__name__}: '{user}'" ) # also update rate attribute from stalker.db.session import DBSession with DBSession.no_autoflush: self.rate = user.rate return user @validates("project") def _validate_project( self, key: str, project: Union[None, Project] ) -> Union[None, Project]: """Validate the given project value. Args: key (str): The name of the validated column. project (Union[None, Project]): The project value to be validated. Raises: TypeError: If the project is not a Project instance. Returns: Union[None, Project]: The validated project value. """ if project is not None: # check if it is instance of Project object if not isinstance(project, Project): raise TypeError( f"{self.__class__.__name__}.project should be a " "stalker.models.project.Project instance, " f"not {project.__class__.__name__}: '{project}'" ) return project @validates("role") def _validate_role(self, key: str, role: Union[None, "Role"]): """Validate the given role instance. Args: key (str): The name of the validated column. role (Union[None, "Role"]): The role value to be validated. Raises: TypeError: If the given role is not a Role instance. Returns: Union[None, "Role"]: The validated role value. """ if role is None: return role from stalker import Role if not isinstance(role, Role): raise TypeError( f"{self.__class__.__name__}.role should be a " "stalker.models.auth.Role instance, " f"not {role.__class__.__name__}: '{role}'" ) return role class ProjectClient(Base): """The association object used in Client-to-Project relation. Args: project (Project): The project. client (Client): The client. role (Role): The client role in this project. """ __tablename__ = "Project_Clients" client_id: Mapped[int] = mapped_column(ForeignKey("Clients.id"), primary_key=True) client: Mapped["Client"] = relationship( back_populates="project_role", cascade_backrefs=False, primaryjoin="Project_Clients.c.client_id==Clients.c.id", ) project_id: Mapped[int] = mapped_column(ForeignKey("Projects.id"), primary_key=True) project: Mapped[Project] = relationship( back_populates="client_role", cascade_backrefs=False, primaryjoin="ProjectClient.project_id==Project.project_id", ) role_id: Mapped[Optional[int]] = mapped_column( "rid", ForeignKey("Roles.id"), nullable=True ) role: Mapped[Optional["Role"]] = relationship( cascade_backrefs=False, primaryjoin="ProjectClient.role_id==Role.role_id", ) def __init__( self, project: Optional[Project] = None, client: Optional["Client"] = None, role: Optional["Role"] = None, ) -> None: self.client = client self.project = project self.role = role @validates("client") def _validate_client( self, key: str, client: Union[None, "Client"], ) -> Union[None, "Client"]: """Validate the given client value. Args: key (str): The name of the validated column. client (Union[None, Client]): The client value to be validated. Raises: TypeError: If the given client arg value is not a Client instance. Returns: Client: The validated client value. """ if client is None: return client from stalker.models.client import Client if not isinstance(client, Client): raise TypeError( f"{self.__class__.__name__}.client should be an instance of " "stalker.models.auth.Client, " f"not {client.__class__.__name__}: '{client}'" ) return client @validates("project") def _validate_project( self, key: str, project: Union[None, Project] ) -> Union[None, Project]: """Validate the given project value. Args: key (str): The name of the validated column. project (Project): The project value to be validated. Raises: TypeError: If the given project value is not a Project instance. Returns: Project: The validated project value. """ if project is None: return project # check if it is instance of Project object if not isinstance(project, Project): raise TypeError( f"{self.__class__.__name__}.project should be a " "stalker.models.project.Project instance, " f"not {project.__class__.__name__}: '{project}'" ) return project @validates("role") def _validate_role( self, key: str, role: Union[None, "Role"], ) -> Union[None, "Role"]: """Validate the given role instance. Args: key (str): The name of the validated column. role (Union[None, Role]): The role value to be validated. Raises: TypeError: If the given role value is not a Role instance. Returns: Union[None, Role]: The validated role value. """ if role is None: return role from stalker import Role if not isinstance(role, Role): raise TypeError( f"{self.__class__.__name__}.role should be a " "stalker.models.auth.Role instance, " f"not {role.__class__.__name__}: '{role}'" ) return role def create_project_client(project: Project) -> ProjectClient: """Create ProjectClient instance on association proxy. Args: project (Project): The :class:`.Project` instance to be used to create the :class:`.ProjectClient` instance. Returns: ProjectClient: The :class:`.ProjectClient` instance. """ return ProjectClient(project=project) ================================================ FILE: src/stalker/models/repository.py ================================================ # -*- coding: utf-8 -*- """Repository related functionality is situated here.""" import os import platform from typing import Any, Dict, Optional, TYPE_CHECKING from sqlalchemy import ForeignKey, String, event from sqlalchemy.orm import Mapped, mapped_column, validates from stalker import defaults from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import CodeMixin if TYPE_CHECKING: # pragma: no cover from sqlalchemy.orm import Mapper from sqlalchemy.engine import Connection logger = get_logger(__name__) class Repository(Entity, CodeMixin): r"""Manage fileserver/repository related data. A repository is a network share that all users have access to. A studio can create several repositories, for example, one for movie projects and one for commercial projects. A repository also defines the default paths for linux, windows and mac foreshores. The path separator in the repository is always forward slashes ("/"). Setting a path that contains backward slashes ("\"), will be converted to a path with forward slashes. .. versionadded:: 0.2.24 Code attribute Starting with v0.2.24 Repository instances have a new :attr:`.code` attribute whose value is used by the :class:`stalker.models.studio.Studio` to generate environment variables that contains the path of this :class:`stalker.models.repository.Repository` (i.e. $REPOCP/path/to/asset.ma ``CP`` here is the ``Repository.code``) so that instead of using absolute full paths one can use the :attr:`.make_relative`` path to generate a universal path that can be used across OSes and different installations of Stalker. Args: code (str): The code of the :class:`stalker.models.repository.Repository`. This attribute value is used by the :class:`stalker.models.studio.Studio` to generate environment variables that contains the path of this ``Repository`` (i.e. $REPOCP/path/to/asset.ma) so that instead of using absolute full paths one can use the ``repository_relative`` path to generate a universal path that can be used across OSes and different installations of Stalker. linux_path (str): shows the linux path of the repository root, should be a string macos_path (str): shows the macOS path of the repository root, should be a string. windows_path (str): shows the windows path of the repository root, should be a string """ # # TODO: Add OpenLDAP support. # # In an OpenLDAP Server + AutoFS setup Stalker can create new entries to # OpenLDAP server. # # The AutoFS can be installed to any linux system easily or it is already # installed. macOS has it already. I know nothing about Windows. # # AutoFS can be setup to listen for new mount points from an OpenLDAP # server. Thus it is heavily related with the users system, Stalker # cannot do anything about that. The IT should setup workstations. # # But Stalker can connect to the OpenLDAP server and create new entries. # __auto_name__ = False __tablename__ = "Repositories" __mapper_args__ = {"polymorphic_identity": "Repository"} repository_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) linux_path: Mapped[Optional[str]] = mapped_column(String(256)) windows_path: Mapped[Optional[str]] = mapped_column(String(256)) macos_path: Mapped[Optional[str]] = mapped_column(String(256)) def __init__( self, code: str = "", linux_path: str = "", windows_path: str = "", macos_path: str = "", **kwargs: Dict[str, Any], ) -> None: kwargs["code"] = code super(Repository, self).__init__(**kwargs) CodeMixin.__init__(self, **kwargs) self.linux_path = linux_path self.windows_path = windows_path self.macos_path = macos_path @validates("linux_path") def _validate_linux_path(self, key: str, linux_path: str) -> str: """Validate the given Linux path. Args: key (str): The name of the validated column. linux_path (str): The Linux path to validated. Raises: TypeError: If the given Linux path is not a str. Returns: str: The validated Linux path. """ if not isinstance(linux_path, str): raise TypeError( f"{self.__class__.__name__}.linux_path should be an instance of " f"string, not {linux_path.__class__.__name__}: '{linux_path}'" ) linux_path = os.path.normpath(linux_path) + "/" linux_path = linux_path.replace("\\", "/") if self.code is not None and platform.system() == "Linux": # update the environment variable os.environ[defaults.repo_env_var_template.format(code=self.code)] = ( linux_path ) return linux_path @validates("macos_path") def _validate_macos_path(self, key: str, macos_path: str) -> str: """Validate the given macOS path. Args: key (str): The name of the validated column. macos_path (str): The macOS path to validate. Raises: TypeError: If the given macOS path is not a str. Returns: str: The validated macOS path. """ if not isinstance(macos_path, str): raise TypeError( f"{self.__class__.__name__}.macos_path should be an instance of " f"string, not {macos_path.__class__.__name__}: '{macos_path}'" ) macos_path = os.path.normpath(macos_path) + "/" macos_path = macos_path.replace("\\", "/") if self.code is not None and platform.system() == "Darwin": # update the environment variable rendered_env_var = defaults.repo_env_var_template.format(code=self.code) os.environ[rendered_env_var] = macos_path return macos_path @validates("windows_path") def _validate_windows_path(self, key: str, windows_path: str) -> str: """Validate the given Windows path. Args: key (str): The name of the validated column. windows_path (str): The Windows path to validate. Raises: TypeError: If the given Windows path is not a str. Returns: str: The validated Windows path. """ if not isinstance(windows_path, str): raise TypeError( f"{self.__class__.__name__}.windows_path should be an instance of " f"string, not {windows_path.__class__.__name__}: '{windows_path}'" ) windows_path = os.path.normpath(windows_path) windows_path = windows_path.replace("\\", "/") if not windows_path.endswith("/"): windows_path += "/" if self.code is not None and platform.system() == "Windows": # update the environment variable os.environ[defaults.repo_env_var_template.format(code=self.code)] = ( windows_path ) return windows_path @property def path(self) -> str: """Return the repository path for the current OS. Returns: str: The repository path for the current OS. """ # return the proper value according to the current os platform_system = platform.system() if platform_system == "Linux": return self.linux_path elif platform_system == "Windows": return self.windows_path elif platform_system == "Darwin": return self.macos_path @path.setter def path(self, path: str) -> None: """Set the path for the current OS. Args: path (str): The path. """ # return the proper value according to the current os platform_system = platform.system() if platform_system == "Linux": self.linux_path = path elif platform_system == "Windows": self.windows_path = path elif platform_system == "Darwin": self.macos_path = path def is_in_repo(self, path: str) -> bool: """Return True or False depending on the given is in this repo or not. Args: path: The path to be investigated. Returns: bool: Return True if the given path is in this repository. """ path = path.replace("\\", "/") return ( path.lower().startswith(self.windows_path.lower()) or path.startswith(self.linux_path) or path.startswith(self.macos_path) ) def _to_path(self, path: str, replace_with: str) -> str: """Return the path replacing the OS related part with the given str. Args: path (str): The input path. replace_with (str): replace_with path Raises: TypeError: When the given path is not a str. Returns: str: The converted path. """ if not isinstance(path, str): raise TypeError( "path should be a string containing a file path, " f"not {path.__class__.__name__}: '{path}'" ) if not isinstance(replace_with, str): raise TypeError( "replace_with should be a string containing a file path, " f"not {replace_with.__class__.__name__}: '{replace_with}'" ) # expand all variables path = os.path.normpath(os.path.expandvars(os.path.expanduser(path))).replace( "\\", "/" ) if path.startswith(self.windows_path): return path.replace(self.windows_path, replace_with) elif path.startswith(self.linux_path): return path.replace(self.linux_path, replace_with) elif path.startswith(self.macos_path): return path.replace(self.macos_path, replace_with) return path def to_linux_path(self, path: str) -> str: """Return the Linux version of the given path. Args: path (str): The path that needs to be converted to Linux path. Returns: str: The Linux path. """ return self._to_path(path, self.linux_path) def to_windows_path(self, path: str) -> str: """Return the Windows version of the given path. Args: path (str): The path that needs to be converted to windows path. Returns: str: The Windows path. """ return self._to_path(path, self.windows_path) def to_macos_path(self, path: str) -> str: """Return the macOS version of the given path. Args: path (str): The path that needs to be converted to macOS path. Returns: str: The macOS path. """ return self._to_path(path, self.macos_path) def to_native_path(self, path: str) -> str: """Return the native version of the given path. Args: path (str): The path that needs to be converted to native path. Returns: str: The native path. """ return self._to_path(path, self.path) def make_relative(self, path: str) -> str: """Make the given path relative to the repository root. Args: path (str): The path to be made relative. Returns: str: The relative path. """ path = self.to_native_path(path) return os.path.relpath(path, self.path).replace("\\", "/") @classmethod def find_repo(cls, path: str) -> "Repository": """Return the repository from the given path. Args: path (str): Path in a repository. Returns: Repository: """ logger.debug(f"Looking for a repo for path: {path}") # path could be using environment variables so expand them path = os.path.expandvars(path) logger.debug(f"path after expanding vars : {path}") # first find the repository repos = Repository.query.all() found_repo = None for repo in repos: if ( path.startswith(repo.path) or path.lower().startswith(repo.windows_path.lower()) or path.startswith(repo.linux_path) or path.startswith(repo.macos_path) ): found_repo = repo break if found_repo is None: logger.debug(f"Couldn't find a repo for path: {path}") return found_repo @classmethod def to_os_independent_path(cls, path: str) -> str: """Replace the part of the given path with repository environment var. This makes the given path OS independent. Args: path (str): path to make OS independent. Returns: str: OS independent path. """ # find the related repo repo = cls.find_repo(path) if repo: logger.debug("Found repo for path: {}".format(repo)) return "${}/{}".format(repo.env_var, repo.make_relative(path)) else: logger.debug("Can't find repo for path: {}".format(path)) return path @property def env_var(self) -> str: """Return the env var of this repo. Returns: str: The env_var corresponding to this repo. """ return defaults.repo_env_var_template.format(code=self.code) def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is equal to this one as an Entity, is a Repository instance and has the same linux_path, macos_path, windows_path. """ return ( super(Repository, self).__eq__(other) and isinstance(other, Repository) and self.linux_path == other.linux_path and self.macos_path == other.macos_path and self.windows_path == other.windows_path ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Repository, self).__hash__() @event.listens_for(Repository, "after_insert") def receive_after_insert( mapper: "Mapper", connection: "Connection", repo: "Repository", ) -> None: """Listen for the 'after_insert' event and update environment variables. This is a mapper event to update the environment variables with the newly inserted Repository data. Args: mapper (sqlalchemy.orm.Mapper): The mapper object. connection (sqlalchemy.engine.Connection): The connection object. repo (Repository): The Repository instance that is just inserted to the DB. """ logger.debug("auto creating env var for Repository: {}".format(repo.name)) os.environ[defaults.repo_env_var_template.format(code=repo.code)] = repo.path ================================================ FILE: src/stalker/models/review.py ================================================ # -*- coding: utf-8 -*- """Review related classes and functions are situated here.""" from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union from sqlalchemy import ForeignKey from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym, validates from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.entity import Entity, SimpleEntity from stalker.models.enum import DependencyTarget, TimeUnit, TraversalDirection from stalker.models.file import File from stalker.models.mixins import ( ProjectMixin, ScheduleMixin, StatusMixin, ) from stalker.models.status import Status from stalker.utils import walk_hierarchy if TYPE_CHECKING: # pragma: no cover from stalker.models.auth import User from stalker.models.task import Task from stalker.models.version import Version logger = get_logger(__name__) class Review(SimpleEntity, ScheduleMixin, StatusMixin): """Manages the Task Review Workflow. This class represents a very important part of the review workflow. For more information about the workflow please read the documentation about the `Stalker Task Review Workflow`_. .. _`Stalker Task Review Workflow`: task_review_workflow_top_level According to the workflow, Review instances holds information about what have the responsible of the task requested about the task when the resource requested a review from the responsible. Each Review instance with the same :attr:`.review_number` for a :class:`.Task` represents a set of reviews. .. version-added:: 1.0.0 Review -> Version relation Versions can now be attached to reviews. Review instances, alongside the :class:`.Task` can also optionally hold a :class:`.Version` instance. This allows the information of which :class:`.Version` instance has been reviewed as a part of the review process to be much cleaner, and when the Review history is investigated, it will be much easier to identify which :class:`.Version` the review was about. Args: task (Task): A :class:`.Task` instance that this review is related to. It can be skipped if a :class:`.Version` instance has been given. version (Version): A :class:`.Version` instance that this review instance is related to. The :class:`.Version` and the :class:`.Task` should be related, a ``ValueError`` will be raised if they are not. review_number (int): This number represents the revision set id that this Review instance belongs to. reviewer (User): One of the responsible of the related Task. There will be only one Review instances with the same review_number for every responsible of the same Task. schedule_timing (int): Holds the timing value of this review. It is a float value. Only useful if it is a review which ends up requesting a revision. schedule_unit (Union[str, TimeUnit]): Holds the timing unit of this review. Only useful if it is a review which ends up requesting a revision. schedule_model (str): It holds the schedule model of this review. Only useful if it is a review which ends up requesting a revision. """ __auto_name__ = True __tablename__ = "Reviews" __table_args__ = {"extend_existing": True} __mapper_args__ = {"polymorphic_identity": "Review"} review_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) task_id: Mapped[int] = mapped_column( ForeignKey("Tasks.id"), nullable=False, doc="The id of the related task.", ) task: Mapped["Task"] = relationship( primaryjoin="Reviews.c.task_id==Tasks.c.id", uselist=False, back_populates="reviews", doc="The :class:`.Task` instance that this Review is created for", ) version_id: Mapped[Optional[int]] = mapped_column( "version_id", ForeignKey("Versions.id") ) version: Mapped[Optional["Version"]] = relationship( primaryjoin="Reviews.c.version_id==Versions.c.id", uselist=False, back_populates="reviews", ) reviewer_id: Mapped[int] = mapped_column( ForeignKey("Users.id"), nullable=False, doc="The User which does the review, also on of the responsible of " "the related Task", ) reviewer: Mapped["User"] = relationship( primaryjoin="Reviews.c.reviewer_id==Users.c.id" ) _review_number: Mapped[Optional[int]] = mapped_column("review_number", default=1) def __init__( self, task: Optional["Task"] = None, version: Optional["Version"] = None, reviewer: Optional["User"] = None, description: str = "", **kwargs: Dict[str, Any], ) -> None: kwargs["description"] = description SimpleEntity.__init__(self, **kwargs) ScheduleMixin.__init__(self, **kwargs) StatusMixin.__init__(self, **kwargs) self.task = task self.version = version self.reviewer = reviewer # set the status to NEW with DBSession.no_autoflush: new = Status.query.filter_by(code="NEW").first() self.status = new # set the review_number self._review_number = self.task.review_number + 1 @validates("task") def _validate_task( self, key: str, task: Union[None, "Task"] ) -> Union[None, "Task"]: """Validate the given task value. Args: key (str): The name of the validated column. task (Union[None, Task]): The task value to be validated. Raises: TypeError: If the given task value is not a Task instance. ValueError: If the given task is not a leaf task. Returns: Union[None, Task]: The validated Task instance. """ if task is None: return task from stalker.models.task import Task if not isinstance(task, Task): raise TypeError( f"{self.__class__.__name__}.task should be an instance of " f"stalker.models.task.Task, not {task.__class__.__name__}: '{task}'" ) # is it a leaf task if not task.is_leaf: raise ValueError( "It is only possible to create a review for a leaf tasks, " f"and {task} is not a leaf task." ) # set the review_number of this review instance self._review_number = task.review_number + 1 return task @validates("version") def _validate_version( self, key: str, version: Union[None, "Version"] ) -> Union[None, "Version"]: """Validate the given version value. Args: key (str): The name of the validated column. version (Union[None, Version]): The version value to be validated. Raises: TypeError: If version is not a Version instance. ValueError: If the version.task and the self.task is not matching. Returns: Union[None, Version]: The validated version value. """ if version is None: return version from stalker.models.version import Version if not isinstance(version, Version): raise TypeError( f"{self.__class__.__name__}.version should be a Version " f"instance, not {version.__class__.__name__}: '{version}'" ) if self.task is not None: if version.task != self.task: raise ValueError( f"{self.__class__.__name__}.version should be a Version " f"instance related to this Task: {version}" ) else: self.task = version.task return version @validates("reviewer") def _validate_reviewer(self, key: str, reviewer: "User") -> "User": """Validate the given reviewer value. Args: key (str): The name of the validated column. reviewer (User): The reviewer value to validate. Raises: TypeError: If the given reviewer is not a User instance. Returns: User: The validated reviewer value. """ from stalker.models.auth import User if not isinstance(reviewer, User): raise TypeError( f"{self.__class__.__name__}.reviewer should be set to a " "stalker.models.auth.User instance, " f"not {reviewer.__class__.__name__}: '{reviewer}'" ) return reviewer def _review_number_getter(self) -> int: """Return the review number value. Returns: int: The review_number value. """ return self._review_number review_number: Mapped[Optional[int]] = synonym( "_review_number", descriptor=property(_review_number_getter), doc="returns the _review_number attribute value", ) @property def review_set(self) -> List["Review"]: """Return all the reviews in the same review set with this one. Returns: List[Review]: The Review instances in the same review set with this one. """ logger.debug( f"finding revisions with the same review_number of: {self.review_number}" ) with DBSession.no_autoflush: logger.debug("using raw Python to get review set") reviews = [] rev_num = self.review_number for review in self.task.reviews: if review.review_number == rev_num: reviews.append(review) return reviews def is_finalized(self) -> bool: """Check if all reviews in the same set with this one are finalized. Returns: bool: True if all the reviews in the same review set with this one are finalized, False otherwise. """ return all([review.status.code != "NEW" for review in self.review_set]) def request_revision( self, schedule_timing: Union[float, int] = 1, schedule_unit: Union[str, TimeUnit] = TimeUnit.Hour, description: str = "", ) -> None: """Finalize the review by requesting a revision. Args: schedule_timing (Union[float, int]): The schedule timing value for this Review instance. schedule_unit (Union[str, TimeUnit]): The schedule unit value for this Review instance. description (str): The description for this Review instance. """ # set self timing values self.schedule_timing = schedule_timing self.schedule_unit = schedule_unit self.description = description # set self status to RREV with DBSession.no_autoflush: rrev = Status.query.filter_by(code="RREV").first() # set self status to RREV self.status = rrev # call finalize_review_set self.finalize_review_set() def approve(self): """Finalize the review by approving the task.""" # set self status to APP with DBSession.no_autoflush: app = Status.query.filter_by(code="APP").first() self.status = app # call finalize review_set self.finalize_review_set() def finalize_review_set(self) -> None: """Finalize the current review set Review decisions.""" with DBSession.no_autoflush: hrev = Status.query.filter_by(code="HREV").first() cmpl = Status.query.filter_by(code="CMPL").first() # check if all the reviews are finalized if not self.is_finalized(): logger.debug("not all reviews are finalized yet!") return logger.debug("all reviews are finalized") # check if there are any RREV reviews revise_task = False # now we can extend the timing of the task total_seconds = self.task.total_logged_seconds for review in self.review_set: if review.status.code == "RREV": total_seconds += review.schedule_seconds revise_task = True timing, unit = self.least_meaningful_time_unit(total_seconds) self.task._review_number += 1 if revise_task: # revise the task timing if the task needs more time if total_seconds > self.task.schedule_seconds: logger.debug(f"total_seconds including reviews: {total_seconds}") self.task.schedule_timing = timing self.task.schedule_unit = unit self.task.status = hrev else: # approve the task self.task.status = cmpl # also clamp the schedule timing self.task.schedule_timing = timing self.task.schedule_unit = unit # update task parent statuses self.task.update_parent_statuses() from stalker import TaskDependency # update dependent task statuses for dependency in walk_hierarchy( self.task, "dependent_of", method=TraversalDirection.BreadthFirst ): logger.debug(f"current TaskDependency object: {dependency}") dependency.update_status_with_dependent_statuses() if dependency.status.code in ["HREV", "PREV", "DREV", "OH", "STOP"]: # for tasks that are still be able to continue to work, # change the dependency_target to DependencyTarget.OnStart # to allow the two of the tasks to work together and still let # the TJ to be able to schedule the tasks correctly with DBSession.no_autoflush: task_dependencies = TaskDependency.query.filter_by( depends_on=dependency ).all() for task_dependency in task_dependencies: task_dependency.dependency_target = DependencyTarget.OnStart # also update the status of parents of dependencies dependency.update_parent_statuses() class Daily(Entity, StatusMixin, ProjectMixin): """Manages data related to **Dailies**. Dailies are sessions where outputs of a group of tasks are reviewed all together by the resources and responsible of those tasks. The main purpose of a ``Daily`` is to gather a group of :class:`.File` instances and introduce a simple way of presenting them as a group. :class:`.Note` s created during a Daily session can be directly stored both in the :class:`.File` and the :class:`.Daily` instances and a *join* will reveal which :class:`.Note` is created in which :class:`.Daily`. """ __auto_name__ = False __tablename__ = "Dailies" __mapper_args__ = {"polymorphic_identity": "Daily"} daily_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) files: Mapped[Optional[List[File]]] = association_proxy( "file_relations", "file", creator=lambda n: DailyFile(file=n) ) file_relations: Mapped[Optional[List["DailyFile"]]] = relationship( back_populates="daily", cascade="all, delete-orphan", primaryjoin="Dailies.c.id==Daily_Files.c.daily_id", ) def __init__( self, files: Optional[List[File]] = None, **kwargs: Dict[str, Any], ) -> None: super(Daily, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) ProjectMixin.__init__(self, **kwargs) if files is None: files = [] self.files = files @property def versions(self) -> List["Version"]: """Return the Version instances related to this Daily. Returns: List[Task]: A list of :class:`.Version` instances that this Daily is related to (through the files attribute of the versions). """ from stalker.models.version import Version return ( Version.query.join(Version.files) .join(DailyFile) .join(Daily) .filter(Daily.id == self.id) .all() ) @property def tasks(self) -> List["Task"]: """Return the Task's related this Daily instance. Returns: List[Task]: A list of :class:`.Task` instances that this Daily is related to (through the files attribute of the versions). """ from stalker.models.version import Version from stalker.models.task import Task return ( Task.query.join(Task.versions) .join(Version.files) .join(DailyFile) .join(Daily) .filter(Daily.id == self.id) .all() ) class DailyFile(Base): """The association object used in Daily-to-File relation.""" __tablename__ = "Daily_Files" daily_id: Mapped[int] = mapped_column( ForeignKey("Dailies.id"), primary_key=True, ) daily: Mapped[Daily] = relationship( back_populates="file_relations", primaryjoin="DailyFile.daily_id==Daily.daily_id", ) file_id: Mapped[int] = mapped_column( ForeignKey("Files.id"), primary_key=True, ) file: Mapped[File] = relationship( primaryjoin="DailyFile.file_id==File.file_id", doc="""stalker.models.file.File instances related to the Daily instance. Attach the same :class:`.File` instances that are linked as an output to a certain :class:`.Version` s instance to this attribute. This attribute is an **association_proxy** so and the real attribute that the data is related to is the :attr:`.file_relations` attribute. You can use the :attr:`.file_relations` attribute to change the ``rank`` attribute of the :class:`.DailyFile` instance (which is the returned data), thus change the order of the ``Files``. This is done in that way to be able to store the order of the files in this Daily instance. """, ) # may used for sorting rank: Mapped[Optional[int]] = mapped_column(default=0) def __init__( self, daily: Optional[Daily] = None, file: Optional[File] = None, rank: int = 0 ) -> None: super(DailyFile, self).__init__() self.daily = daily self.file = file self.rank = rank @validates("file") def _validate_file(self, key: str, file: Union[None, File]) -> Union[None, File]: """Validate the given file instance. Args: key (str): The name of the validated column. file (Union[None, File]): The like value to be validated. Raises: TypeError: When the given like value is not a File instance. Returns: Union[None, File]: The validated File instance. """ from stalker import File if file is not None and not isinstance(file, File): raise TypeError( f"{self.__class__.__name__}.file should be an instance of " "stalker.models.file.File instance, " f"not {file.__class__.__name__}: '{file}'" ) return file @validates("daily") def _validate_daily( self, key: str, daily: Union[None, Daily] ) -> Union[None, Daily]: """Validate the given daily instance. Args: key (str): The name of the validated column. daily (Union[None, Daily]): The daily value to be validated. Raises: TypeError: If the given daily value is not a Daily instance. Returns: Union[None, Daily]: The validated daily instance. """ if daily is not None: if not isinstance(daily, Daily): raise TypeError( f"{self.__class__.__name__}.daily should be an instance of " "stalker.models.review.Daily instance, " f"not {daily.__class__.__name__}: '{daily}'" ) return daily ================================================ FILE: src/stalker/models/scene.py ================================================ # -*- coding: utf-8 -*- """Scene related classes and functions are situated here.""" from typing import Any, Dict, List, Optional, TYPE_CHECKING from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.log import get_logger from stalker.models.mixins import CodeMixin from stalker.models.task import Task if TYPE_CHECKING: # pragma: no cover from stalker.models.shot import Shot logger = get_logger(__name__) class Scene(Task, CodeMixin): """Stores data about Scenes. Scenes are grouping the Shots according to their view to the world, that is shots taking place in the same set configuration can be grouped together by using Scenes. You cannot replace :class:`.Sequence` s with Scenes, because Scene instances doesn't have some key features that :class:`.Sequence` s have. A Scene needs to be tied to a :class:`.Project` instance, so it is not possible to create a Scene without a one. """ __auto_name__ = False __tablename__ = "Scenes" __mapper_args__ = {"polymorphic_identity": "Scene"} scene_id: Mapped[int] = mapped_column( "id", ForeignKey("Tasks.id"), primary_key=True, ) shots: Mapped[Optional[List["Shot"]]] = relationship( primaryjoin="Shots.c.scene_id==Scenes.c.id", back_populates="scene", doc="""The :class:`.Shot` s that is related with this Scene. It is a list of :class:`.Shot` instances. """, ) def __init__(self, shots: Optional[List["Shot"]] = None, **kwargs: Dict[str, Any]): super(Scene, self).__init__(**kwargs) # call the mixin __init__ methods CodeMixin.__init__(self, **kwargs) if shots is None: shots = [] self.shots = shots @validates("shots") def _validate_shots(self, key: str, shot: "Shot") -> "Shot": """Validate the given shot value. Args: key (str): The name of the validated column. shot (Shot): The shot instance. Raises: TypeError: If the shot is not a Shot instance. Returns: Shot: Return the validated Shot instance. """ from stalker.models.shot import Shot if not isinstance(shot, Shot): raise TypeError( f"{self.__class__.__name__}.shots should only contain " "instances of stalker.models.shot.Shot, " f"not {shot.__class__.__name__}: '{shot}'" ) return shot def __eq__(self, other: Any) -> bool: """Check the equality with the other object. Args: other (Any): The other object. Returns: bool: True if the other object is equal to this object. """ return isinstance(other, Scene) and super(Scene, self).__eq__(other) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Scene, self).__hash__() ================================================ FILE: src/stalker/models/schedulers.py ================================================ # -*- coding: utf-8 -*- """Scheduler related function and classes are situated here.""" import csv import datetime import json import os import subprocess import sys import tempfile import time from typing import List, Optional, TYPE_CHECKING, Union from jinja2 import Template import pytz from sqlalchemy import bindparam, text from stalker import defaults from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.project import Project from stalker.models.task import Task, Task_Computed_Resources if TYPE_CHECKING: # pragma: no cover from stalker.models.studio import Studio logger = get_logger(__name__) class SchedulerBase(object): """This is the base class for schedulers. All the schedulers should be derived from this class. """ def __init__(self, studio: Optional["Studio"] = None) -> None: self._studio = None self.studio = studio def _validate_studio(self, studio: Union[None, "Studio"]) -> Union[None, "Studio"]: """Validate the given studio value. Args: studio (Union[None, Studio]): The Studio instance to set the studio attribute to. Raises: TypeError: If the given value is not a Studio instance. Returns: Union[None, Studio]: The validated Studio instance. """ if studio is not None: from stalker.models.studio import Studio if not isinstance(studio, Studio): raise TypeError( f"{self.__class__.__name__}.studio should be an instance of " "stalker.models.studio.Studio, " f"not {studio.__class__.__name__}: '{studio}'" ) return studio @property def studio(self) -> Union[None, "Studio"]: """Return studio attribute value. Returns: Studio: The studio attribute value. """ return self._studio @studio.setter def studio(self, studio: Union[None, "Studio"]) -> None: """Set studio attribute. Args: studio (Studio): The Studio instance to set the studio attribute to. """ self._studio = self._validate_studio(studio) def schedule(self) -> None: """Schedule function that needs to be implemented in the derivatives. Raises: NotImplementedError: If this is not implemented in the derived class. """ raise NotImplementedError class TaskJugglerScheduler(SchedulerBase): """This is the main scheduler for Stalker right now. This class prepares the data for TaskJuggler and let it solve the scheduling problem, and then retrieves the solved date and resource data back. TaskJugglerScheduler needs a :class:`.Studio` instance to work with, it will create a .tjp file and then solve the tasks and restore the computed_start and computed_end dates and the computed_resources attributes for each task. Stalker will pass all its data to TaskJuggler by creating a tjp file that TaskJuggler can parse. This tjp file has all the Projects, Tasks, Users, Departments, TimeLogs, Vacations and everything that TJ need for solving the tasks. With every new version of it, Stalker tries to cover more and more TaskJuggler directives. .. note:: .. versionadded:: 0.2.5 Alternative Resources Stalker is now able to pass alternative resources to TaskJuggler. Although, per resource alternatives are not yet possible, it will be implemented in future versions of Stalker. .. note:: .. versionadded:: 0.2.5 Task Dependency Relation Attributes Stalker now can use 'gapduration', 'gaplength', 'onstart' and 'onend' TaskJuggler directives for each dependent task of a task. Use the TaskDependency instance in Task.task_dependency attribute to control how a particular task is depending on another task. .. warning:: **Task.computed_resources Attribute Content** After the scheduling is finished, TaskJuggler will create a ``csv`` report that TaskJugglerScheduler will parse. This csv file contains the ``id``, ``start date``, ``end date`` and ``resources`` data. The resources reported back by TJ will be stored in :attr:`.Task.computed_resources` attribute. TaskJuggler will put all the resources who may have entered a :class:`.TimeLog` previously to the csv file. But the resources from the csv file may not be in :attr:`.Task.resources` or :attr:`.Task.alternative_resources` anymore. Because of that, TaskJugglerScheduler will only store the resources those are both in csv file and in :attr:`.Task.resources` or :attr:`.Task.alternative_resources` attributes. Stalker will export each Project to tjp as the highest task in the hierarchy and all the projects will be combined in to the same tjp file. Combining all the Projects in one tjp file has a very nice side effect, projects using the same resources will respect their allocations to the resource. So that when a TaskJugglerScheduler instance is used to schedule the project, all projects are scheduled together. The following table shows which Stalker data type is converted to which TaskJuggler type: +------------+-------------+ | Stalker | TaskJuggler | +============+=============+ | Studio | Project | +------------+-------------+ | Project | Task | +------------+-------------+ | Task | Task | +------------+-------------+ | Asset | Task | +------------+-------------+ | Shot | Task | +------------+-------------+ | Sequence | Task | +------------+-------------+ | Departmemt | Resource | +------------+-------------+ | User | Resource | +------------+-------------+ | TimeLog | Booking | +------------+-------------+ | Vacation | Vacation | +------------+-------------+ Args: compute_resources (bool): When set to True it will also consider :attr:`.Task.alternative_resources` attribute and will fill :attr:`.Task.computed_resources` attribute for each Task. With :class:`.TaskJugglerScheduler` when the total number of Task is around 15k it will take around 7 minutes to generate this data, so by default it is False. parsing_method (int): Choose between SQL (0) or Pure Python (1) parsing. The default is SQL. """ def __init__( self, studio: Optional["Studio"] = None, compute_resources: Optional[bool] = False, parsing_method: Optional[int] = 0, projects: Optional[Project] = None, ) -> None: super(TaskJugglerScheduler, self).__init__(studio) self.tjp_content = "" self.temp_file_full_path = None self.temp_file_path = None self.temp_file_name = None self.tjp_file_full_path = None self.tjp_file = None self.csv_file_full_path = None self.csv_file = None self.compute_resources = compute_resources self.parsing_method = parsing_method self._projects = [] self.projects = projects def _create_tjp_file(self) -> None: """Create the tjp file.""" self.temp_file_full_path = tempfile.mktemp(prefix="Stalker_") self.temp_file_path = os.path.dirname(self.temp_file_full_path) self.temp_file_name = os.path.basename(self.temp_file_full_path) self.tjp_file_full_path = f"{self.temp_file_full_path}.tjp" self.csv_file_full_path = f"{self.temp_file_full_path}.csv" def _create_tjp_file_content(self) -> None: # noqa: C901 """Create the tjp file content.""" start = time.time() # use new way of doing it, it will just work with PostgreSQL template = Template(defaults.tjp_main_template2) if not self.projects: project_ids = ( DBSession.connection() .execute(text('select id, code from "Projects"')) .fetchall() ) else: project_ids = [[project.id] for project in self.projects] sql_query = """select "Tasks".id, tasks.path, coalesce("Tasks".parent_id, "Tasks".project_id) as parent_id, tasks.entity_type, tasks.name, "Tasks".priority, "Tasks".schedule_timing, "Tasks".schedule_unit, "Tasks".schedule_model, "Tasks".allocation_strategy, "Tasks".persistent_allocation, tasks.depth, task_resources.resource_ids, task_alternative_resources.resource_ids as alternative_resource_ids, time_logs.time_log_array, task_dependencies.dependency_info, not exists ( select 1 from "Tasks" as "Child_Tasks" where "Child_Tasks".parent_id = "Tasks".id ) as is_leaf from "Tasks" join ( with recursive recursive_task(id, parent_id, path_as_text, path, depth) as ( select id, parent_id, id::text as path_as_text, array[project_id] as path, 0 from "Tasks" where parent_id is NULL and project_id = :id union all select task.id, task.parent_id, (parent.path_as_text || '-' || task.id) as path_as_text, (parent.path || task.parent_id) as path, parent.depth + 1 as depth from "Tasks" as task join recursive_task as parent on task.parent_id = parent.id ) select recursive_task.id, recursive_task.parent_id, recursive_task.path_as_text, recursive_task.path, "SimpleEntities".name as name, "SimpleEntities".entity_type, recursive_task.depth from recursive_task join "SimpleEntities" on recursive_task.id = "SimpleEntities".id --order by path_as_text ) as tasks on "Tasks".id = tasks.id -- resources left outer join ( select task_id, array_agg(resource_id order by resource_id) as resource_ids from "Task_Resources" group by task_id ) as task_resources on "Tasks".id = task_resources.task_id -- alternative resources left outer join ( select task_id, array_agg(resource_id order by resource_id) as resource_ids from "Task_Alternative_Resources" group by task_id ) as task_alternative_resources on "Tasks".id = task_alternative_resources.task_id -- time logs left outer join ( select "TimeLogs".task_id, array_agg(('User_' || "TimeLogs".resource_id, to_char(cast("TimeLogs".start at time zone 'utc' as timestamp), 'YYYY-MM-DD-HH24:MI:00'), to_char(cast("TimeLogs".end at time zone 'utc' as timestamp), 'YYYY-MM-DD-HH24:MI:00'))) as time_log_array from "TimeLogs" group by task_id ) as time_logs on "Tasks".id = time_logs.task_id -- dependencies left outer join ( select task_id, array_agg((tasks.alt_path, dependency_target, gap_timing, gap_unit, gap_model)) dependency_info from "Task_Dependencies" join ( with recursive recursive_task(id, parent_id, alt_path) as ( select id, parent_id, project_id::text as alt_path from "Tasks" where parent_id is NULL union all select task.id, task.parent_id, (parent.alt_path || '-' || task.parent_id) as alt_path from "Tasks" as task join recursive_task as parent on task.parent_id = parent.id ) select recursive_task.id, recursive_task.parent_id, recursive_task.alt_path || '-' || recursive_task.id as alt_path from recursive_task join "SimpleEntities" on recursive_task.id = "SimpleEntities".id ) as tasks on "Task_Dependencies".depends_on_id = tasks.id group by task_id ) as task_dependencies on "Tasks".id = task_dependencies.task_id --order by "Tasks".id order by path_as_text""" # noqa: B950 result_buffer = [] num_of_records = 0 # run it per project for pr in project_ids: p_id = pr[0] # p_code = pr[1] result = DBSession.connection().execute(text(sql_query), {"id": p_id}) # start by adding the project first result_buffer.append(f'task Project_{p_id} "Project_{p_id}" {{') # now start jumping around previous_level = 0 for r in result.fetchall(): # start by appending task tjp id first task_id = r[0] # path = r[1] # parent_id = r[2] # entity_type = r[3] # name = r[4] priority = r[5] schedule_timing = r[6] schedule_unit = r[7] schedule_model = r[8] allocation_strategy = r[9] persistent_allocation = r[10] depth = r[11] + 1 resource_ids = r[12] alternative_resource_ids = r[13] time_log_array = r[14] dependency_info = r[15] is_leaf = r[16] tab = " " * depth # close the previous level if necessary for i in range(previous_level - depth + 1): i_tab = " " * (previous_level - i) result_buffer.append(f"{i_tab}}}") result_buffer.append( f"""{tab}task Task_{task_id} "Task_{task_id}" {{""" ) # append priority if it is different then 500 if priority != 500: result_buffer.append(f"{tab} priority {priority}") # append dependency information if dependency_info: dep_buffer = [f"{tab} depends "] json_data = json.loads( dependency_info.replace("{", "[") .replace("}", "]") .replace("(", "") .replace(")", "") ) # it is an array of string for i, dependency in enumerate(json_data): if i > 0: dep_buffer.append(", ") ( dep_full_ids, dependency_target, gap_timing, gap_unit, gap_model, ) = dependency.split(",") dep_full_path = ".".join( map(lambda x: f"Task_{x}", dep_full_ids.split("-")) ) # fix for Project id dep_full_path = f"Project_{dep_full_path[5:]}" dep_string = f"{dep_full_path} {{{dependency_target}}}" dep_buffer.append(dep_string) result_buffer.append("".join(dep_buffer)) # append schedule model and timing information # if this is a leaf task and has resources if is_leaf and resource_ids: result_buffer.append( f"{tab} {schedule_model} {schedule_timing}{schedule_unit}" ) resource_buffer = [f"{tab} allocate "] for i, resource_id in enumerate(resource_ids): if i > 0: resource_buffer.append(", ") resource_buffer.append(f"User_{resource_id}") # now go through alternatives if alternative_resource_ids: resource_buffer.append(" { alternative ") for j, alt_resource_id in enumerate( alternative_resource_ids ): if j > 0: resource_buffer.append(", ") resource_buffer.append(f"User_{alt_resource_id}") # set the allocation strategy resource_buffer.append(f" select {allocation_strategy}") # is is persistent if persistent_allocation: resource_buffer.append(" persistent") resource_buffer.append(" }") result_buffer.append("".join(resource_buffer)) # append any time log information if time_log_array: json_data = json.loads( time_log_array.replace("{", "[") .replace("}", "]") .replace("(", "") .replace(")", "") ) # it is an array of string for tlog in json_data: user_id, t_start, t_end = tlog.split(",") result_buffer.append( f"{tab} booking {user_id} {t_start} - {t_end} " "{ overtime 2 }" ) previous_level = depth num_of_records += 1 # and close the brackets per project depth = 0 # current depth is 0 (Project) # previous_level is the last task for i in range(previous_level - depth + 1): i_tab = " " * (previous_level - i) result_buffer.append(f"{i_tab}}}") tasks_buffer = "\n".join(result_buffer) import stalker self.tjp_content = template.render( { "stalker": stalker, "studio": self.studio, "csv_file_name": self.temp_file_name, "csv_file_full_path": self.temp_file_full_path, "compute_resources": self.compute_resources, "tasks_buffer": tasks_buffer, }, trim_blocks=True, lstrip_blocks=True, ) logger.debug(f"total number of records: {num_of_records}") end = time.time() logger.debug( "rendering the whole tjp file took: {:0.3f} seconds".format(end - start) ) def _fill_tjp_file(self) -> None: """Fill the tjp file with content.""" with open(self.tjp_file_full_path, "w+") as self.tjp_file: self.tjp_file.write(self.tjp_content) def _delete_tjp_file(self) -> None: """Delete the temp tjp file.""" try: os.remove(self.tjp_file_full_path) except OSError: pass def _delete_csv_file(self) -> None: """Delete the temp csv file.""" try: os.remove(self.csv_file_full_path) except OSError: pass def _clean_up(self) -> None: """Remove the temp files.""" self._delete_tjp_file() self._delete_csv_file() def _parse_csv_file(self) -> None: """Parse the csv file and set the Task.computes_start and Task.computed_end.""" parsing_start = time.time() logger.debug(f"csv_file_full_path : {self.csv_file_full_path}") if not os.path.exists(self.csv_file_full_path): logger.debug("could not find CSV file, returning without updating db!") return entity_ids = [] update_data = [] update_user_data = [] with open(self.csv_file_full_path, "r") as self.csv_file: csv_content = csv.reader(self.csv_file, delimiter=";") lines = [line for line in csv_content] lines.pop(0) for data in lines: id_line = data[0] entity_id = int(id_line.split(".")[-1].split("_")[-1]) if not entity_id: continue entity_ids.append(entity_id) start_date = datetime.datetime.strptime(data[1], "%Y-%m-%d-%H:%M") end_date = datetime.datetime.strptime(data[2], "%Y-%m-%d-%H:%M") # implement time zone info start_date = start_date.replace(tzinfo=pytz.utc) end_date = end_date.replace(tzinfo=pytz.utc) # computed_resources if self.compute_resources and data[3] != "": resources_data = map( lambda x: x.split("_")[-1].split(")")[0], data[3].split(",") ) for rid in resources_data: update_user_data.append({"task_id": entity_id, "resource_id": rid}) update_data.append( { "b_id": entity_id, "start": start_date, "end": end_date, "computed_start": start_date, "computed_end": end_date, } ) # update date values update_statement = ( Task.__table__.update() .where(Task.__table__.c.id == bindparam("b_id")) .values( start=bindparam("start"), end=bindparam("end"), computed_start=bindparam("computed_start"), computed_end=bindparam("computed_end"), ) ) DBSession.connection().execute(update_statement, update_data) # update project dates update_project_statement = ( Project.__table__.update() .where(Project.__table__.c.id == bindparam("b_id")) .values( start=bindparam("start"), end=bindparam("end"), computed_start=bindparam("computed_start"), computed_end=bindparam("computed_end"), ) ) DBSession.connection().execute(update_project_statement, update_data) # update computed resources data # first delete everything if self.compute_resources: delete_resources_statement = Task_Computed_Resources.delete() update_resources_statement = Task_Computed_Resources.insert().values( task_id=bindparam("task_id"), resource_id=bindparam("resource_id") ) DBSession.connection().execute(delete_resources_statement) DBSession.connection().execute(update_resources_statement, update_user_data) parsing_end = time.time() logger.debug( "completed parsing csv file in (SQL): {} seconds".format( parsing_end - parsing_start ) ) def schedule(self) -> str: """Schedule the project or all projects in the Studio. Raises: TypeError: If the self.studio is not a Studio instance. RuntimeError: If the tj3 command returns an error. Returns: str: The tj3 command output. """ # check the studio attribute from stalker.models.studio import Studio if not isinstance(self.studio, Studio): raise TypeError( f"{self.__class__.__name__}.studio should be an instance of " "stalker.models.studio.Studio, " f"not {self.studio.__class__.__name__}: '{self.studio}'" ) # create a tjp file self._create_tjp_file() # create tjp file content self._create_tjp_file_content() # fill it with data self._fill_tjp_file() logger.debug(f"tjp_file_full_path: {self.tjp_file_full_path}") # pass it to tj3 if sys.platform == "win32": logger.debug("tj3 using fallback mode for Windows!") command = "{} {} -o {}".format( defaults.tj_command, self.tjp_file_full_path, self.temp_file_path, ) logger.debug(f"tj3 command: {command}") return_code = os.system(command) stderr_buffer = "" else: process = subprocess.Popen( [ defaults.tj_command, self.tjp_file_full_path, "-o", self.temp_file_path, ], stderr=subprocess.PIPE, ) # loop until process finishes and capture stderr output stderr_buffer = [] while True: stderr = process.stderr.readline() if stderr == b"" and process.poll() is not None: break if stderr != b"": stderr = stderr.decode("utf-8").strip() stderr_buffer.append(stderr) logger.debug(stderr) # flatten the buffer stderr_buffer = "\n".join(stderr_buffer) return_code = process.returncode if return_code: # there is an error raise RuntimeError(stderr_buffer) # read back the csv file self._parse_csv_file() logger.debug(f"tj3 return code: {return_code}") # remove the tjp file self._clean_up() return stderr_buffer def _validate_projects(self, projects: List[Project]) -> List[Project]: """Validate the given projects value. Args: projects (List[Project]): List of Project instances. Raises: TypeError: If the projects is not a list or if any of the items in the projects list is not a Project instance. Returns: List[Project]: List of validated Project instances. """ if projects is None: projects = [] msg = ( "{cls}.projects should only contain instances of " "stalker.models.project.Project, not " "{projects_class}: '{projects}'" ) if not isinstance(projects, list): raise TypeError( msg.format( cls=self.__class__.__name__, projects_class=projects.__class__.__name__, projects=projects, ) ) for item in projects: if not isinstance(item, Project): raise TypeError( msg.format( cls=self.__class__.__name__, projects_class=item.__class__.__name__, projects=item, ) ) return projects @property def projects(self) -> List[Project]: """Return the projects attribute value. Returns: List[Project]: List of Project instances. """ return self._projects @projects.setter def projects(self, projects: List[Project]) -> None: """Set the projects attribute. Args: projects (List[Project]): List of Project instances. """ self._projects = self._validate_projects(projects) ================================================ FILE: src/stalker/models/sequence.py ================================================ # -*- coding: utf-8 -*- """Sequence related function and classes are situated here.""" from typing import Any, Dict, List, Optional, TYPE_CHECKING from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.log import get_logger from stalker.models.mixins import CodeMixin, ReferenceMixin from stalker.models.task import Task if TYPE_CHECKING: # pragma: no cover from stalker.models.shot import Shot logger = get_logger(__name__) class Sequence(Task, CodeMixin): """Stores data about Sequences. Sequences are a way of grouping the Shots according to their temporal position to each other. **Initialization** .. warning:: .. deprecated:: 0.2.0 Sequences do not have a lead anymore. Use the :class:`.Task.responsible` attribute of the super (:class:`.Task`). """ __auto_name__ = False __tablename__ = "Sequences" __mapper_args__ = {"polymorphic_identity": "Sequence"} sequence_id: Mapped[int] = mapped_column( "id", ForeignKey("Tasks.id"), primary_key=True, ) shots: Mapped[Optional[List["Shot"]]] = relationship( primaryjoin="Shots.c.sequence_id==Sequences.c.id", back_populates="sequence", doc="""The :class:`.Shot` s assigned to this Sequence. It is a list of :class:`.Shot` instances. """, ) def __init__(self, **kwargs: Dict[str, Any]) -> None: super(Sequence, self).__init__(**kwargs) # call the mixin __init__ methods ReferenceMixin.__init__(self, **kwargs) CodeMixin.__init__(self, **kwargs) self.shots = [] @validates("shots") def _validate_shots(self, key: str, shot: "Shot") -> "Shot": """Validate the given shot value. Args: key (str): The name of the validated column. shot (Shot): The Shot instance to validate. Raises: TypeError: If the given shot is not a Shot instance. Returns: Shot: The validated shot value. """ from stalker.models.shot import Shot if not isinstance(shot, Shot): raise TypeError( f"{self.__class__.__name__}.shots should only contain " "instances of stalker.models.shot.Shot, " f"not {shot.__class__.__name__}: '{shot}'" ) return shot def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Sequence instance and has the same attributes. """ return isinstance(other, Sequence) and super(Sequence, self).__eq__(other) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Sequence, self).__hash__() ================================================ FILE: src/stalker/models/shot.py ================================================ # -*- coding: utf-8 -*- """Shot related functions and classes are situated here.""" from typing import Any, Dict, Optional, TYPE_CHECKING, Union from sqlalchemy import Float, ForeignKey from sqlalchemy.exc import OperationalError, UnboundExecutionError from sqlalchemy.orm import ( Mapped, mapped_column, reconstructor, relationship, synonym, validates, ) from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.format import ImageFormat from stalker.models.mixins import CodeMixin, ReferenceMixin, StatusMixin from stalker.models.task import Task if TYPE_CHECKING: # pragma: no cover from stalker.models.project import Project from stalker.models.scene import Scene from stalker.models.sequence import Sequence logger = get_logger(__name__) class Shot(Task, CodeMixin): """Manages Shot related data. A shot is a continuous, unbroken sequence of images that makes up a single part of a film. Shots are organized into :class:`.Sequence` s and :class:`.Scene` s :class:`.Sequence` s group shots together based on time, such as montage or flashback. :class:`.Scene` s group shots together based on location and narrative, marking where and when a specific story event occurs. .. warning:: .. deprecated:: 0.1.2 Because most of the shots in different projects may going to have the same name, which is a kind of a code like SH001, SH012A etc., and in Stalker you cannot have two entities with the same name if their types are also matching, to guarantee all the shots are going to have different names the :attr:`.name` attribute of the Shot instances are automatically set to a randomly generated **uuid4** sequence. .. note:: .. versionadded:: 0.1.2 The name of the shot can be freely set without worrying about clashing names. .. note:: .. versionadded:: 0.2.0 Shot instances now can have their own image format. So you can set up different resolutions per shot. .. note:: .. versionadded:: 0.2.0 Shot instances can now be created with a Project instance only, without needing a Sequence instance. Sequences are now a kind of a grouping attribute for the Shots. And Shots can have more than one Sequence. .. note:: .. versionadded:: 1.0.0 Shot instances can only be connected to a single Sequence instance via the `Shot.sequence` attribute. Previously, Shots could have multiple Sequences, the initial purpose of that was to allow the very very rare case of having the same shot appear in two different sequences which proved itself being very useless and making things unnecessarily complicated. So, it has been removed and Shots can only be connected to a single Sequence (Shot <-> Scene relation will follow this in later versions/commits). .. note:: .. versionadded:: 1.0.0 Shot and Scene relation is now many-to-one, meaning a Shot can only be connected to a single Scene instance through the `Shot.scene` attribute. Two shots with the same :attr:`.code` cannot be assigned to the same :class:`.Sequence`. .. note:: .. versionadded:: 0.2.10 Simplified the implementation of :attr:`.cut_in`, :attr:`.cut_out` and :attr:`.cut_duration` attributes. The :attr:`.cut_duration` is always the difference between :attr:`.cut_in` and :attr:`.cut_out` and its value is only be calculated when it is requested. This greatly simplifies the implementation of :attr:`.cut_in` and :attr:`.cut_out` attributes. The :attr:`.cut_out` and :attr:`.cut_duration` attributes effects each other. Setting the :attr:`.cut_out` will change the :attr:`.cut_duration` and setting the :attr:`.cut_duration` will change the :attr:`.cut_out` value. The default value of the :attr:`.cut_duration` attribute is calculated from the :attr:`.cut_in` and :attr:`.cut_out` attributes. If both :attr:`.cut_out` and :attr:`.cut_duration` arguments are set to None, the :attr:`.cut_duration` defaults to 1 and :attr:`.cut_out` will be set to :attr:`.cut_in` + :attr:`.cut_duration`. So the priority of the attributes are as follows: :attr:`.cut_in` > :attr:`.cut_out` > :attr:`.cut_duration` .. note:: .. versionadded:: 0.2.4 :attr:`.handles_at_start` and :attr:`.handles_at_end` attributes. .. note:: .. versionadded:: 0.2.17.2 Per shot FPS values. It is now possible to change the shot fps by setting its :attr:`.fps` attribute. The default values is same with the :class:`.Project` . Args: project (Project): This is the :class:`.Project` instance that this shot belongs to. A Shot cannot be created without a Project instance. sequence (Sequence): This is a :class:`.Sequence` that this shot is assigned to. A Shot can be created without having a Sequence instance. cut_in (int): The in frame number that this shot starts. The default value is 1. When the ``cut_in`` is bigger then ``cut_out``, the :attr:`.cut_out` attribute is set to :attr:`.cut_in` + 1. cut_duration (int): The duration of this shot in frames. It should be zero or a positive integer value (natural number?) or . The default value is None. cut_out (int): The out frame number that this shot ends. If it is given as a value lower then the ``cut_in`` parameter, then the :attr:`.cut_out` will be recalculated from the existent :attr:`.cut_in` :attr:`.cut_duration` attributes. Can be skipped. The default value is None. image_format (ImageFormat): The image format of this shot. This is an optional variable to differentiate the image format per shot. The default value is the same with the Project that this Shot belongs to. fps (float): The FPS of this shot. Default value is the same with the :class:`.Project` . """ __auto_name__ = True __tablename__ = "Shots" __mapper_args__ = {"polymorphic_identity": "Shot"} shot_id: Mapped[int] = mapped_column( "id", ForeignKey("Tasks.id"), primary_key=True, ) sequence_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Sequences.id")) sequence: Mapped[Optional["Sequence"]] = relationship( primaryjoin="Shots.c.sequence_id==Sequences.c.id", back_populates="shots", ) scene_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Scenes.id")) scene: Mapped[Optional["Scene"]] = relationship( primaryjoin="Shots.c.scene_id==Scenes.c.id", back_populates="shots", ) image_format_id: Mapped[Optional[int]] = mapped_column( ForeignKey("ImageFormats.id") ) _image_format: Mapped[Optional[ImageFormat]] = relationship( "ImageFormat", primaryjoin="Shots.c.image_format_id==ImageFormats.c.id", doc="""The :class:`.ImageFormat` of this shot. This value defines the output image format of this shot, should be an instance of :class:`.ImageFormat`. """, ) # the cut_duration attribute is not going to be stored in the database, # only the cut_in and cut_out will be enough to calculate the cut_duration cut_in: Mapped[Optional[int]] = mapped_column( doc="The start frame of this shot. It is the start frame of the " "playback range in the application (Maya, Nuke etc.).", default=1, ) cut_out: Mapped[Optional[int]] = mapped_column( doc="The end frame of this shot. It is the end frame of the " "playback range in the application (Maya, Nuke etc.).", default=1, ) source_in: Mapped[Optional[int]] = mapped_column( doc="The start frame of the used range, should be in between" ":attr:`.cut_in` and :attr:`.cut_out`", ) source_out: Mapped[Optional[int]] = mapped_column( doc="The end frame of the used range, should be in between" ":attr:`.cut_in and :attr:`.cut_out`", ) record_in: Mapped[Optional[int]] = mapped_column( doc="The start frame in the Editors timeline specifying the start " "frame general placement of this shot.", ) _fps: Mapped[Optional[float]] = mapped_column( "fps", Float(precision=3), doc="""The fps of the project. It is a float value, any other types will be converted to float. The default value is equal to :attr:`stalker.models.project..Project.fps`. """, ) def __init__( self, code: Optional[str] = None, project: Optional["Project"] = None, sequence: Optional["Sequence"] = None, scene: Optional["Scene"] = None, cut_in: Optional[int] = None, cut_out: Optional[int] = None, source_in: Optional[int] = None, source_out: Optional[int] = None, record_in: Optional[int] = None, image_format: Optional[ImageFormat] = None, fps: Optional[float] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["project"] = project kwargs["code"] = code self._updating_cut_in_cut_out = False super(Shot, self).__init__(**kwargs) ReferenceMixin.__init__(self, **kwargs) StatusMixin.__init__(self, **kwargs) CodeMixin.__init__(self, **kwargs) self.sequence = sequence self.scene = scene self.image_format = image_format if cut_in is None: if cut_out is not None: cut_in = cut_out if cut_out is None: if cut_in is not None: cut_out = cut_in # if both are None set them to default values if cut_in is None and cut_out is None: cut_in = 1 cut_out = 1 self.cut_in = cut_in self.cut_out = cut_out if source_in is None: source_in = self.cut_in if source_out is None: source_out = self.cut_out self.source_in = source_in self.source_out = source_out self.record_in = record_in self.fps = fps @reconstructor def __init_on_load__(self) -> None: """Initialize on DB load.""" super(Shot, self).__init_on_load__() self._updating_cut_in_cut_out = False def __repr__(self) -> str: """Return the string representation of this Shot instance. Returns: str: The string representation of this Shot instance. """ return f"<{self.entity_type} ({self.name}, {self.code})>" def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Shot instance and has the same code and project. """ return ( isinstance(other, Shot) and self.code == other.code and self.project == other.project ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Shot, self).__hash__() @classmethod def _check_code_availability(cls, code: str, project: "Project") -> bool: """Check if the given code is available in the given project. Args: code (str): The code to check the availability of. project (Project): The stalker.models.project.Project instance that this shot is a part of. Raises: TypeError: If the code is not a str. Returns: bool: True if the given code is available, False otherwise. """ if not project or not code: return True if not isinstance(code, str): raise TypeError( "code should be a string containing a shot code, " f"not {code.__class__.__name__}: '{code}'" ) from stalker import Project if not isinstance(project, Project): raise TypeError( "project should be a Project instance, " f"not {project.__class__.__name__}: '{project}'" ) try: logger.debug("Try checking Shot.code with SQL expression.") with DBSession.no_autoflush: return ( Shot.query.filter(Shot.project == project) .filter(Shot.code == code) .first() is None ) except (UnboundExecutionError, OperationalError): # Fallback to Python logger.debug("SQL expression failed, falling back to Python!") for t in project.tasks: if isinstance(t, Shot) and t.code == code: return False return True def _fps_getter(self) -> float: """Return the fps value either from the Project or from the _fps attribute. Returns: float: The fps attribute value. """ if self._fps is None: return self.project.fps else: return self._fps def _fps_setter(self, fps: float) -> None: """Set the fps value. Args: fps (float): The fps value to set the fps attribute to. """ self._fps = self._validate_fps(fps) fps: Mapped[Optional[float]] = synonym( "_fps", descriptor=property(_fps_getter, _fps_setter), doc="The fps of this shot.", ) def _validate_fps(self, fps: Union[int, float]) -> float: """Validate the given fps value. Args: fps (Union[int, float]): Either an integer or float value to used as the fps. Raises: TypeError: If the given `fps` value is not an integer or float. ValueError: If the `fps` value is smaller or equal to 0. Returns: float: The validated fps value. """ if fps is None: # fps = self.project.fps return None if not isinstance(fps, (int, float)): raise TypeError( "{}.fps should be a positive float or int, not {}: '{}'".format( self.__class__.__name__, fps.__class__.__name__, fps ) ) fps = float(fps) if fps <= 0: raise ValueError( "{}.fps should be a positive float or int, not {}".format( self.__class__.__name__, fps ) ) return float(fps) @validates("cut_in") def _validate_cut_in(self, key: str, cut_in: int) -> int: """Validate the cut_in value. Args: key (str): The name of the validated column. cut_in (int): The `cut_in` value to be validated. Raises: TypeError: If the given `cut_in` value is not an integer. Returns: int: The validated `cut_in` value. """ if not isinstance(cut_in, int): raise TypeError( f"{self.__class__.__name__}.cut_in should be an int, " f"not {cut_in.__class__.__name__}: '{cut_in}'" ) if self.cut_out is not None and not self._updating_cut_in_cut_out: if cut_in > self.cut_out: # lock the attribute update self._updating_cut_in_cut_out = True self.cut_out = cut_in self._updating_cut_in_cut_out = False return cut_in @validates("cut_out") def _validate_cut_out(self, key: str, cut_out: int) -> int: """Validate the cut_out value. Args: key (str): The name of the validated column. cut_out (int): The `cut_out` value to be validated. Raises: TypeError: If the `cut_out` value is not an integer. Returns: int: The validated `cut_out` value. """ if not isinstance(cut_out, int): raise TypeError( f"{self.__class__.__name__}.cut_out should be an int, " f"not {cut_out.__class__.__name__}: '{cut_out}'" ) if ( self.cut_in is not None and not self._updating_cut_in_cut_out and cut_out < self.cut_in ): # lock the attribute update self._updating_cut_in_cut_out = True self.cut_in = cut_out self._updating_cut_in_cut_out = False return cut_out @validates("source_in") def _validate_source_in(self, key: str, source_in: int) -> int: """Validate the source_in value. Args: key (str): The name of the validated column. source_in (int): The `source_in` value to be validated. Raises: TypeError: If the `source_in` value is not an int. ValueError: If the given `source_in` value is smaller than the `cut_in` attribute value. ValueError: If the given `source_in` value is larger than the `cut_out` attribute value. ValueError: If a `source_out` is given before and the `source_in` value is larger than the `source_out` attribute value. Returns: int: The validated `source_in` value. """ if not isinstance(source_in, int): raise TypeError( f"{self.__class__.__name__}.source_in should be an int, " f"not {source_in.__class__.__name__}: '{source_in}'" ) if source_in < self.cut_in: raise ValueError( "{cls}.source_in cannot be smaller than " "{cls}.cut_in, cut_in: {cut_in} where as " "source_in: {source_in}".format( cls=self.__class__.__name__, cut_in=self.cut_in, source_in=source_in, ) ) if source_in > self.cut_out: raise ValueError( "{cls}.source_in cannot be bigger than " "{cls}.cut_out, cut_out: {cut_out} where as " "source_in: {source_in}".format( cls=self.__class__.__name__, cut_out=self.cut_out, source_in=source_in, ) ) if self.source_out and source_in > self.source_out: raise ValueError( "{cls}.source_in cannot be bigger than " "{cls}.source_out, source_in: {source_in} where " "as source_out: {source_out}".format( cls=self.__class__.__name__, source_out=self.source_out, source_in=source_in, ) ) return source_in @validates("source_out") def _validate_source_out(self, key: str, source_out: int) -> int: """Validate the source_out value. Args: key (str): The name of the validated column. source_out (int): The source_out value to be validated. Raises: TypeError: If the source_out is not an integer. ValueError: If the source_out is smaller than the cut_in attribute value. ValueError: If the source_out is larger than the cut_out attribute value. ValueError: If the source_in is not None and source_out is smaller than the source_in value. Returns: int: The validated source_out value. """ if not isinstance(source_out, int): raise TypeError( f"{self.__class__.__name__}.source_out should be an int, " f"not {source_out.__class__.__name__}: '{source_out}'" ) if source_out < self.cut_in: raise ValueError( "{cls}.source_out cannot be smaller than " "{cls}.cut_in, cut_in: {cut_in} where as " "source_out: {source_out}".format( cls=self.__class__.__name__, cut_in=self.cut_in, source_out=source_out, ) ) if source_out > self.cut_out: raise ValueError( "{cls}.source_out cannot be bigger than " "{cls}.cut_out, cut_out: {cut_out} where as " "source_out: {source_out}".format( cls=self.__class__.__name__, cut_out=self.cut_out, source_out=source_out, ) ) if self.source_in and source_out < self.source_in: raise ValueError( "{cls}.source_out cannot be smaller than " "{cls}.source_in, source_in: {source_in} where " "as source_out: {source_out}".format( cls=self.__class__.__name__, source_in=self.source_in, source_out=source_out, ) ) return source_out # @validates('record_in') # def _validate_record_in(self, key, record_in): # """validates the given record_in value # """ # # we don't really care about the record in value right now. # # it can be set to anything # return record_in @property def cut_duration(self) -> int: """Return the cut_duration property value. Returns: int: The cut_duration property value. """ return self.cut_out - self.cut_in + 1 @cut_duration.setter def cut_duration(self, cut_duration: int) -> None: """Set the cut_duration attribute. Args: cut_duration (int): The cut_duration value to be validated. Raises: TypeError: If the given cut_duration value is not an integer. ValueError: If the given cut_duration value is not a positive integer. """ if not isinstance(cut_duration, int): raise TypeError( f"{self.__class__.__name__}.cut_duration should be a positive " "integer value, " f"not {cut_duration.__class__.__name__}: '{cut_duration}'" ) if cut_duration < 1: raise ValueError( f"{self.__class__.__name__}.cut_duration cannot be set to " "zero or a negative value" ) # always extend or contract the shot from end self.cut_out = self.cut_in + cut_duration - 1 @validates("sequence") def _validate_sequence(self, key: str, sequence: "Sequence") -> "Sequence": """Validate the given sequence value. Args: key (str): The name of the validated column. sequence (Sequence): The sequence value to validate. Raises: TypeError: If the given sequence value is not a Sequence instance. Returns: Sequence: The validated Sequence instance. """ from stalker.models.sequence import Sequence if sequence is not None and not isinstance(sequence, Sequence): raise TypeError( f"{self.__class__.__name__}.sequence should be a " "stalker.models.sequence.Sequence instance, " f"not {sequence.__class__.__name__}: '{sequence}'" ) return sequence @validates("scene") def _validate_scene(self, key: str, scene: "Scene") -> "Scene": """Validate the given scene value. Args: key (str): The name of the validated column. scene (Scene): The scene value to validate. Raises: TypeError: If the given scene is not a Scene instance. Returns: Scene: The validated Scene instance. """ from stalker.models.scene import Scene if scene is not None and not isinstance(scene, Scene): raise TypeError( f"{self.__class__.__name__}.scene should be a " "stalker.models.scene.Scene instance, " f"not {scene.__class__.__name__}: '{scene}'" ) return scene def _image_format_getter(self) -> ImageFormat: """Return image_format value from the Project or from the _image_format attr. Returns: ImageFormat: The ImageFormat instance from image_format attribute or from the related Project's image_format attribute. """ if self._image_format is None: return self.project.image_format else: return self._image_format def _image_format_setter(self, imf: ImageFormat) -> None: """Set the image_format value. Args: imf (ImageFormat): The ImageFormat instance to set the image_format attribute value. """ self._image_format = self._validate_image_format(imf) image_format: Mapped[Optional[ImageFormat]] = synonym( "_image_format", descriptor=property(_image_format_getter, _image_format_setter), doc="The image_format of this shot. Set it to None to re-sync with " "Project.image_format.", ) def _validate_image_format( self, imf: Union[None, ImageFormat] ) -> Union[None, ImageFormat]: """Validate the given imf value. Args: imf (ImageFormat): The ImageFormat instance to validate. Raises: TypeError: If the given imf value is not an ImageFormat instance. Returns: ImageFormat: The validated ImageFormat instance. """ if imf is None: # do not set it to anything it will automatically use the project # image format return None if not isinstance(imf, ImageFormat): raise TypeError( f"{self.__class__.__name__}.image_format should be an instance of " "stalker.models.format.ImageFormat, " f"not {imf.__class__.__name__}: '{imf}'" ) return imf @validates("code") def _validate_code(self, key: str, code: str) -> str: """Validate the given code value. Args: key (str): The name of the validated column. code (str): The code to validate. Raises: ValueError: If the code is not available. Returns: str: The validated code value. """ code = super(Shot, self)._validate_code(key, code) # check code uniqueness if code != self.code and not self._check_code_availability(code, self.project): raise ValueError(f"There is a Shot with the same code: {code}") return code ================================================ FILE: src/stalker/models/status.py ================================================ # -*- coding: utf-8 -*- """Status and StatusList related functions and classes are situated here.""" from typing import Any, Dict, List, Optional, Type, Union from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import CodeMixin, TargetEntityTypeMixin logger = get_logger(__name__) class Status(Entity, CodeMixin): """Defines object statutes. No extra parameters, use the *code* attribute to give a short name for the status. A Status object can be compared with a string value and it will return if the lower case name or lower case code of the status matches the lower case form of the given string:: .. code-block:: Python >>> from stalker import Status >>> a_status = Status(name="On Hold", code="OH") >>> a_status == "on hold" True >>> a_status != "complete" True >>> a_status == "oh" True >>> a_status == "another status" False Args: name (str): The name long name of this Status. code (str): The code of this Status, its generally the short version of the name attribute. """ __auto_name__ = False __tablename__ = "Statuses" __mapper_args__ = {"polymorphic_identity": "Status"} status_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) def __init__( self, name: Optional[str] = None, code: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["name"] = name kwargs["code"] = code super(Status, self).__init__(**kwargs) self.code = code def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Status instance and has the same attributes. """ if isinstance(other, str): return ( self.name.lower() == other.lower() or self.code.lower() == other.lower() ) else: return super(Status, self).__eq__(other) and isinstance(other, Status) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Status, self).__hash__() class StatusList(Entity, TargetEntityTypeMixin): """Type specific list of :class:`.Status` instances. Holds multiple :class:`.Status` instances to be used as a choice list for several other classes. A StatusList can only be assigned to only one entity type. So a :class:`.Project` can only have one suitable StatusList object which is designed for :class:`.Project` entities. The list of statuses in StatusList can be accessed by using a list like indexing and it also supports string indexes only for getting the item, you cannot set an item with string indices: .. code-block:: Python >>> from stalker import Status, StatusList >>> status1 = Status(name="Complete", code="CMPLT") >>> status2 = Status(name="Work in Progress", code="WIP") >>> status3 = Status(name="Pending Review", code="PRev") >>> a_status_list = StatusList(name="Asset Status List", statuses=[status1, status2, status3], target_entity_type="Asset") >>> a_status_list[0] >>> a_status_list["complete"] >>> a_status_list["WIP"] Args: statuses (List[Status]): This is a list of :class:`.Status` instances, so you can prepare different StatusLists for different kind of entities using the same pool of :class:`.Status` instances. target_entity_type (str): use this parameter to specify the target entity type that this StatusList is designed for. It accepts classes or names of classes. For example: .. code-block:: Python from stalker import Status, StatusList, Project status_list = [ Status(name="Waiting To Start", code="WTS"), Status(name="On Hold", code="OH"), Status(name="In Progress", code="WIP"), Status(name="Waiting Review", code="WREV"), Status(name="Approved", code="APP"), Status(name="Completed", code="CMPLT"), ] project_status_list = StatusList( name="Project Status List", statuses=status_list, target_entity_type="Project" ) # or project_status_list = StatusList( name="Project Status List", statuses=status_list, target_entity_type=Project ) now with the code above you cannot assign the ``project_status_list`` object to any other class than a ``Project`` object. The StatusList instance can be empty, means it may not have anything in its :attr:`.StatusList.statuses`. But it is useless. The validation for empty statuses list is left to the SOM user. """ __auto_name__ = True __tablename__ = "StatusLists" __mapper_args__ = {"polymorphic_identity": "StatusList"} __unique_target__ = True status_list_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) statuses: Mapped[Optional[List[Status]]] = relationship( secondary="StatusList_Statuses", doc="List of :class:`.Status` objects, showing the possible statuses", ) def __init__( self, statuses: Optional[List[Status]] = None, target_entity_type: Optional[Union[Type, str]] = None, **kwargs: Dict[str, Any], ) -> None: super(StatusList, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, target_entity_type, **kwargs) if statuses is None: statuses = [] self.statuses = statuses @validates("statuses") def _validate_statuses(self, key: str, status: Status) -> Status: """Validate the given status value. Args: key (str): The name of the validated column. status (Status): The status value to be validated. Raises: TypeError: If the status value is not a Status instance. Returns: Status: The validated status value. """ if not isinstance(status, Status): raise TypeError( f"All of the elements in {self.__class__.__name__}.statuses must be an " "instance of stalker.models.status.Status, " f"not {status.__class__.__name__}: '{status}'" ) return status def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a StatusList instance and has the same statuses, target_entity_type. """ return ( super(StatusList, self).__eq__(other) and isinstance(other, StatusList) and self.statuses == other.statuses and self.target_entity_type == other.target_entity_type ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(StatusList, self).__hash__() def __getitem__(self, key: int) -> Status: """Return the Status at the given key. Args: key (int): The index to return the value of. Returns: Status: The Status instance at the given index. """ return_item = None with DBSession.no_autoflush: if isinstance(key, str): for item in self.statuses: if item == key: return_item = item break else: return_item = self.statuses[key] return return_item def __setitem__(self, key: int, value: Status) -> None: """Set the value at the given index. Args: key (int): The index to set the item value to. value (Status): The Status instance to set at the given index. """ self.statuses[key] = value def __delitem__(self, key: int) -> None: """Delete the item with the given key. Args: key (int): Remove the Status at the given index. """ del self.statuses[key] def __len__(self) -> int: """Return the number of Statuses in this StatusList. Returns: int: The number of Statuses in this StatusList. """ return len(self.statuses) # StatusList_Statuses Table StatusList_Statuses = Table( "StatusList_Statuses", Base.metadata, Column("status_list_id", Integer, ForeignKey("StatusLists.id"), primary_key=True), Column("status_id", Integer, ForeignKey("Statuses.id"), primary_key=True), ) ================================================ FILE: src/stalker/models/structure.py ================================================ # -*- coding: utf-8 -*- """Structure related functions and classes are situated here.""" from typing import Any, Dict, List, Optional, Union from sqlalchemy import Column, ForeignKey, Integer, Table, Text from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from stalker.db.declarative import Base from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.template import FilenameTemplate logger = get_logger(__name__) class Structure(Entity): """Defines folder structures for :class:`.Projects`. Structures are generally owned by :class:`.Project` objects. Whenever a :class:`.Project` is physically created, project folders are created by looking at :attr:`.Structure.custom_template` of the :class:`.Structure`, the :class:`.Project` object is generally given to the :class:`.Structure`. So it is possible to use a variable like "{{project}}" or derived variables like:: {% for seq in project.sequences %} {{do something here}} Every line of this rendered template will represent a folder and Stalker will create these folders on the attached :class:`.Repository`. Args: templates (List[FilenameTemplate]): A list of :class:`.FilenameTemplate` instances which defines a specific template for the given :attr:`.FilenameTemplate.target_entity_type` values. custom_template (str): A string containing several lines of folder names. The folders are relative to the :class:`.Project` root. It can also contain a Jinja2 Template code. Which will be rendered to show the list of folders to be created with the project. The Jinja2 Template is going to have the {{project}} variable. The important point to be careful about is to list all the custom folders of the project in a new line in this string. For example a :class:`.Structure` for a :class:`.Project` can have the following :attr:`.Structure.custom_template`:: .. code-block:: Jinja ASSETS {% for asset in project.assets %} {% set asset_root = 'ASSETS/' + asset.code %} {{asset_root}} {% for task in asset.tasks %} {% set task_root = asset_root + '/' + task.code %} {{task_root}} SEQUENCES {% for seq in project.sequences %}} {% set seq_root = 'SEQUENCES/' + {{seq.code}} %} {{seq_root}}/Edit {{seq_root}}/Edit/Export {{seq_root}}/Storyboard {% for shot in seq.shots %} {% set shot_root = seq_root + '/SHOTS/' + shot.code %} {{shot_root}} {% for task in shot.tasks %} {% set task_root = shot_root + '/' + task.code %} {{task_root}} The above example has gone far beyond deep than it is needed, where it started to define paths for :class:`.Asset` s. Even it is possible to create a :class:`.Project` structure like that, in general it is unnecessary. Because the above folders are going to be created but they are probably going to be empty for a while, because the :class:`.Asset` s are not created yet (or in fact no :class:`.Version` instances are created for the :class:`.Task` s). Anyway, it is much suitable and desired to create this details by using :class:`.FilenameTemplate` objects. Which are specific to certain :attr:`.FilenameTemplate.target_entity_type` s. And by using the :attr:`.Structure.custom_template` attribute, Stalker cannot place any source or output file of a :class:`.Version` in the :class:`.Repository` where as it can by using :class:`.FilenameTemplate` s. But for certain types of :class:`.Task` s it is may be good to previously create the folder structure just because in certain environments (programs) it is not possible to run a Python code that will place the file in to the Repository like in Photoshop. The ``custom_template`` parameter can be None or an empty string if it is not needed. A :class:`.Structure` cannot be created without a ``type`` (__strictly_typed__ = True). By giving a ``type`` to the :class:`.Structure`, you can create one structure for **Commercials** and another project structure for **Movies** and another one for **Print** projects etc. and can reuse them with new :class:`.Project` s. """ # __strictly_typed__ = True __auto_name__ = False __tablename__ = "Structures" __mapper_args__ = {"polymorphic_identity": "Structure"} structure_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) templates: Mapped[Optional[List[FilenameTemplate]]] = relationship( secondary="Structure_FilenameTemplates" ) custom_template: Mapped[Optional[str]] = mapped_column("custom_template", Text) def __init__( self, templates: Optional[List[FilenameTemplate]] = None, custom_template: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: super(Structure, self).__init__(**kwargs) if templates is None: templates = [] self.templates = templates self.custom_template = custom_template def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Structure instance and has the same templates, custom_template. """ return ( super(Structure, self).__eq__(other) and isinstance(other, Structure) and self.templates == other.templates and self.custom_template == other.custom_template ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Structure, self).__hash__() @validates("custom_template") def _validate_custom_template( self, key: str, custom_template: Union[None, str] ) -> str: """Validate the given custom_template value. Args: key (str): The name of the validated column. custom_template (Union[None, str]): The custom template value to be validated. Raises: TypeError: If the given custom_template value is not a str. Returns: str: The validated `custom_template` value. """ if custom_template is None: custom_template = "" if not isinstance(custom_template, str): raise TypeError( "{}.custom_template should be a string, not {}: '{}'".format( self.__class__.__name__, custom_template.__class__.__name__, custom_template, ) ) return custom_template @validates("templates") def _validate_templates( self, key: str, template: FilenameTemplate ) -> FilenameTemplate: """Validate the given template value. Args: key (str): The name of the validated column. template (FilenameTemplate): The validated template value. Raises: TypeError: If the given template value is not a FilenameTemplate instance. Returns: FilenameTemplate: Return the validated template value. """ if not isinstance(template, FilenameTemplate): raise TypeError( f"{self.__class__.__name__}.templates should only contain " "instances of stalker.models.template.FilenameTemplate, " f"not {template.__class__.__name__}: '{template}'" ) return template # Structure_FilenameTemplates Table Structure_FilenameTemplates = Table( "Structure_FilenameTemplates", Base.metadata, Column("structure_id", Integer, ForeignKey("Structures.id"), primary_key=True), Column( "filenametemplate_id", Integer, ForeignKey("FilenameTemplates.id"), primary_key=True, ), ) ================================================ FILE: src/stalker/models/studio.py ================================================ # -*- coding: utf-8 -*- """Studio, WorkingHours and Vacation related functions and classes are situated here.""" import copy import datetime import time from math import ceil from typing import Any, Dict, List, Optional, Union import pytz from sqlalchemy import ForeignKey, Interval, Text from sqlalchemy.orm import ( Mapped, mapped_column, reconstructor, relationship, synonym, validates, ) from stalker import defaults, log from stalker.db.session import DBSession from stalker.db.types import GenericDateTime, GenericJSON from stalker.models.auth import User from stalker.models.department import Department from stalker.models.entity import Entity, SimpleEntity from stalker.models.mixins import DateRangeMixin, WorkingHoursMixin from stalker.models.project import Project from stalker.models.schedulers import SchedulerBase from stalker.models.status import Status logger = log.get_logger(__name__) class Studio(Entity, DateRangeMixin, WorkingHoursMixin): """Manage all the studio information at once. With Stalker, you can manage all you Studio data by using this class. Studio knows all the projects, all the departments, all the users and every thing about the studio. But the most important part of the Studio is that it can schedule all the Projects by using TaskJuggler. Studio class is kind of the database itself:: studio = Studio() # simple data studio.projects studio.active_projects studio.inactive_projects studio.departments studio.users # project management studio.to_tjp # a tjp representation of the studio with all # its projects, departments and resources etc. studio.schedule() # schedules all the active projects at once **Working Hours** In Stalker, Studio class also manages the working hours of the studio. Allowing project tasks to be scheduled to be scheduled in those hours. **Vacations** Studio wide vacations are managed by the Studio class. **Scheduling** .. versionadded: 0.2.5 Schedule Info Attributes There are a couple of attributes those become pretty interesting when used together with the Studio instance while using the scheduling part of the Studio. Please refer to the attribute documentation for each attribute: :attr:`.is_scheduling` :attr:`.last_scheduled_at` :attr:`.last_scheduled_by` :attr:`.last_schedule_message` Args: daily_working_hours (int): An integer specifying the daily working hours for the studio. It is another critical value attribute which TaskJuggler uses mainly converting working day values to working hours (1d = 10h kind of thing). now (datetime.datetime): The now attribute overrides the TaskJugglers ``now`` attribute allowing the user to schedule the projects as if the scheduling is done on that date. The default value is the rounded value of datetime.datetime.now(pytz.utc). timing_resolution (datetime.timedelta): The timing_resolution of the datetime.datetime object in datetime.timedelta. Uses ``timing_resolution`` settings in the :class:`stalker.config.Config` class which defaults to 1 hour. Setting the timing_resolution to less then 5 minutes is not suggested because it is a limit for TaskJuggler. """ __auto_name__ = False __tablename__ = "Studios" __mapper_args__ = {"polymorphic_identity": "Studio"} studio_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) _timing_resolution: Mapped[Optional[datetime.timedelta]] = mapped_column( "timing_resolution", Interval ) is_scheduling: Mapped[Optional[bool]] = mapped_column(default=False) is_scheduling_by_id: Mapped[Optional[int]] = mapped_column( ForeignKey("Users.id"), doc="The id of the user who is scheduling the Studio projects right " "now", ) is_scheduling_by: Mapped[Optional[User]] = relationship( primaryjoin="Studios.c.is_scheduling_by_id==Users.c.id", doc="The User who is scheduling the Studio projects right now", ) scheduling_started_at: Mapped[Optional[datetime.datetime]] = mapped_column( GenericDateTime, doc="Stores when the current scheduling is started at, it is a good " "measure for measuring if the last schedule is not correctly " "finished", ) last_scheduled_at: Mapped[Optional[datetime.datetime]] = mapped_column( GenericDateTime, doc="Stores the last schedule date" ) last_scheduled_by_id: Mapped[Optional[int]] = mapped_column( ForeignKey("Users.id"), doc="The id of the user who has last scheduled the Studio projects", ) last_scheduled_by: Mapped[Optional[User]] = relationship( primaryjoin="Studios.c.last_scheduled_by_id==Users.c.id", doc="The User who has last scheduled the Studio projects", ) last_schedule_message: Mapped[Optional[str]] = mapped_column( Text, doc="Holds the last schedule message, generally coming generated by " "TaskJuggler", ) def __init__( self, daily_working_hours: Optional[int] = None, now: Optional[datetime.datetime] = None, timing_resolution: Optional[datetime.timedelta] = None, **kwargs: Dict[str, Any], ) -> None: super(Studio, self).__init__(**kwargs) DateRangeMixin.__init__(self, **kwargs) WorkingHoursMixin.__init__(self, **kwargs) self.timing_resolution = timing_resolution self.daily_working_hours = daily_working_hours self._now = None self.now = self._validate_now(now) self._scheduler = None # update defaults self.update_defaults() @property def daily_working_hours(self) -> int: """Return the Studio.working_hours.daily_working_hours. Returns: int: The daily working hours of this Studio. """ return self.working_hours.daily_working_hours @daily_working_hours.setter def daily_working_hours(self, daily_working_hours: int) -> None: """Set the Studio.working_hours.daily_working_hours. Args: daily_working_hours (int): The daily working hours in this studio. """ self.working_hours.daily_working_hours = daily_working_hours def update_defaults(self) -> None: """Update the default values with the studio.""" # TODO: add update_defaults() to attribute edit/update methods, # so we will always have an up to date info about the working # hours. logger.debug("updating defaults with Studio instance") logger.debug("defaults: {}".format(defaults)) logger.debug("id(defaults): {}".format(id(defaults))) defaults["daily_working_hours"] = self.daily_working_hours logger.debug( "updated defaults.daily_working_hours: {}".format( defaults.daily_working_hours ) ) defaults["weekly_working_days"] = self.weekly_working_days logger.debug( f"updated defaults.weekly_working_days: {defaults.weekly_working_days}" ) defaults["weekly_working_hours"] = self.weekly_working_hours logger.debug( "updated defaults.weekly_working_hours: {}".format( defaults.weekly_working_hours ) ) defaults["yearly_working_days"] = self.yearly_working_days logger.debug( f"updated defaults.yearly_working_days: {defaults.yearly_working_days}" ) defaults["timing_resolution"] = self.timing_resolution logger.debug( f"updated defaults.timing_resolution: {defaults.timing_resolution}" ) logger.debug( """done updating defaults: daily_working_hours : {daily_working_hours} weekly_working_days : {weekly_working_days} weekly_working_hours : {weekly_working_hours} yearly_working_days : {yearly_working_days} timing_resolution : {timing_resolution} """.format( daily_working_hours=defaults.daily_working_hours, weekly_working_days=defaults.weekly_working_days, weekly_working_hours=defaults.weekly_working_hours, yearly_working_days=defaults.yearly_working_days, timing_resolution=defaults.timing_resolution, ) ) @reconstructor def __init_on_load__(self) -> None: """Update defaults on load.""" self.update_defaults() def _validate_now(self, now: datetime.datetime) -> datetime.datetime: """Validate the given now value. Args: now (Union[None, datetime.datetime]): Either None in which the current date and time will be used or a datetime.datetime instance. Raises: TypeError: If the now value is not None and not a datetime.datetime instance. Returns: datetime.datetime: The validated datetime.datetime value. """ if now is None: now = datetime.datetime.now(pytz.utc) if not isinstance(now, datetime.datetime): raise TypeError( "{}.now attribute should be an instance of datetime.datetime, " "not {}: '{}'".format( self.__class__.__name__, now.__class__.__name__, now ) ) return self.round_time(now) @property def now(self) -> datetime.datetime: """Return the currently stored now value. Returns: datetime.datetime: Return the currently stored now value if there is any, return the current date and time otherwise. """ if self._now is None: self._now = self.round_time(datetime.datetime.now(pytz.utc)) return self._now @now.setter def now(self, now: datetime.datetime) -> None: """Set the current date and time. Args: now (datetime.datetime): The datetime.datetime instance showing the current date and time, useful for project management purposes before scheduling. """ self._now = self._validate_now(now) def _validate_scheduler( self, scheduler: Union[None, SchedulerBase] ) -> Union[None, SchedulerBase]: """Validate the given scheduler value. Args: scheduler (Union[None, SchedulerBase]): The scheduler to be used to schedule the projects in this Studio instance. Can be set to None to disable the scheduling abilities. Raises: TypeError: If the given scheduler value is not None and is not a SchedulerBase instance. Returns: Union[None, SchedulerBase]: The validated scheduler value. """ if scheduler is not None and not isinstance(scheduler, SchedulerBase): raise TypeError( "{}.scheduler should be an instance of " "stalker.models.scheduler.SchedulerBase, not {}: '{}'".format( self.__class__.__name__, scheduler.__class__.__name__, scheduler ) ) return scheduler @property def scheduler(self) -> Union[None, SchedulerBase]: """Return the scheduler. Returns: Union[None, SchedulerBase]: The scheduler of this Studio. """ return self._scheduler @scheduler.setter def scheduler(self, scheduler: Union[None, SchedulerBase]): """Set the scheduler. Args: scheduler (Union[None, SchedulerBase]): The SchedulerBase derivative as the scheduler. """ self._scheduler = self._validate_scheduler(scheduler) @property def to_tjp(self) -> str: """Convert the studio to a tjp representation. Returns: str: The TaskJuggler representation of this Studio. """ start = time.time() tab = " " indent = tab now = self.round_time(self.now).astimezone(pytz.utc).strftime("%Y-%m-%d-%H:%M") tjp = ( f'project {self.tjp_id} "{self.tjp_id}" ' f"{self.start.date()} - {self.end.date()} {{" ) timing_resolution = ( self.timing_resolution.days * 86400 + self.timing_resolution.seconds // 60 ) tjp += f"\n{indent}timingresolution {timing_resolution}min" tjp += f"\n{indent}now {now}" tjp += f"\n{indent}dailyworkinghours {self.daily_working_hours}" tjp += f"\n{indent}weekstartsmonday" # working hours tjp += "\n" tjp += "\n".join( f"{indent}{line}" for line in self.working_hours.to_tjp.split("\n") ) tjp += f'\n{indent}timeformat "%Y-%m-%d"' tjp += f'\n{indent}scenario plan "Plan"' tjp += f"\n{indent}trackingscenario plan" tjp += "\n}" end = time.time() logger.debug("render studio to tjp took: {:0.3f} seconds".format(end - start)) return tjp @property def projects(self) -> List[Project]: """Returns all the projects in the studio. Returns: List[Project]: List of all the Project instances in this Studio. """ return Project.query.all() @property def active_projects(self) -> List[Project]: """Return all the active projects in the studio. Returns: List[Project]: List of active Project instances in this studio. """ with DBSession.no_autoflush: wip = Status.query.filter_by(code="WIP").first() return Project.query.filter(Project.status == wip).all() @property def inactive_projects(self) -> List[Project]: """Return all the inactive projects in the studio. Returns: List[Project]: List of inactive Project instances in this studio. """ with DBSession.no_autoflush: wip = Status.query.filter_by(code="WIP").first() return Project.query.filter(Project.status != wip).all() @property def departments(self) -> List[Department]: """Return all the departments in the studio. Returns: List[Department]: The list of Department instances in this Studio. """ return Department.query.all() @property def users(self) -> List[User]: """Return all the users in the studio. Returns: List[User]: List of User instances in the studio. """ return User.query.all() @property def vacations(self) -> List["Vacation"]: """Return all Vacations which doesn't have a User defined. Returns: List[Vacation]: List of Vacation instances. """ return Vacation.query.filter(Vacation.user == None).all() # noqa: E711 def schedule(self, scheduled_by: Optional[User] = None) -> str: """Schedule all the active projects in the studio. Needs a Scheduler, so before calling it set a scheduler by using the :attr:`.scheduler` attribute. Args: scheduled_by (stalker.models.auth.User): A User instance who is doing the scheduling. Raises: RuntimeError: If the `self.scheduler` is None or it is not a `SchedulerBase` instance. Returns: str: The result of the scheduling process. """ # check the scheduler first if self.scheduler is None or not isinstance(self.scheduler, SchedulerBase): raise RuntimeError( "There is no scheduler for this {cls}, please assign a scheduler to " "the {cls}.scheduler attribute, before calling {cls}.schedule()".format( cls=self.__class__.__name__ ) ) with DBSession.no_autoflush: self.scheduling_started_at = datetime.datetime.now(pytz.utc) # run the scheduler self.scheduler.studio = self start = time.time() # commit before scheduling # DBSession.commit() result = None try: result = self.scheduler.schedule() finally: # in any case set is_scheduling to False with DBSession.no_autoflush: self.is_scheduling = False self.is_scheduling_by = None # also store the result # if result: self.last_schedule_message = result # And the date the schedule is completed self.last_scheduled_at = datetime.datetime.now(pytz.utc) # and who has done the scheduling if scheduled_by: logger.debug(f"setting last_scheduled_by to : {scheduled_by}") self.last_scheduled_by = scheduled_by end = time.time() logger.debug("scheduling took {:0.3f} seconds".format(end - start)) return result @property def weekly_working_hours(self) -> int: """Return the WorkingHours.weekly_working_hours value. Returns: int: The weekly working hours value stored in the working hours configuration of this Studio instance. """ return self.working_hours.weekly_working_hours @property def weekly_working_days(self) -> int: """Return the WorkingHours.weekly_working_hours value. Returns: int: The weekly working days value stored in the working hours configuration of this Studio instance. """ return self.working_hours.weekly_working_days @property def yearly_working_days(self) -> int: """Return the WorkingHours.yearly_working_days value. Returns: int: The yearly working days in the working hours configuration of this Studio instance. """ return self.working_hours.yearly_working_days def to_unit( self, from_timing: int, from_unit: str, to_unit: str, working_hours: bool = True, ) -> int: """Convert the given timing and unit to the desired unit. If working_hours=True then the given timing is considered as working hours. Args: from_timing (int): The timing value. from_unit (str): The timing unit. to_unit (str): The other timing unit to convert the given timing unit to. working_hours (bool): True to consider the given from timing as a working hour. Default is True. Raises: NotImplementedError: Unless it is implemented. """ raise NotImplementedError("this is not implemented yet") def _timing_resolution_getter(self) -> datetime.timedelta: """Return the timing_resolution value. Returns: datetime.timedelta: The timing resolution stored in this Studio instance. """ return self._timing_resolution def _timing_resolution_setter(self, timing_resolution: datetime.timedelta) -> None: """Set the timing_resolution. Args: timing_resolution (datetime.timedelta): The `timing_resolution` instance to validate. """ self._timing_resolution = self._validate_timing_resolution(timing_resolution) logger.debug(f"self._timing_resolution: {self._timing_resolution}") # update date values if self.start and self.end and self.duration: self._start, self._end, self._duration = self._validate_dates( self.round_time(self.start), self.round_time(self.end), None ) timing_resolution: Mapped[Optional[datetime.timedelta]] = synonym( "_timing_resolution", descriptor=property( _timing_resolution_getter, _timing_resolution_setter, doc="""The timing_resolution of this object. Can be set to any value that is representable with datetime.timedelta. The default value is 1 hour. Whenever it is changed the start, end and duration values will be updated. """, ), ) def _validate_timing_resolution( self, timing_resolution: datetime.timedelta ) -> datetime.timedelta: """Validate the given timing_resolution value. Args: timing_resolution (datetime.timedelta): The timing resolution value as a `datetime.timedelta` instance. Raises: TypeError: If the given `timing_resolution` is not a `datetime.timedelta` instance. Returns: datetime.timedelta: The validated timing resolution instance. """ if timing_resolution is None: timing_resolution = defaults.timing_resolution if not isinstance(timing_resolution, datetime.timedelta): raise TypeError( "{}.timing_resolution should be an instance of " "datetime.timedelta, not {}: '{}'".format( self.__class__.__name__, timing_resolution.__class__.__name__, timing_resolution, ) ) return timing_resolution class WorkingHours(Entity): """A helper class to manage Studio working hours. Working hours is a data class to store the weekly working hours pattern of the studio. The data stored as a dictionary with the short day names are used as the key and the value is a list of two integers showing the working hours interval as the minutes after midnight. This is done in that way to ease the data transfer to TaskJuggler. The default value is defined in :class:`stalker.config.Config` :: wh = WorkingHours() wh.working_hours = { 'mon': [[540, 720], [820, 1080]], # 9:00 - 12:00, 13:00 - 18:00 'tue': [[540, 720], [820, 1080]], # 9:00 - 12:00, 13:00 - 18:00 'wed': [[540, 720], [820, 1080]], # 9:00 - 12:00, 13:00 - 18:00 'thu': [[540, 720], [820, 1080]], # 9:00 - 12:00, 13:00 - 18:00 'fri': [[540, 720], [820, 1080]], # 9:00 - 12:00, 13:00 - 18:00 'sat': [], # saturday off 'sun': [], # sunday off } The default value is 9:00 - 18:00 from Monday to Friday and Saturday and Sunday are off. The working hours can be updated by the user supplied dictionary. If the user supplied dictionary doesn't have all the days then the default values will be used for those days. It is possible to use day index and day short names as a key value to reach the data:: from stalker import config defaults = config.Config() wh = WorkingHours() # this is same by doing wh.working_hours['sun'] assert wh['sun'] == defaults.working_hours['sun'] # you can reach the data using the weekday number as index assert wh[0] == defaults.working_hours['mon'] # working hours of sunday if defaults are used or any other day defined # by the stalker.config.Config.day_order assert wh[0] == defaults.working_hours[defaults.day_order[0]] Args: working_hours (Union[None, dict]): The dictionary that shows the working hours. The keys of the dictionary should be one of ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']. And the values should be a list of two integers like [[int, int], [int, int], ...] format, showing the minutes after midnight. For missing days the default value will be used. If skipped the default value is going to be used. daily_working_hours (Union[None, int]): The daily working hours value. If given None the default value will be used. """ __auto_name__ = True __tablename__ = "WorkingHours" __mapper_args__ = {"polymorphic_identity": "WorkingHours"} working_hours_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) working_hours: Mapped[Optional[Dict[str, List]]] = mapped_column(GenericJSON) daily_working_hours: Mapped[Optional[int]] = mapped_column( default=defaults.daily_working_hours ) def __init__( self, working_hours: Optional[Dict[str, List]] = None, daily_working_hours=None, **kwargs: Dict[str, Any], ) -> None: super(WorkingHours, self).__init__(**kwargs) if working_hours is None: working_hours = defaults.working_hours self.working_hours = working_hours self.daily_working_hours = daily_working_hours def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a WorkingHours instance and has the same working_hours. """ return ( isinstance(other, WorkingHours) and other.working_hours == self.working_hours ) def __getitem__(self, index: Union[int, str]) -> Optional[List]: """Return the item at the given index. Args: index (Union[int, str]): Either an integer representing the weekday starting from Monday:0 or a string value of a shorthand day name one of ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]. Returns: List[int, int]: The daily working hour arranged in a list where the first item is the minute from the midnight of the start of the working hour and the second item is the minute from the midnight of the end of the daily working hour. As in [540, 1080] represents 9am to 6pm. """ if isinstance(index, int): return self.working_hours[defaults.day_order[index]] elif isinstance(index, str): return self.working_hours[index] def __setitem__(self, key: Union[int, str], value: List[List]) -> None: """Set the item value at the given index. Args: key (Union[int, str]): The index to set the value of or the day name. value (List[List[int, int]]): The working hours data arranged in a list of lists of two integers. Raises: KeyError: If the given key value is not one of the day names. """ self._validate_working_hours_value(value) if isinstance(key, int): self.working_hours[defaults.day_order[key]] = value elif isinstance(key, str): # check if key is in if key not in defaults.day_order: raise KeyError( "{} accepts only {} as key, not '{}'".format( self.__class__.__name__, defaults.day_order, key ) ) self.working_hours[key] = value @validates("working_hours") def _validate_working_hours(self, key: str, working_hours: Dict[str, List]) -> dict: """Validate the given working hours value. Args: key (str): The name of the validated column. working_hours (dict): The working hours value to be validated. Raises: TypeError: If the given working hours is not a dictionary. TypeError: If the values in the working hours dictionary are not lists. Returns: dict: The validated working hours dictionary. """ if not isinstance(working_hours, dict): raise TypeError( "{}.working_hours should be a dictionary, not {}: '{}'".format( self.__class__.__name__, working_hours.__class__.__name__, working_hours, ) ) for day in working_hours: if not isinstance(working_hours[day], list): raise TypeError( '{}.working_hours should be a dictionary with keys "mon, ' 'tue, wed, thu, fri, sat, sun" and the values should a ' "list of lists of two integers like [[540, 720], [800, " "1080]], not {}: '{}'".format( self.__class__.__name__, working_hours[day].__class__.__name__, working_hours[day], ) ) # validate item values self._validate_working_hours_value(working_hours[day]) # update the default values with the supplied working_hour dictionary # copy the defaults wh_def = copy.copy(defaults.working_hours) # update them wh_def.update(working_hours) return wh_def def is_working_hour(self, check_for_date: datetime.datetime) -> bool: """Check if the given datetime is in working hours. Args: check_for_date (datetime.datetime): The time value to check if it is a working hour. Returns: bool: True if the given datetime coincides to a working hour, False otherwise. """ weekday_nr = check_for_date.weekday() hour = check_for_date.hour minute = check_for_date.minute time_from_midnight = hour * 60 + minute # check if the hour is inside the working hour ranges logger.debug(f"checking for: {time_from_midnight}") logger.debug(f"self[weekday_nr]: {self[weekday_nr]}") for working_hour_groups in self[weekday_nr]: start = working_hour_groups[0] end = working_hour_groups[1] logger.debug(f"start : {start}") logger.debug(f"end : {end}") if start <= time_from_midnight < end: return True return False def _validate_working_hours_value(self, value: List) -> List: """Validate the working hour value. The given value should follow the following format: .. code-block:: python working_hours = [ [540, 1080], # Working hour in minutes for Monday [540, 1080], # Working hour in minutes for Tuesday [540, 1080], # Working hour in minutes for Wednesday [540, 1080], # Working hour in minutes for Thursday [540, 1080], # Working hour in minutes for Friday [0, 0], # Working hour in minutes for Saturday [0, 0], # Working hour in minutes for Sunday ] Args: value (List): The validated working hour value. Raises: TypeError: If the given value is not a list. TypeError: If the immediate items in the list is not a list. TypeError: If the length of the items in the given list is not 2. TypeError: If the items in the lists inside the list are not integers. ValueError: If the integer values in the secondary lists are smaller than 0 or larger than 1440 (which is 24 * 60). Returns: List[List[int, int]] """ err = ( "{}.working_hours value should be a list of lists of two " "integers and the range of integers should be between 0-1440, " "not {}: '{}'".format( self.__class__.__name__, value.__class__.__name__, value ) ) if not isinstance(value, list): raise TypeError(err) for i in value: if not isinstance(i, list): raise TypeError(err) # check list length if len(i) != 2: raise ValueError(err) # check type for j in range(2): if not isinstance(i[j], int): raise TypeError(err) # check range if i[j] < 0 or i[j] > 1440: raise ValueError(err) return value @property def to_tjp(self) -> str: """Return TaskJuggler representation of this object. Returns: str: The TaskJuggler representation. """ tjp = "" for i, day in enumerate(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]): if i != 0: tjp += "\n" tjp += f"workinghours {day} " if self[day]: for i, part in enumerate(self[day]): start_hour, end_hour = part if i != 0: tjp += ", " tjp += ( f"{start_hour // 60:02d}:{start_hour % 60:02d} - " f"{end_hour // 60:02d}:{end_hour % 60:02d}" ) else: tjp += "off" return tjp @property def weekly_working_hours(self) -> int: """Return the total working hours in a week. Returns: int: The calculated weekly working hours. """ weekly_working_hours = 0 for i in range(0, 7): for start, end in self[i]: weekly_working_hours += end - start return weekly_working_hours / 60.0 @property def weekly_working_days(self) -> int: """Return the weekly working days by looking at the working hours settings. Returns: int: The weekly working days value. """ wwd = 0 for i in range(0, 7): if len(self[i]): wwd += 1 return wwd @property def yearly_working_days(self) -> int: """Return the total working days in a year. Returns: int: The calculated yearly_working_days value. """ return int(ceil(self.weekly_working_days * 52.1428)) @validates("daily_working_hours") def _validate_daily_working_hours(self, key: str, daily_working_hours: int) -> int: """Validate the given daily working hours value. Args: key (str): The name of the validated column. daily_working_hours (int): The daily working hours to be validated. Raises: TypeError: If the `daily_working_hours` value is not an integer. ValueError: If the `daily_working_hours` is smaller thane 0 or bigger than 24. Returns: int: The validated daily working hours value. """ if daily_working_hours is None: daily_working_hours = defaults.daily_working_hours if not isinstance(daily_working_hours, int): raise TypeError( "{}.daily_working_hours should be an integer, not {}: '{}'".format( self.__class__.__name__, daily_working_hours.__class__.__name__, daily_working_hours, ) ) if daily_working_hours <= 0 or daily_working_hours > 24: raise ValueError( f"{self.__class__.__name__}.daily_working_hours should be a positive " "integer value greater than 0 and smaller than or equal to 24" ) return daily_working_hours def split_in_to_working_hours( self, start: datetime.datetime, end: datetime.datetime ) -> List[datetime.datetime]: """Split the given start and end datetime objects in to working hours. Args: start (datetime.datetime): The start date and time. end (datetime.datetime): The end date and time. Raises: NotImplementedError: Unless this is implemented. """ raise NotImplementedError() class Vacation(SimpleEntity, DateRangeMixin): """Vacation is the way to manage the User vacations. Args: user (User): The user of this vacation. Should be an instance of :class:`.User` if skipped or given as None the Vacation is considered as a Studio vacation and applies to all Users. start (datetime.datetime): The start datetime of the vacation. Is is an datetime.datetime instance. When skipped it will be set to the rounded value of. end (datetime.datetime): The end datetime of the vacation. It is an datetime.datetime instance. """ __auto_name__ = True __tablename__ = "Vacations" __mapper_args__ = {"polymorphic_identity": "Vacation"} __strictly_typed__ = False vacation_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) user_id: Mapped[Optional[int]] = mapped_column("user_id", ForeignKey("Users.id")) user: Mapped[User] = relationship( primaryjoin="Vacations.c.user_id==Users.c.id", back_populates="vacations", doc="""The User of this Vacation. Accepts :class:`.User` instance. """, ) def __init__( self, user: Optional[User] = None, start: Optional[datetime.datetime] = None, end: Optional[datetime.datetime] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["start"] = start kwargs["end"] = end super(Vacation, self).__init__(**kwargs) DateRangeMixin.__init__(self, **kwargs) self.user = user @validates("user") def _validate_user(self, key: str, user: User) -> User: """Validate the given user instance. Args: key (str): The name of the validated column. user (User): The user value to be validated. Raises: TypeError: If the user value is not None and not a :class:`stalker.models.auth.User` instance. Returns: User: The validated user value. """ if user is not None and not isinstance(user, User): raise TypeError( "{}.user should be an instance of stalker.models.auth.User, " "not {}: '{}'".format( self.__class__.__name__, user.__class__.__name__, user ) ) return user @property def to_tjp(self) -> str: """Override the to_tjp method. Returns: str: The rendered tjp template. """ tjp = "vacation " tjp += f"{self.start.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M:%S')} - " tjp += f"{self.end.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M:%S')}" return tjp ================================================ FILE: src/stalker/models/tag.py ================================================ # -*- coding: utf-8 -*- """Tag related functions and classes are situated here.""" from typing import Any, Dict from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker.log import get_logger from stalker.models.entity import SimpleEntity logger = get_logger(__name__) class Tag(SimpleEntity): """Use it to create tags for any object available in SOM. Doesn't have any other attribute than what is inherited from :class:`.SimpleEntity` """ __auto_name__ = False __tablename__ = "Tags" __mapper_args__ = {"polymorphic_identity": "Tag"} tag_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs: Dict[str, Any]) -> None: super(Tag, self).__init__(**kwargs) def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Tag instance and has the same attributes. """ return super(Tag, self).__eq__(other) and isinstance(other, Tag) def __hash__(self) -> int: """Return the hash value for this Tag instance. Returns: int: The hash of this Tag. """ return super(Tag, self).__hash__() ================================================ FILE: src/stalker/models/task.py ================================================ # -*- coding: utf-8 -*- """Task related functions and classes are situated here.""" import copy import datetime import os from typing import Any, Dict, Generator, List, Optional, TYPE_CHECKING, Union from jinja2 import Template import pytz import sqlalchemy from sqlalchemy import ( CheckConstraint, Column, DDL, Enum, ForeignKey, Integer, Table, event, text, ) from sqlalchemy.exc import ( InternalError, InvalidRequestError, OperationalError, ProgrammingError, UnboundExecutionError, ) from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import ( Mapped, mapped_column, reconstructor, relationship, synonym, validates, ) from sqlalchemy.orm.attributes import AttributeEvent from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.exceptions import ( CircularDependencyError, DependencyViolationError, OverBookedError, StatusError, ) from stalker.log import get_logger from stalker.models.auth import User from stalker.models.budget import Good from stalker.models.entity import Entity from stalker.models.enum import ( DependencyTarget, DependencyTargetDecorator, ScheduleConstraint, ScheduleModel, TimeUnit, TimeUnitDecorator, TraversalDirection, ) from stalker.models.mixins import ( DAGMixin, DateRangeMixin, ReferenceMixin, ScheduleMixin, StatusMixin, ) from stalker.models.review import Review from stalker.models.status import Status from stalker.models.ticket import Ticket from stalker.utils import check_circular_dependency, walk_hierarchy if TYPE_CHECKING: # pragma: no cover from stalker.models.project import Project from stalker.models.version import Version logger = get_logger(__name__) BINARY_STATUS_VALUES = { "WFD": 0b100000000, "RTS": 0b010000000, "WIP": 0b001000000, "PREV": 0b000100000, "HREV": 0b000010000, "DREV": 0b000001000, "OH": 0b000000100, "STOP": 0b000000010, "CMPL": 0b000000001, } """ +--------- WFD |+-------- RTS ||+------- WIP |||+------ PREV ||||+----- HREV |||||+---- DREV ||||||+--- OH |||||||+-- STOP ||||||||+- CMPL ||||||||| 0b000000000 """ # noqa: SC100 CHILDREN_TO_PARENT_STATUSES_MAP = { 0b000000000: 0, 0b000000001: 3, 0b000000010: 3, 0b000000011: 3, 0b010000000: 1, 0b010000010: 1, 0b100000000: 0, 0b100000010: 0, 0b110000000: 1, 0b110000010: 1, } """Although the dictionary seems cryptic, it shows the final status index in parent_statuses_map[] list. So by using the cumulative statuses of children we got an index from the following table, and use the found element (integer) as the index for the parent_statuses_map[] list, and we find the desired status. We are doing it in this way for a couple of reasons: 1. We shouldn't hold the statuses in the following list, 2. We are using a sparse dictionary which is more efficient than storing all the data in a single list. """ class TimeLog(Entity, DateRangeMixin): """Time entry for the time spent on a :class:`.Task` by a specific :class:`.User`. It is so important to note that the TimeLog reports the **uninterrupted** time interval that is spent for a Task. Thus it doesn't care about the working time attributes like daily working hours, weekly working days or anything else. Again it is the uninterrupted time which is spent for a task. Entering a time log for 2 days will book the resource for 48 hours and not, 2 * daily working hours. TimeLogs are created per resource. It means, you need to record all the works separately for each resource. So there is only one resource in a TimeLog instance. A :class:`.TimeLog` instance needs to be initialized with a :class:`.Task` and a :class:`.User` instances. Adding overlapping time log for a :class:`.User` will raise a :class:`.OverBookedError`. .. :: TimeLog instances automatically extends the :attr:`.Task.schedule_timing` of the assigned Task if the :attr:`.Task.total_logged_seconds` is getting bigger than the :attr:`.Task.schedule_timing` after this TimeLog. Args: task (Task): The :class:`.Task` instance that this time log belongs to. resource (User): The :class:`.User` instance that this time log is created for. """ __auto_name__ = True __tablename__ = "TimeLogs" __mapper_args__ = {"polymorphic_identity": "TimeLog"} __table_args__ = ( CheckConstraint('"end" > start'), # this will be ignored in SQLite3 ) time_log_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) task_id: Mapped[int] = mapped_column( ForeignKey("Tasks.id"), nullable=False, doc="""The id of the related task.""", ) task: Mapped["Task"] = relationship( primaryjoin="TimeLogs.c.task_id==Tasks.c.id", uselist=False, back_populates="time_logs", doc="""The :class:`.Task` instance that this time log is created for""", ) resource_id: Mapped[int] = mapped_column( ForeignKey("Users.id"), nullable=False, ) resource: Mapped[User] = relationship( primaryjoin="TimeLogs.c.resource_id==Users.c.id", uselist=False, back_populates="time_logs", doc="""The :class:`.User` instance that this time_log is created for""", ) def __init__( self, task: Optional["Task"] = None, resource: Optional[User] = None, start: Optional[datetime.datetime] = None, end: Optional[datetime.datetime] = None, duration: Optional[datetime.timedelta] = None, **kwargs: Dict[str, Any], ) -> None: super(TimeLog, self).__init__(**kwargs) kwargs["start"] = start kwargs["end"] = end kwargs["duration"] = duration DateRangeMixin.__init__(self, **kwargs) self.task = task self.resource = resource @validates("task") def _validate_task(self, key: str, task: "Task") -> "Task": """Validate the given task value. Args: key (str): The name of the validated column. task (Task): The Task instance to be validated. Raises: TypeError: If the given task is not a Task instance. ValueError: If this is a container task. StatusError: If the Task.status is one of [WDF, OH, STOP, CMPL] where it is not allowed to entry any further TimeLog information. DependencyViolationError: If this TimeLog overlaps with one of the dependencies start and end time, essentially forcing this Task to start or end before its dependencies start or end. Returns: Task: The validated task value. """ if not isinstance(task, Task): raise TypeError( "{}.task should be an instance of stalker.models.task.Task, " "not {}: '{}'".format( self.__class__.__name__, task.__class__.__name__, task ) ) if task.is_container: raise ValueError( f"{task.name} (id: {task.id}) is a container task, and it is not " "allowed to create TimeLogs for a container task" ) # check status logger.debug("checking task status!") with DBSession.no_autoflush: task_status_list = task.status_list WFD = task_status_list["WFD"] RTS = task_status_list["RTS"] WIP = task_status_list["WIP"] # PREV = task_status_list["PREV"] HREV = task_status_list["HREV"] # DREV = task_status_list["DREV"] OH = task_status_list["OH"] STOP = task_status_list["STOP"] CMPL = task_status_list["CMPL"] if task.status in [WFD, OH, STOP, CMPL]: raise StatusError( f"{task.name} is a {task.status.code} task, and it is not allowed " f"to create TimeLogs for a {task.status.code} task, please supply " "a RTS, WIP, HREV or DREV task!" ) elif task.status in [RTS, HREV]: # update task status logger.debug("updating task status to WIP!") task.status = WIP # check dependent tasks logger.debug("checking dependent task statuses") for task_dependencies in task.task_depends_on: dep_task = task_dependencies.depends_on dependency_target = task_dependencies.dependency_target raise_violation_error = False violation_date = None if dependency_target == DependencyTarget.OnEnd: # time log cannot start before the end date of this task if self.start < dep_task.end: raise_violation_error = True violation_date = dep_task.end elif dependency_target == DependencyTarget.OnStart: if self.start < dep_task.start: raise_violation_error = True violation_date = dep_task.start if raise_violation_error: raise DependencyViolationError( "It is not possible to create a TimeLog before " f"{violation_date}, which violates the dependency relation of " f'"{task.name}" to "{dep_task.name}"' ) # this may need to be in an external event, it needs to trigger a flush # to correctly function task.update_parent_statuses() return task @validates("resource") def _validate_resource(self, key: str, resource: User) -> User: """Validate the given resource value. Args: key (str): The name of the validated column. resource (User): The User instance as the resource of this TimeLog. raises: TypeError: If the resource is None or is not a User instance. OverBookedError: If the resource has already a clashing TimeLog. Returns: User: The validated User instance. """ if resource is None: raise TypeError(f"{self.__class__.__name__}.resource cannot be None") if not isinstance(resource, User): raise TypeError( "{}.resource should be a stalker.models.auth.User instance, " "not {}: '{}'".format( self.__class__.__name__, resource.__class__.__name__, resource ) ) # check for overbooking clashing_time_log_data = None with DBSession.no_autoflush: try: from sqlalchemy import or_, and_ clashing_time_log_data = ( DBSession.query(TimeLog.start, TimeLog.end) .filter(TimeLog.id != self.id) .filter(TimeLog.resource_id == resource.id) .filter( or_( and_(TimeLog.start <= self.start, self.start < TimeLog.end), and_(TimeLog.start < self.end, self.end <= TimeLog.end), ) ) .first() ) except (UnboundExecutionError, OperationalError): # fallback to Python for time_log in resource.time_logs: if time_log == self: continue if ( time_log.start == self.start or time_log.end == self.end or time_log.start < self.end < time_log.end or time_log.start < self.start < time_log.end ): clashing_time_log_data = [time_log.start, time_log.end] break if clashing_time_log_data: import tzlocal local_tz = tzlocal.get_localzone() raise OverBookedError( "The resource has another TimeLog between {} and {}".format( clashing_time_log_data[0].astimezone(local_tz), clashing_time_log_data[1].astimezone(local_tz), ) ) return resource def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a TimeLog instance and has the same task, resource, start, end and name. """ return ( isinstance(other, TimeLog) and self.task is other.task and self.resource is other.resource and self.start == other.start and self.end == other.end and self.name == other.name ) # TODO: Consider contracting a Task with TimeLogs, what will happen when the # task has logged in time # TODO: Check, what happens when a task has TimeLogs and will have child task # later on, will it be ok with TJ class Task( Entity, StatusMixin, DateRangeMixin, ReferenceMixin, ScheduleMixin, DAGMixin ): """Manages Task related data. **Introduction** Tasks are the smallest unit of work that should be accomplished to complete a :class:`.Project`. Tasks define a certain amount of time needed to be spent for a purpose. They also define a complex hierarchy of relation. Stalker follows and enhances the concepts stated in TaskJuggler_. .. _TaskJuggler : http://www.taskjuggler.org/ .. note:: .. versionadded:: 0.2.0 References in Tasks Tasks can now have References. **Initialization** Tasks are a part of a bigger Project, that's way a Task needs to be created with a :class:`.Project` instance. It is possible to create a task without a project, if it is created to be a child of another task. And it is also possible to pass both a project and a parent task. But because passing both a project and a parent task may create an ambiguity, Stalker will raise a RuntimeWarning, if both project and task are given and the owner project of the given parent task is different then the supplied project instance. But again Stalker will warn the user but will continue to use the task as the parent and will correctly use the project of the given task as the project of the newly created task. The following codes are a couple of examples for creating Task instances:: # with a project instance >>> from stalker import Project >>> project1 = Project(name='Test Project 1') # simplified >>> task1 = Task(name='Schedule', project=project1) # with a parent task >>> task2 = Task(name='Documentation', parent=task1) # or both >>> task3 = Task(name='Test', project=project1, parent=task1) # this will create a RuntimeWarning >>> project2 = Project(name='Test Project 2') >>> task4 = Task(name='Test', project=project2, parent=task1) # task1 is not a # task of proj2 >>> assert task4.project == project1 # Stalker uses the task1.project for task4 # this will also create a RuntimeError >>> task3 = Task(name='Failure 2') # no project no parent, this is an # orphan task. Also initially Stalker will pin point the :attr:`.start` value and then will calculate proper :attr:`.end` and :attr:`.duration` values by using the :attr:`.schedule_timing` and :attr:`.schedule_unit` attributes. But these values (start, end and duration) are temporary values for an unscheduled task. The final date values will be calculated by TaskJuggler in the `auto scheduling` phase. **Auto Scheduling** Stalker uses TaskJuggler for task scheduling. After defining all the tasks, Stalker will convert them to a single tjp file along with the recorded :class:`.TimeLog` s :class:`.Vacation` s etc. and let TaskJuggler to solve the scheduling problem. During the auto scheduling (with TaskJuggler), the calculation of task duration, start and end dates are effected by the working hours setting of the :class:`.Studio`, the effort that needs to spend for that task and the availability of the resources assigned to the task. A good practice for creating a project plan is to supply the parent/child and dependency relation between tasks and the effort and resource information per task and leave the start and end date calculation to TaskJuggler. The default :attr:`.schedule_model` for Stalker tasks is `ScheduleModel.Effort`, the default :attr:`TimeUnit.Hour` and the default value of :attr:`.schedule_timing` is defined by the :attr:`stalker.config.Config.timing_resolution`. So for a config where the ``timing_resolution`` is set to 1 hour the schedule_timing is 1. It is also possible to use :attr:`.ScheduleModel.Length`` or :attr:`.ScheduleModel.duration` values for :attr:`.schedule_model` (set it to :attr:`ScheduleModel.Effort`, :attr:`.ScheduleModel.Length` or :attr:`.ScheduleModel.Duration` to get the desired scheduling model). To convert a Task instance to a TaskJuggler compatible string use the :attr:`.to_tjp`` attribute. It will try to create a good representation of the Task by using the resources, schedule_model, schedule_timing and schedule_constraint attributes. ** Alternative Resources** .. versionadded:: 0.2.5 Alternative Resources Stalker now supports alternative resources per task. You can specify alternative resources by using the :attr:`.alternative_resources` attribute. The number of resources and the number of alternative resources are not related. So you can define only 1 resource and more than one alternative resources, or you can define 2 resources and only one alternative resource. .. warning:: As opposed to TaskJuggler alternative resources are not per resource based. So Stalker will use the alternatives list for all of the resources in the resources list. Per resource alternative will be supported in future versions of Stalker. Stalker will pass the data to TaskJuggler and TJ will compute a list of resources that are assigned to the task in the report time frame and Stalker will store the resultant list of users in :attr:`.computed_resources` attribute. .. warning:: When TaskJuggler computes the resources, the returned list may contain resources which are not in the :attr:`.resources` nor in :attr:`.alternative_resources` list anymore. Stalker will silently filter those resources and will only store resources (in :attr:`.computed_resources`) those are still available as a direct or alternative resource to that particular task. The selection strategy of the alternative resource is defined by the :attr:`.allocation_strategy` attribute. The `allocation_strategy` attribute value should be one of [minallocated, maxloaded, minloaded, order, random]. The following description is from TaskJuggler documentation: +--------------+--------------------------------------------------------+ | minallocated | Pick the resource that has the smallest allocation | | | factor. The allocation factor is calculated from the | | | various allocations of the resource across the tasks. | | | This is the default setting.) | +--------------+--------------------------------------------------------+ | maxloaded | Pick the available resource that has been used the | | | most so far. | +--------------+--------------------------------------------------------+ | minloaded | Pick the available resource that has been used the | | | least so far. | +--------------+--------------------------------------------------------+ | order | Pick the first available resource from the list. | +--------------+--------------------------------------------------------+ | random | Pick a random resource from the list. | +--------------+--------------------------------------------------------+ As in TaskJuggler the default for :attr:`.allocation_strategy` attribute is "minallocated". Also the allocation of the resources are effected by the :attr:`.persistent_allocation` attribute. The persistent_allocation attribute refers to the ``persistent`` attribute in TJ. The documentation of ``persistent`` in TJ is as follows: Specifies that once a resource is picked from the list of alternatives this resource is used for the whole task. This is useful when several alternative resources have been specified. Normally the selected resource can change after each break. A break is an interval of at least one time slot where no resources were available. :attr:`.persistent_allocation` attribute is True by default. For a not yet scheduled task the :attr:`.computed_resources` attribute will be the same as the :attr:`.resources` list. After the task is scheduled the content of the :attr:`.computed_resources` will purely come from TJ. Updating the resources list will not update the :attr:`.computed_resources` list if the task :attr:`.is_scheduled`. **Task to Task Relation** .. note:: .. versionadded:: 0.2.0 Task to Task Relation Tasks can have child Tasks. So you can create complex relations of Tasks to comply with your project needs. A Task is called a ``container task`` if it has at least one child Task. And it is called a ``leaf task`` if it doesn't have any children Tasks. Tasks which doesn't have a parent called ``root_task``. As opposed to TaskJuggler where the resource information is passed through parent to child, in Stalker the resources in a container task is meaningless, cause the resources are defined by the child tasks. Although the values are not very important after TaskJuggler schedules a task, the :attr:`~.start` and :attr:`~.end` values for a container task is gathered from the child tasks. The start is equal to the earliest start value of the children tasks, and the end is equal to the latest end value of the children tasks. Of course, these values are going to be ignored by TaskJuggler, but for interactive gantt charts these are good toy attributes to play with. Stalker will check if there will be a cycle if one wants to parent a Task to a child Task of its own or the dependency relation creates a cycle. In Gantt Charts the ``computed_start``, ``computed_end`` and ``computed_resources`` attributes will be used if the task :attr:`.is_scheduled`. **Task Responsible** .. note:: .. versionadded:: 0.2.0 Task Responsible .. note:: .. versionadded:: 0.2.5 Multiple Responsible Per Task Tasks have a **responsible** which is a list of :class:`.User` instances who are responsible of the assigned task and all the hierarchy under it. If a task doesn't have any responsible, it will start looking to its parent tasks and will return a copy of the responsible of its parent and it will be an empty list if non of its parents has a responsible. You can create complex responsibility chains for different branches of Tasks. **Percent Complete Calculation** .. versionadded:: 0.2.0 Tasks can calculate how much it is completed based on the :attr:`.schedule_seconds` and :attr:`.total_logged_seconds` attributes. For a parent task, the calculation is based on the total :attr:`.schedule_seconds` and :attr:`.total_logged_seconds` attributes of their children. .. versionadded:: 0.2.14 Because duration tasks do not need time logs there is no way to calculate the percent complete by using the time logs. And Percent Complete on a duration task is calculated directly from the :attr:`.start` and :attr:`.end` and ``datetime.datetime.now(pytz.utc)``. .. versionadded:: 0.2.26 For parent tasks that have both effort based and duration based children tasks the percent complete is calculated as if the :attr:`.total_logged_seconds` is properly filled for duration based tasks proportional to the elapsed time from the :attr:`.start` attr value. Even tough, the percent_complete attribute of a task is 100% the task may not be considered as completed, because it may not be reviewed and approved by the responsible yet. **Task Review Workflow** .. versionadded:: 0.2.5 Task Review Workflow Starting with Stalker v0.2.5 tasks are reviewed by their responsible users. The reviews done by responsible users will set the task status according to the supplied reviews. Please see the :class:`.Review` class documentation for more details. **Task Status Workflow** .. note:: .. versionadded:: 0.2.5 Task Status Workflow Task statuses now follow a workflow called "Task Status Workflow". The "Task Status Workflow" defines the different statuses that a Task will have along its normal life cycle. Container and leaf tasks will have different workflow using nearly the same set of statuses (container tasks have only 4 statuses where as leaf tasks have 9). The following diagram shows the status workflow for leaf tasks: .. image:: ../../../docs/source/_static/images/Task_Status_Workflow.png :width: 637 px :height: 611 px :align: center The workflow defines the following statuses at described situations: +-----------------------------------------------------------------------+ | LEAF TASK STATUS WORKFLOW | +------------------+----------------------------------------------------+ | Status Name | Description | +------------------+----------------------------------------------------+ | Waiting For | If a task has uncompleted dependencies then it | | Dependency (WFD) | will have its status to set to WFD. A WFD Task can | | | not have a TimeLog or a review request cannot be | | | made for it. | +------------------+----------------------------------------------------+ | Ready To Start | A task is set to RTS when there are no | | (RTS) | dependencies or all of its dependencies are | | | completed, so there is nothing preventing it to be | | | started. An RTS Task can have new TimeLogs. A | | | review cannot be requested at this stage cause no | | | work is done yet. | +------------------+----------------------------------------------------+ | Work In Progress | A task is set to WIP when a TimeLog has been | | (WIP) | created for that task. A WIP task can have new | | | TimeLogs and a review can be requested for that | | | task. | +------------------+----------------------------------------------------+ | Pending Review | A task is set to PREV when a new set of Review | | (PREV) | instances created for it by using the | | | :meth:`.Task.request_review` method. And it is | | | possible to request a review only for a task with | | | status WIP. A PREV task cannot have new TimeLogs | | | nor a new request can be made because it is in | | | already in review. | +------------------+----------------------------------------------------+ | Has Revision | A task is set to HREV when one of its Reviews | | (HREV) | completed by requesting a review by using the | | | :meth:`.Review.request_review` method. A HREV Task | | | can have new TimeLogs, and it will be converted to | | | a WIP or DREV depending on its dependency task | | | statuses. | +------------------+----------------------------------------------------+ | Dependency Has | If the dependent task of a WIP, PREV, HREV, DREV | | Revision (DREV) | or CMPL task has a revision then the statuses of | | | the tasks are set to DREV which means both of the | | | dependee and the dependent tasks can work at the | | | same time. For a DREV task a review request can | | | not be made until it is set to WIP again by | | | setting the depending task to CMPL again. | +------------------+----------------------------------------------------+ | On Hold (OH) | A task is set to OH when the resource needs to | | | work for another task, and the :meth:`Task.hold` | | | is called. An OH Task can be resumed by calling | | | :meth:`.Task.resume` method and depending on its | | | :attr:`.Task.time_logs` attribute it will have its | | | status set to RTS or WIP. | +------------------+----------------------------------------------------+ | Stopped (STOP) | A task is set to STOP when no more work needs to | | | done for that task and it will not be used | | | anymore. Call :meth:`.Task.stop` method to do it | | | properly. Only applicable to WIP tasks. | | | | | | The schedule values of the task will be capped to | | | current time spent on it, so Task Juggler will not | | | reserve any more resources for it. | | | | | | Also STOP tasks are treated as if they are dead. | +------------------+----------------------------------------------------+ | Completed (CMPL) | A task is set to CMPL when all of the Reviews are | | | completed by approving the task. It is not | | | possible to create any new TimeLogs and no new | | | review can be requested for a CMPL Task. | +------------------+----------------------------------------------------+ Container "Task Status Workflow" defines a set of statuses where the container task status will only change according to its children task statuses: +-----------------------------------------------------------------------+ | CONTAINER TASK STATUS WORKFLOW | +------------------+----------------------------------------------------+ | Status Name | Description | +------------------+----------------------------------------------------+ | Waiting For | If all of the child tasks are in WFD status then | | Dependency (WFD) | the container task is also WFD. | +------------------+----------------------------------------------------+ | Ready To Start | A container task is set to RTS when children tasks | | (RTS) | have statuses of only WFD and RTS. | +------------------+----------------------------------------------------+ | Work In Progress | A container task is set to WIP when one of its | | (WIP) | children tasks have any of the statuses of RTS, | | | WIP, PREV, HREV, DREV or CMPL. | +------------------+----------------------------------------------------+ | Completed (CMPL) | A container task is set to CMPL when all of its | | | children tasks are CMPL. | +------------------+----------------------------------------------------+ Even though, users are encouraged to use the actions (like :meth:`.Task.create_time_log`, :meth:`.Task.hold`, :meth:`.Task.stop`, :meth:`.Task.resume`, :meth:`.Task.request_revision`, :meth:`.Task.request_review`, :meth:`.Task.approve`) to update the task statuses , setting the :attr:`.Task.status` will also update the dependent tasks or will check the new status against dependencies or the current status of the task. Thus in some situations setting the :attr:`.Task.status` will not change the status of the task. For example, setting the task status to WFD when there are no dependencies will not update the task status to WFD, also updating a PREV task status to STOP or HOLD or RTS is not possible. And it is not possible to set a task to WIP if there are no TimeLogs entered for that task. So the task will strictly follow the Task Status Workflow diagram above. .. warning:: **Dependency Relation in Task Status Workflow** Because the Task Status Workflow heavily effected by the dependent task statuses, and the main reason of having dependency relation is to let TaskJuggler to schedule the tasks correctly, and any task status other than WFD or RTS means that a TimeLog has been created for a task (which means that you cannot change the :attr:`.computed_start` anymore), it is only allowed to change the dependencies of a WFD and RTS tasks. .. warning:: **Resuming a STOP Task** Resuming a STOP Task will be treated as if a revision has been made to that task, and all the statuses of the tasks depending on this particular task will be updated accordingly. .. warning:: **Initial Status of a Task** .. versionadded:: 0.2.5 Because of the Task Status Workflow, supplying a status with the **status** argument may not set the status of the Task to the desired value. A Task starts with WFD status by default, and updated to RTS if it doesn't have any dependencies or all of the dependencies are STOP or CMPL. .. note:: .. versionadded:: 0.2.5.2 Task.path and Task.absolute_path properties Task instances now have two new properties called :attr:`.path` and :attr:`.absolute_path` . The value of these attributes are the rendered version of the related :class:`.FilenameTemplate` which has its target_entity_type attribute set to "Task" (or "Asset", "Shot" or "Sequence" or anything matching to the derived class name, so it can be used in :class:`.Asset`, :class:`.Shot` and :class:`.Sequences` or anything that is derived from Task class) in the :class:`.Project` that this task belongs to. This property has been added to make it easier to write custom template codes for Project :class:`.Structure` s. The :attr:`.path` attribute is a repository relative path, where as the :attr:`.absolute_path` is the full path and includes the OS dependent repository path. .. versionadded: 0.2.13 Task to :class:`.Good` relation. It is now possible to define the related Good to this task, to be able to filter tasks that are related to the same :class:`.Good`. Its main purpose of existence is to be able to generate :class:`.BudgetEntry` instances from the tasks that are related to the same :class:`.Good` and because the Goods are defining the cost and MSRP of different things, it is possible to create BudgetEntries and thus :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. 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:`.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 (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 __auto_name__ = False __tablename__ = "Tasks" __mapper_args__ = {"polymorphic_identity": "Task"} task_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, doc="""The ``primary_key`` attribute for the ``Tasks`` table used by SQLAlchemy to map this Task in relationships. """, ) __id_column__ = "task_id" project_id: Mapped[Optional[int]] = mapped_column( ForeignKey("Projects.id"), doc="""The id of the owner :class:`.Project` of this Task. This attribute is mainly used by **SQLAlchemy** to map a :class:`.Project` instance to a Task. """, ) _project: Mapped[Optional["Project"]] = relationship( primaryjoin="Tasks.c.project_id==Projects.c.id", back_populates="tasks", uselist=False, post_update=True, ) tasks: Mapped[Optional[List["Task"]]] = synonym( "children", doc="""A synonym for the :attr:`.children` attribute used by the descendants of the :class:`Task` class (currently :class:`.Asset`, :class:`.Shot` and :class:`.Sequence` classes). """, ) is_milestone: Mapped[Optional[bool]] = mapped_column( doc="""Specifies if this Task is a milestone. Milestones doesn't need any duration, any effort and any resources. It is used to create meaningful dependencies between the critical stages of the project. """, ) depends_on = association_proxy( "task_depends_on", "depends_on", creator=lambda n: TaskDependency(depends_on=n) ) dependent_of = association_proxy( "task_dependent_of", "task", creator=lambda n: TaskDependency(task=n) ) task_depends_on: Mapped[Optional[List["TaskDependency"]]] = relationship( back_populates="task", cascade="all, delete-orphan", primaryjoin="Tasks.c.id==Task_Dependencies.c.task_id", doc="""A list of :class:`.Task` s that this one is depending on. A CircularDependencyError will be raised when the task dependency creates a circular dependency which means it is not allowed to create a dependency for this Task which is depending on another one which in some way depends on this one again.""", ) task_dependent_of: Mapped[Optional[List["TaskDependency"]]] = relationship( back_populates="depends_on", cascade="all, delete-orphan", primaryjoin="Tasks.c.id==Task_Dependencies.c.depends_on_id", doc="""A list of :class:`.Task` s that this one is being depended by. A CircularDependencyError will be raised when the task dependency creates a circular dependency which means it is not allowed to create a dependency for this Task which is depending on another one which in some way depends on this one again. """, ) resources: Mapped[Optional[List[User]]] = relationship( secondary="Task_Resources", primaryjoin="Tasks.c.id==Task_Resources.c.task_id", secondaryjoin="Task_Resources.c.resource_id==Users.c.id", back_populates="tasks", doc="The list of :class:`.User` s assigned to this Task.", ) alternative_resources: Mapped[Optional[List[User]]] = relationship( secondary="Task_Alternative_Resources", primaryjoin="Tasks.c.id==Task_Alternative_Resources.c.task_id", secondaryjoin="Task_Alternative_Resources.c.resource_id==Users.c.id", backref="alternative_resource_in_tasks", doc="The list of :class:`.User` s assigned to this Task as an " "alternative resource.", ) allocation_strategy: Mapped[str] = mapped_column( Enum(*defaults.allocation_strategy, name="ResourceAllocationStrategy"), default=defaults.allocation_strategy[0], doc="Please read :class:`.Task` class documentation for details.", ) persistent_allocation: Mapped[bool] = mapped_column( default=True, doc="Please read :class:`.Task` class documentation for details.", ) watchers: Mapped[Optional[List[User]]] = relationship( secondary="Task_Watchers", primaryjoin="Tasks.c.id==Task_Watchers.c.task_id", secondaryjoin="Task_Watchers.c.watcher_id==Users.c.id", back_populates="watching", doc="The list of :class:`.User` s watching this Task.", ) _responsible: Mapped[Optional[List[User]]] = relationship( secondary="Task_Responsible", primaryjoin="Tasks.c.id==Task_Responsible.c.task_id", secondaryjoin="Task_Responsible.c.responsible_id==Users.c.id", back_populates="responsible_of", doc="The list of :class:`.User` s responsible from this Task.", ) priority: Mapped[Optional[int]] = mapped_column( doc="""An integer number between 0 and 1000 used by TaskJuggler to determine the priority of this Task. The default value is 500.""", default=500, ) time_logs: Mapped[Optional[List[TimeLog]]] = relationship( primaryjoin="TimeLogs.c.task_id==Tasks.c.id", back_populates="task", cascade="all, delete-orphan", doc="""A list of :class:`.TimeLog` instances showing who and when has spent how much effort on this task.""", ) versions: Mapped[Optional[List["Version"]]] = relationship( primaryjoin="Versions.c.task_id==Tasks.c.id", back_populates="task", cascade="all, delete-orphan", doc="""A list of :class:`.Version` instances showing the files created for this task. """, ) _computed_resources: Mapped[Optional[List[User]]] = relationship( secondary="Task_Computed_Resources", primaryjoin="Tasks.c.id==Task_Computed_Resources.c.task_id", secondaryjoin="Task_Computed_Resources.c.resource_id==Users.c.id", backref="computed_resource_in_tasks", doc="A list of :class:`.User` s computed by TaskJuggler. It is the " "result of scheduling.", ) bid_timing: Mapped[Optional[float]] = mapped_column( default=0, doc="""The value of the initial bid of this Task. It is an integer or a float. """, ) bid_unit: Mapped[Optional[TimeUnit]] = mapped_column( TimeUnitDecorator, doc="""The unit of the initial bid of this Task. It is a string value. And should be one of 'min', 'h', 'd', 'w', 'm', 'y'. """, ) _schedule_seconds: Mapped[Optional[int]] = mapped_column( "schedule_seconds", Integer, nullable=True, doc="cache column for schedule_seconds", ) _total_logged_seconds: Mapped[Optional[int]] = mapped_column( "total_logged_seconds", doc="cache column for total_logged_seconds", ) reviews: Mapped[Optional[List[Review]]] = relationship( primaryjoin="Reviews.c.task_id==Tasks.c.id", back_populates="task", cascade="all, delete-orphan", doc="""A list of :class:`.Review` holding the details about the reviews created for this task.""", ) _review_number: Mapped[Optional[int]] = mapped_column("review_number", default=0) good_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Goods.id")) good: Mapped[Optional[Good]] = relationship( primaryjoin="Tasks.c.good_id==Goods.c.id", uselist=False, post_update=True, ) # TODO: Add ``unmanaged`` attribute for Asset management only tasks. # # Some tasks are created for asset management purposes only and doesn't # need TimeLogs to be entered. Create an attribute called ``unmanaged`` and # and set it to False by default, and if its True don't include it in the # TaskJuggler project. And do not track its status. def __init__( self, project: Optional["Project"] = None, parent: Optional["Task"] = None, depends_on: Optional[List["Task"]] = None, resources: Optional[List[User]] = None, alternative_resources: Optional[List[User]] = None, responsible: Optional[List[User]] = None, watchers: Optional[List[User]] = None, start: Optional[datetime.datetime] = None, end: Optional[datetime.datetime] = None, schedule_timing: float = 1.0, schedule_unit: TimeUnit = TimeUnit.Hour, schedule_model: Optional[ScheduleModel] = None, schedule_constraint: Optional[ScheduleConstraint] = ScheduleConstraint.NONE, bid_timing: Optional[Union[int, float]] = None, bid_unit: Optional[TimeUnit] = None, is_milestone: bool = False, priority: int = defaults.task_priority, allocation_strategy: str = defaults.allocation_strategy[0], persistent_allocation: bool = True, good: Optional[Good] = None, **kwargs: Dict[str, Any], ) -> None: # temp attribute for remove event self._previously_removed_dependent_tasks = [] # update kwargs with extras kwargs["start"] = start kwargs["end"] = end kwargs["schedule_timing"] = schedule_timing kwargs["schedule_unit"] = schedule_unit kwargs["schedule_model"] = schedule_model kwargs["schedule_constraint"] = schedule_constraint super(Task, self).__init__(**kwargs) # call the mixin __init__ methods StatusMixin.__init__(self, **kwargs) DateRangeMixin.__init__(self, **kwargs) ScheduleMixin.__init__(self, **kwargs) kwargs["parent"] = parent DAGMixin.__init__(self, **kwargs) self._review_number = 0 # self.parent = parent self._project = project self.time_logs = [] self.versions = [] self.is_milestone = is_milestone # update the status with DBSession.no_autoflush: self.status = self.status_list["WFD"] if depends_on is None: depends_on = [] self.depends_on = depends_on if self.is_milestone: resources = None if resources is None: resources = [] self.resources = resources if alternative_resources is None: alternative_resources = [] self.alternative_resources = alternative_resources # for newly created tasks set the computed_resources to resources self.computed_resources = self.resources if watchers is None: watchers = [] self.watchers = watchers if bid_timing is None: bid_timing = self.schedule_timing if bid_unit is None: bid_unit = self.schedule_unit self.bid_timing = bid_timing self.bid_unit = bid_unit self.priority = priority if responsible is None: responsible = [] self.responsible = responsible self.allocation_strategy = allocation_strategy self.persistent_allocation = persistent_allocation self.update_status_with_dependent_statuses() self.good = good @reconstructor def __init_on_load__(self) -> None: """Update defaults on load.""" # temp attribute for remove event self._previously_removed_dependent_tasks = [] def __eq__(self, other: Any) -> None: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Task instance and has the same project, parent, depends_on, start and end value and resources. """ return ( super(Task, self).__eq__(other) and isinstance(other, Task) and self.project == other.project and self.parent == other.parent and self.depends_on == other.depends_on and self.start == other.start and self.end == other.end and self.resources == other.resources ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Task, self).__hash__() @validates("time_logs") def _validate_time_logs(self, key: str, time_log: TimeLog) -> TimeLog: """Validate the given time_log value. Args: key (str): The name of the validated column. time_log (TimeLog): A TimeLog instance to validate. Raises: TypeError: If the given time_log value is not a :class:`.TimeLog` instance. Returns: TimeLog: The validated TimeLog instance. """ if not isinstance(time_log, TimeLog): raise TypeError( "{}.time_logs should only contain instances of " "stalker.models.task.TimeLog, not {}: '{}'".format( self.__class__.__name__, time_log.__class__.__name__, time_log ) ) # TODO: convert this to an event # update parents total_logged_second attribute with DBSession.no_autoflush: if self.parent: self.parent.total_logged_seconds += time_log.total_seconds return time_log @validates("reviews") def _validate_reviews(self, key: str, review: Review) -> Review: """Validate the given review value. Args: key (str): The name of the validated column. review (Review): The validated review value. Raises: TypeError: If the review is not a :class:`stalker.models.review.Review` instance. Returns: Review: The validated review instance. """ if not isinstance(review, Review): raise TypeError( "{}.reviews should only contain instances of " "stalker.models.review.Review, not {}: '{}'".format( self.__class__.__name__, review.__class__.__name__, review ) ) return review @validates("task_depends_on") def _validate_task_depends_on(self, key: str, task_depends_on: "Task") -> "Task": """Validate the given task_depends_on value. Args: key (str): The name of the validated column. task_depends_on (Task): The Task instance that this Task is depending on. Raises: TypeError: If the `task_depends_on.depends_on` is not a Task instance. StatusError: If the status of the current task is one of WIP, PREV, HREV, OH, STOP or CMPL as this means the Task has been started to be worked on and it is not allowed to change the dependency chain of an already started task. StatusError: If the current task is a container and its status is CMPL. CircularDependencyError: If the given task is in circular relation with this Task instance. Returns: Task: The validated task_depends_on value. """ if not isinstance(task_depends_on, TaskDependency): raise TypeError( "{}.task_depends_on should only contain instances of " "TaskDependency, not {}: '{}'".format( self.__class__.__name__, task_depends_on.__class__.__name__, task_depends_on, ) ) depends_on = task_depends_on.depends_on if not depends_on: # the relation is still not setup yet # trust to the TaskDependency class for checking the # depends_on attribute return task_depends_on # check the status of the current task with DBSession.no_autoflush: wfd = self.status_list["WFD"] rts = self.status_list["RTS"] wip = self.status_list["WIP"] prev = self.status_list["PREV"] hrev = self.status_list["HREV"] drev = self.status_list["DREV"] oh = self.status_list["OH"] stop = self.status_list["STOP"] cmpl = self.status_list["CMPL"] if self.status in [wip, prev, hrev, drev, oh, stop, cmpl]: raise StatusError( f"This is a {self.status.code} task and it is not allowed to " f"change the dependencies of a {self.status.code} task" ) # check for the circular dependency with DBSession.no_autoflush: check_circular_dependency(depends_on, self, "depends_on") check_circular_dependency(depends_on, self, "children") # check for circular dependency toward the parent, non of the parents # should be depending on the given depends_on_task with DBSession.no_autoflush: parent = self.parent while parent: if parent in depends_on.depends_on: raise CircularDependencyError( f"One of the parents of {self} is depending on {depends_on}" ) parent = parent.parent # update status with the new dependency # update towards more constrained situation # # Do not update for example to RTS if the current dependent task is # CMPL or STOP, this will be done by the approve or stop action in the # dependent task it self if self.status == rts: with DBSession.no_autoflush: do_update_status = False if depends_on.status in [wfd, rts, wip, oh, prev, hrev, drev, oh]: do_update_status = True if do_update_status: self.status = wfd return task_depends_on @validates("schedule_timing") def _validate_schedule_timing( self, key: str, schedule_timing: Union[int, float] ) -> Union[int, float]: """Validate the given schedule_timing value. Args: key (str): The name of the validated column. schedule_timing (Union[int, float]): The schedule_timing value to be validated. Returns: float: The validated schedule_timing value. """ schedule_timing = ScheduleMixin._validate_schedule_timing( self, key, schedule_timing ) # reschedule self._reschedule(schedule_timing, self.schedule_unit) return schedule_timing @validates("schedule_unit") def _validate_schedule_unit( self, key: str, schedule_unit: Union[str, TimeUnit] ) -> TimeUnit: """Validate the given schedule_unit value. Args: key (str): The name of the validated column. schedule_unit (Union[str, TimeUnit]): The schedule_unit value to be validated. Returns: TimeUnit: The validated schedule_unit value. """ schedule_unit = ScheduleMixin._validate_schedule_unit(self, key, schedule_unit) if self.schedule_timing: self._reschedule(self.schedule_timing, schedule_unit) return schedule_unit def _reschedule( self, schedule_timing: Union[int, float], schedule_unit: Union[str, TimeUnit] ) -> None: """Update the start and end date with schedule_timing and schedule_unit values. Args: schedule_timing (Union[int, float]): An integer or float value showing the value of the schedule timing. schedule_unit (Union[str, TimeUnit]): One of 'min', 'h', 'd', 'w', 'm', 'y' or a TimeUnit enum value. """ # update end date value by using the start and calculated duration if not self.is_leaf: return from stalker import defaults schedule_unit_value = None if schedule_unit is not None: schedule_unit = TimeUnit.to_unit(schedule_unit) schedule_unit_value = schedule_unit.value unit = defaults.datetime_units_to_timedelta_kwargs.get(schedule_unit_value) if not unit: # we are in a pre flushing state do not do anything return kwargs = {unit["name"]: schedule_timing * unit["multiplier"]} calculated_duration = datetime.timedelta(**kwargs) if ( 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 == ScheduleConstraint.End: # get start self._start, self._end, self._duration = self._validate_dates( None, self.end, calculated_duration ) elif self.schedule_constraint == ScheduleConstraint.Both: # restore duration self._start, self._end, self._duration = self._validate_dates( self.start, self.end, None ) # also update cached _schedule_seconds value self._schedule_seconds = self.schedule_seconds @validates("is_milestone") def _validate_is_milestone(self, key: str, is_milestone: Union[None, bool]) -> bool: """Validate the given is_milestone value. Args: key (str): The name of the validated column. is_milestone (Union[None, bool]): The value to validated. Raises: TypeError: If the is_milestone value is not None and not a bool value. Returns: bool: The validated value. """ if is_milestone is None: is_milestone = False if not isinstance(is_milestone, bool): raise TypeError( "{}.is_milestone should be a bool value (True or False), " "not {}: '{}'".format( self.__class__.__name__, is_milestone.__class__.__name__, is_milestone, ) ) if is_milestone: self.resources = [] return bool(is_milestone) @validates("parent") def _validate_parent(self, key: str, parent: "Task") -> "Task": """Validate the given parent value. Args: key (str): The name of the validated column. parent (Task): The parent value to be validated. Raises: TypeError: If the parent value is not None and not a :class:`.Task` instance. Returns: Task: The validated parent value. """ if parent is not None: if not isinstance(parent, Task): raise TypeError( "{}.parent should be an instance of " "stalker.models.task.Task, not {}: '{}'".format( self.__class__.__name__, parent.__class__.__name__, parent ) ) # check for cycle check_circular_dependency(self, parent, "children") check_circular_dependency(self, parent, "depends_on") old_parent = self.parent new_parent = parent if old_parent: old_parent.schedule_seconds -= self.schedule_seconds old_parent.total_logged_seconds -= self.total_logged_seconds # update the new parent if new_parent: # if the new parent was a leaf task before this attachment # set schedule_seconds to 0 if new_parent.is_leaf: new_parent.schedule_seconds = self.schedule_seconds new_parent.total_logged_seconds = self.total_logged_seconds else: new_parent.schedule_seconds += self.schedule_seconds new_parent.total_logged_seconds += self.total_logged_seconds return parent @validates("_project") def _validate_project(self, key: str, project: "Project") -> "Project": """Validate the given project value. Args: key (str): The name of the validated column. project (stalker.models.project.Project): The project value to validate. Raises: TypeError: If the project is None and a :class:`stalker.models.project.Project` cannot be found through the parents of this current task. TypeError: If the project is not a :class:`stalker.models.project.Project` instance. Returns: stalker.models.project.Project: The validated :class:`stalker.models.project.Project` instance. """ if project is None: # check if there is a parent defined if self.parent: # use its project as the project # to prevent prematurely flush the parent with DBSession.no_autoflush: project = self.parent.project else: # no project, no task, go mad again!!! raise TypeError( "{}.project should be an instance of " "stalker.models.project.Project, not {}: '{}'.\n\nOr please supply " "a stalker.models.task.Task with the parent argument, so " "Stalker can use the project of the supplied parent task".format( self.__class__.__name__, project.__class__.__name__, project ) ) from stalker.models.project import Project if not isinstance(project, Project): # go mad again it is not a project instance raise TypeError( "{}.project should be an instance of stalker.models.project.Project, " "not {}: '{}'".format( self.__class__.__name__, project.__class__.__name__, project ) ) # check if there is a parent if not self.parent: return project # check if given project is matching the parent.project with DBSession.no_autoflush: if self.parent.project != project: # don't go mad again, but warn the user that there is # an ambiguity!!! import warnings message = ( "The supplied parent and the project is not matching in " "{}, Stalker will use the parent's project ({}) as the " "parent of this {}".format( self, self.parent.project, self.__class__.__name__ ) ) warnings.warn(message, RuntimeWarning, stacklevel=2) # use the parent.project project = self.parent.project return project @validates("priority") def _validate_priority(self, key: str, priority: Union[int, float]) -> int: """Validate the given priority value. Args: key (str): The name of the validated column. priority (int): The priority value to be validated. It should be a float or integer value between 0 and 1000, any other value will be clamped to this range. Raises: TypeError: If the given priority value is not an integer or float. Returns: int: The validated priority value. """ if priority is None: from stalker import defaults priority = defaults.task_priority if not isinstance(priority, (int, float)): raise TypeError( "{}.priority should be an integer value between 0 and 1000, " "not {}: '{}'".format( self.__class__.__name__, priority.__class__.__name__, priority ) ) if priority < 0: priority = 0 elif priority > 1000: priority = 1000 return int(priority) @validates("children") def _validate_children(self, key: str, child: "Task") -> "Task": """Validate the given child value. Args: key (str): The name of the validated column. child (Task): The child Task to be validated. Returns: Task: The validated child Task instance. """ # just empty the resources list # do it without a flush with DBSession.no_autoflush: self.resources = [] # if this is the first ever child we receive # set total_scheduled_seconds to child's total_logged_seconds # and set schedule_seconds to child's schedule_seconds if self.is_leaf: # remove info from parent old_schedule_seconds = self.schedule_seconds self._total_logged_seconds = child.total_logged_seconds self._schedule_seconds = child.schedule_seconds # got a parent ? if self.parent: # update schedule_seconds self.parent._schedule_seconds -= old_schedule_seconds self.parent._schedule_seconds += child.schedule_seconds # it was a leaf but now a parent, so set the start to max and # end to min self._start = datetime.datetime.max.replace(tzinfo=pytz.utc) self._end = datetime.datetime.min.replace(tzinfo=pytz.utc) # extend start and end dates self._expand_dates(self, child.start, child.end) return child @validates("resources") def _validate_resources(self, key: str, resource: User) -> User: """Validate the given resources value. Args: key (str): The name of the validated column. resource (User): The value to validate. Raises: TypeError: If the given resource value is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated :class:`stalker.models.auth.User` instance. """ if not isinstance(resource, User): raise TypeError( "{}.resources should only contain instances of " "stalker.models.auth.User, not {}: '{}'".format( self.__class__.__name__, resource.__class__.__name__, resource, ) ) return resource @validates("alternative_resources") def _validate_alternative_resources(self, key: str, resource: User) -> User: """Validate the given resource value. Args: key (str): The name of the validated column. resource (User): The value to validate. Raises: TypeError: If the given resource value is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated User instance. """ if not isinstance(resource, User): raise TypeError( "{}.alternative_resources should only contain instances of " "stalker.models.auth.User, not {}: '{}'".format( self.__class__.__name__, resource.__class__.__name__, resource, ) ) return resource @validates("_computed_resources") def _validate_computed_resources(self, key: str, resource: User) -> User: """Validate the computed resources value. Args: key (str): The name of the validated column. resource (User): The value to validate. Raises: TypeError: If the given resource value is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated User instance. """ if not isinstance(resource, User): raise TypeError( "{}.computed_resources should only contain instances of " "stalker.models.auth.User, not {}: '{}'".format( self.__class__.__name__, resource.__class__.__name__, resource ) ) return resource def _computed_resources_getter(self): """Return the _computed_resources attribute value. Returns: User: The computed_user attribute value if there are any else the same content of the resources attribute. """ if not self.is_scheduled: self._computed_resources = self.resources return self._computed_resources def _computed_resources_setter(self, resources: List[User]) -> None: """Set the _computed_resources attribute value. Args: resources (List[User]): List of User instances to set the computed resources too. """ self._computed_resources = resources computed_resources: Mapped[Optional[List["User"]]] = synonym( "_computed_resources", descriptor=property(_computed_resources_getter, _computed_resources_setter), ) @validates("allocation_strategy") def _validate_allocation_strategy(self, key: str, strategy: str) -> str: """Validate the given allocation_strategy value. Args: key (str): The name of the validated column. strategy (str): The allocation strategy value to validate. Raises: TypeError: If the given allocation strategy value is not a string. ValueError: If the given allocation strategy value is not one of [ "minallocated", "maxloaded", "minloaded", "order", "random"]. Returns: str: The validated allocation strategy value. """ from stalker import defaults if strategy is None: strategy = defaults.allocation_strategy[0] if not isinstance(strategy, str): raise TypeError( "{}.allocation_strategy should be one of {}, not {}: '{}'".format( self.__class__.__name__, defaults.allocation_strategy, strategy.__class__.__name__, strategy, ) ) if strategy not in defaults.allocation_strategy: raise ValueError( "{}.allocation_strategy should be one of {}, not '{}'".format( self.__class__.__name__, defaults.allocation_strategy, strategy, ) ) return strategy @validates("persistent_allocation") def _validate_persistent_allocation( self, key: str, persistent_allocation: bool ) -> bool: """Validate the given persistent_allocation value. Args: key (str): The name of the validate column. persistent_allocation (bool): The persistent allocation value to be validated. Returns: bool: The validated persistent allocation value. """ if persistent_allocation is None: from stalker import defaults persistent_allocation = defaults.persistent_allocation return bool(persistent_allocation) @validates("watchers") def _validate_watchers(self, key: str, watcher: User) -> User: """Validate the given watcher value. Args: key (str): The name of the validated column. watcher (User): The watcher value to be validated. Raises: TypeError: If the watcher is not a :class:`stalker.models.auth.User` instance. Returns: User: The validated :class:`stalker.models.auth.User` instance. """ if not isinstance(watcher, User): raise TypeError( "{}.watchers should only contain instances of " "stalker.models.auth.User, not {}: '{}'".format( self.__class__.__name__, watcher.__class__.__name__, watcher, ) ) return watcher @validates("versions") def _validate_versions(self, key: str, version: "Version"): """Validate the given version value. Args: key (str): The name of the validated column. version (stalker.models.version.Version): The version value to be validated. Raises: TypeError: If the version is not an Version instance. Returns: stalker.models.version.Version: The validated :class:`stalker.models.version.Version` value. """ from stalker.models.version import Version if not isinstance(version, Version): raise TypeError( "{}.versions should only contain instances of " "stalker.models.version.Version, and not {}: '{}'".format( self.__class__.__name__, version.__class__.__name__, version, ) ) return version @validates("bid_timing") def _validate_bid_timing( self, key: str, bid_timing: Union[None, int, float] ) -> Union[None, int, float]: """Validate the given bid_timing value. Args: key (str): The name of the validated column. bid_timing (Union[int, float]): The bid_timing value to be validated. Raises: TypeError: If the bid_timing is not None and not an integer or float. Returns: Union[int, float]: The validated bid_timing value. """ if bid_timing is not None: if not isinstance(bid_timing, (int, float)): raise TypeError( "{}.bid_timing should be an integer or float showing the value of " "the initial bid for this {}, not {}: '{}'".format( self.__class__.__name__, self.__class__.__name__, bid_timing.__class__.__name__, bid_timing, ) ) return bid_timing @validates("bid_unit") def _validate_bid_unit(self, key: str, bid_unit: Union[str, TimeUnit]) -> str: """Validate the given bid_unit value. Args: key (str): The name of the validated column. bid_unit (Union[str, TimeUnit]): The timing unit of the bid value, should be a TimeUnit enum value or one of ["min", "h", "d", "w", "m", "y", "Minute", "Hour", "Day", "Week", "Month", "Year"]. Returns: str: The validated bid_unit value. """ if bid_unit is None: bid_unit = TimeUnit.Hour bid_unit = TimeUnit.to_unit(bid_unit) return bid_unit @classmethod def _expand_dates( cls, task: "Task", start: datetime.datetime, end: datetime.datetime ) -> None: """Extend the given tasks date values with the given start and end values. Args: task (Task): The Task instance. start (datetime.datetime): The start datetime.datetime instance. end (datetime.datetime): The end datetime.datetime instance. """ # update parents start and end date if task: if task.start > start: task.start = start if task.end < end: task.end = end # TODO: Why these methods are not in the DateRangeMixin class. @validates("computed_start") def _validate_computed_start( self, key: str, computed_start: datetime.datetime ) -> datetime.datetime: """Validate the given computed_start value. Args: key (str): The name of the validated column. computed_start (datetime.datetime): The computed start as a datetime.datetime instance. Returns: datetime.datetime: The validated computed start value. """ self.start = computed_start return computed_start @validates("computed_end") def _validate_computed_end( self, key: str, computed_end: datetime.datetime ) -> datetime.datetime: """Validate the given computed_end value. Args: key (str): The name of the validated column. computed_end (datetime.datetime): The computed start as a datetime.datetime instance. Returns: datetime.datetime: The validated computed end value. """ self.end = computed_end return computed_end def _start_getter(self) -> datetime.datetime: """Return the start value. Returns: datetime.datetime: The start date and time value. """ return self._start def _start_setter(self, start: datetime.datetime) -> None: """Set the start value. Args: start (datetime.datetime): The start date and time value to be validated. """ self._start, self._end, self._duration = self._validate_dates( start, self._end, self._duration ) self._expand_dates(self.parent, self.start, self.end) def _end_getter(self) -> datetime.datetime: """Return the end value. Returns: datetime.datetime: The end date and time value. """ return self._end def _end_setter(self, end: datetime.datetime) -> None: """Set the end value. Args: end (datetime.datetime): The end date and time value to be validated. """ # update the end only if this is not a container task self._start, self._end, self._duration = self._validate_dates( self.start, end, self.duration ) self._expand_dates(self.parent, self.start, self.end) def _project_getter(self) -> "Project": """Return the project value. Returns: stalker.models.project.Project: The :class:`stalker.models.project.Project` instance. """ return self._project project: Mapped[Optional["Project"]] = synonym( "_project", descriptor=property(_project_getter), doc="""The owner Project of this task. It is a read-only attribute. It is not possible to change the owner Project of a Task it is defined when the Task is created. """, ) @property def tjp_abs_id(self) -> str: """Return the calculated absolute id of this task. Returns: str: The calculated absolute id of this task. """ abs_id = self.parent.tjp_abs_id if self.parent else self.project.tjp_id return f"{abs_id}.{self.tjp_id}" @property def to_tjp(self) -> str: """Return the TaskJuggler representation of this task. Returns: str: The TaskJuggler representation of this task. """ tab = " " indent = tab * len(self.parents) has_inner_data = False tjp = f'{indent}task {self.tjp_id} "{self.tjp_id}" {{' if self.priority != 500: has_inner_data = True tjp += f"\n{indent}{tab}priority {self.priority}" if self.task_depends_on: has_inner_data = True tjp += f"\n{indent}{tab}depends " for i, depends_on in enumerate(self.task_depends_on): if i != 0: tjp += ", " tjp += depends_on.to_tjp if self.is_container: has_inner_data = True for child_task in self.children: tjp += "\n" tjp += child_task.to_tjp if self.resources: has_inner_data = True if self.schedule_constraint: if self.schedule_constraint in [1, 3]: tjp += f"\n{indent}{tab}start {self.start.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M')}" # noqa: B950 if self.schedule_constraint in [2, 3]: tjp += f"\n{indent}{tab}end {self.end.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M')}" # noqa: B950 tjp += f"\n{indent}{tab}{self.schedule_model} {self.schedule_timing}{self.schedule_unit}" # noqa: B950 tjp += f"\n{indent}{tab}allocate " for i, resource in enumerate(sorted(self.resources, key=lambda x: x.id)): if i != 0: tjp += ", " tjp += resource.tjp_id if not self.alternative_resources: continue tjp += f" {{\n{indent}{tab}{tab}alternative\n{indent}{tab}{tab}" for i, alt_res in enumerate( sorted(self.alternative_resources, key=lambda x: x.id) ): if i != 0: tjp += ", " tjp += alt_res.tjp_id tjp += f" select {self.allocation_strategy}" if self.persistent_allocation: tjp += f"\n{indent}{tab}{tab}persistent" tjp += f"\n{indent}{tab}}}" for time_log in self.time_logs: has_inner_data = True tjp += ( f"\n{indent}{tab}booking " f"{time_log.resource.tjp_id} " f"{time_log.start.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M:%S')} " f"- " f"{time_log.end.astimezone(pytz.utc).strftime('%Y-%m-%d-%H:%M:%S')} " "{ overtime 2 }" ) tjp += f"\n{indent}}}" if has_inner_data else "}" return tjp @property def level(self) -> int: """Returns the hierarchical level of this task. It is a temporary property and will be useless when Stalker has its own implementation of a proper Gantt Chart. Right now it is used by the jQueryGantt. Returns: int: The hierarchical level of this task. """ i = 0 current_task = self while current_task: i += 1 current_task = current_task.parent return i @property def is_scheduled(self) -> bool: """Return if this task has both a computed_start and computed_end values. Returns: bool: Return True if this task has both a computed_start and computed_end values. """ return self.computed_start is not None and self.computed_end is not None def _total_logged_seconds_getter(self) -> int: """Return the total effort spent for this Task. It is the sum of all the TimeLogs recorded for this task as seconds. Returns: int: An integer showing the total seconds spent. """ with DBSession.no_autoflush: if not self.is_leaf: if self._total_logged_seconds is None: self.update_schedule_info() return self._total_logged_seconds if self.schedule_model == ScheduleModel.Effort: logger.debug("effort based task detected!") try: sql = """ select extract(epoch from sum("TimeLogs".end - "TimeLogs".start))::int from "TimeLogs" where "TimeLogs".task_id = :task_id """ connection = DBSession.connection() result = connection.execute( text(sql), {"task_id": self.id} ).fetchone() return result[0] if result[0] else 0 except (UnboundExecutionError, OperationalError, ProgrammingError): # no database connection # fallback to Python logger.debug("No session found! Falling back to Python") seconds = 0 for time_log in self.time_logs: seconds += time_log.total_seconds return seconds else: now = datetime.datetime.now(pytz.utc) if self.schedule_model == ScheduleModel.Duration: # directly return the difference between # min(now - start, end - start) logger.debug( "duration based task detected!, " "calculating schedule_info from duration of the task" ) daily_working_hours = 86400.0 elif self.schedule_model == ScheduleModel.Length: # directly return the difference between # min(now - start, end - start) # but use working days logger.debug( "length based task detected!, " "calculating schedule_info from duration of the task" ) from stalker import defaults daily_working_hours = defaults.daily_working_hours if self.end <= now: seconds = ( self.duration.days * daily_working_hours + self.duration.seconds ) elif self.start >= now: seconds = 0 else: past = now - self.start past_as_seconds = past.days * daily_working_hours + past.seconds logger.debug(f"past_as_seconds: {past_as_seconds}") seconds = past_as_seconds return seconds def _total_logged_seconds_setter(self, seconds: int) -> None: """Set the total_logged_seconds value. This is mainly used for container tasks, to cache the child logged_seconds Args: seconds (int): An integer value for the seconds. """ # only set for container tasks if self.is_container: # update parent old_value = 0 if self._total_logged_seconds: old_value = self._total_logged_seconds self._total_logged_seconds = seconds if self.parent: self.parent.total_logged_seconds = ( self.parent.total_logged_seconds - old_value + seconds ) total_logged_seconds: Mapped[Optional[int]] = synonym( "_total_logged_seconds", descriptor=property(_total_logged_seconds_getter, _total_logged_seconds_setter), ) def _schedule_seconds_getter(self) -> int: """Return the total effort, length or duration in seconds. This is used for calculating the percent_complete value. Returns: int: The total effort, length or duration in seconds. """ # for container tasks use the children schedule_seconds attribute if self.is_container: if self._schedule_seconds is None or self._schedule_seconds < 0: self.update_schedule_info() return self._schedule_seconds else: return self.to_seconds( self.schedule_timing, self.schedule_unit, self.schedule_model ) def _schedule_seconds_setter(self, seconds: int) -> None: """Set the schedule_seconds of this task. Mainly used for container tasks. Args: seconds (int): An integer value of schedule_seconds for this task. """ # do it only for container tasks if self.is_container: # also update the parents with DBSession.no_autoflush: if self.parent: current_value = 0 if self._schedule_seconds: current_value = self._schedule_seconds self.parent.schedule_seconds = ( self.parent.schedule_seconds - current_value + seconds ) self._schedule_seconds = seconds schedule_seconds: Mapped[Optional[int]] = synonym( "_schedule_seconds", descriptor=property(_schedule_seconds_getter, _schedule_seconds_setter), ) def update_schedule_info(self) -> None: """Update the total_logged_seconds and schedule_seconds attributes. This updates the total_logged_seconds and schedule_seconds attributes by using the children info and triggers an update on every children. """ if self.is_container: total_logged_seconds = 0 schedule_seconds = 0 logger.debug(f"updating schedule info for: {self.name}") for child in self.children: # update children if they are a container task if child.is_container: child.update_schedule_info() if child.schedule_seconds: schedule_seconds += child.schedule_seconds if child.total_logged_seconds: total_logged_seconds += child.total_logged_seconds else: # DRY please!!!! schedule_seconds += ( child.schedule_seconds if child.schedule_seconds else 0 ) total_logged_seconds += ( child.total_logged_seconds if child.total_logged_seconds else 0 ) self._schedule_seconds = schedule_seconds self._total_logged_seconds = total_logged_seconds else: self._schedule_seconds = self.schedule_seconds self._total_logged_seconds = self.total_logged_seconds @property def percent_complete(self) -> float: """Calculate and return the percent_complete value. The percent_complete value is based on the total_logged_seconds and schedule_seconds of the task. Container tasks will use info from their children. Returns: float: The percent complete value between 0 and 1. """ if self.is_container and ( self._total_logged_seconds is None or self._schedule_seconds is None ): self.update_schedule_info() return self.total_logged_seconds / float(self.schedule_seconds) * 100.0 @property def remaining_seconds(self) -> int: """Return the remaining amount of effort, length or duration left as seconds. Returns: int: The remaining amount of effort, length or duration in seconds. """ # for effort based tasks use the time_logs return self.schedule_seconds - self.total_logged_seconds def _responsible_getter(self) -> List[User]: """Return the current responsible of this task. Returns: List[User]: The list of :class:`stalker.models.auth.User` instances that are the responsible of this task. If no stored value is found the same list of Users from the parents will be returned. """ if not self._responsible: # traverse parents for parent in reversed(self.parents): if parent.responsible: self._responsible = copy.copy(parent.responsible) break # so parents do not have a responsible return self._responsible def _responsible_setter(self, responsible: List[User]) -> None: """Set the responsible attribute. Args: responsible (List[User]): A list of :class:`.User` instances to be the responsible of this Task. """ self._responsible = responsible @validates("_responsible") def _validate_responsible(self, key, responsible: User) -> User: """Validate the given responsible value (each responsible). Args: key (str): The name of the validated column. responsible (User): A :class:`stalker.models.auth.User` instance to be validated. Raises: TypeError: If the given responsible value is not a User instance. Returns: User: The validated :class:`stalker.models.auth.User` instance. """ if not isinstance(responsible, User): raise TypeError( "{}.responsible should only contain instances of " "stalker.models.auth.User, not {}: '{}'".format( self.__class__.__name__, responsible.__class__.__name__, responsible, ) ) return responsible responsible: Mapped[Optional[List[User]]] = synonym( "_responsible", descriptor=property( _responsible_getter, _responsible_setter, doc="""The responsible of this task. This attribute will return the responsible of this task which is a list of :class:`.User` instances. If there is no responsible set for this task, then it will try to find a responsible in its parents. """, ), ) @property def tickets(self) -> List[Ticket]: """Return the tickets referencing this Task in their links attribute. Returns: List[Ticket]: List of :class:`stalker.models.ticket.Ticket` instances that are referencing this Task in their links attribute. """ return Ticket.query.filter(Ticket.links.contains(self)).all() @property def open_tickets(self) -> List[Ticket]: """Return the open tickets referencing this task in their links attribute. Returns: List[Ticket]: List of open :class:`stalker.models.ticket.Ticket` instances that are referencing this Task in their links attribute. """ status_closed = Status.query.filter(Status.name == "Closed").first() return ( Ticket.query.filter(Ticket.links.contains(self)) .filter(Ticket.status != status_closed) .all() ) def walk_dependencies( self, method: Union[int, str, TraversalDirection] = TraversalDirection.BreadthFirst, ) -> Generator[None, "Task", None]: """Walk the dependencies of this task. Args: method (Union[int, str, TraversalDirection]): The walk method defined by the :class:`.TraversalDirection` enum value. Default is :attr:`.TraversalDirection.BreadthFirst`. Yields: Task: Yields Task instances. """ for t in walk_hierarchy(self, "depends_on", method=method): yield t @validates("good") def _validate_good(self, key: str, good: Good) -> Good: """Validate the given good value. Args: key (str): The name of the validated column. good (Good): The validated good value. Raises: TypeError: If the given good is not None and not a Good instance. Returns: Good: The validated good value. """ if good is not None and not isinstance(good, Good): raise TypeError( "{}.good should be a stalker.models.budget.Good instance, " "not {}: '{}'".format( self.__class__.__name__, good.__class__.__name__, good ) ) return good # ============= # ** ACTIONS ** # ============= def create_time_log( self, resource: User, start: datetime.datetime, end: datetime.datetime ) -> TimeLog: """Create a TimeLog with the given information. This will ease creating TimeLog instances for task. Args: resource (User): The :class:`stalker.models.auth.User` instance as the resource for the TimeLog. start (datetime.datetime): The start date and time. end (datetime.datetime): The end date and time. Returns: TimeLog: The created TimeLog instance. """ # all the status checks are now part of TimeLog._validate_task # create a TimeLog return TimeLog(task=self, resource=resource, start=start, end=end) # also updating parent statuses are done in TimeLog._validate_task def request_review(self, version: Optional["Version"] = None) -> List[Review]: """Create and return Review instances for each of the responsible of this task. Also set the task status to PREV. .. versionadded:: 0.2.0 Request review will not cap the timing of this task anymore. Only applicable to leaf tasks. Args: version (Optional[Version]): An optional :class:`.Version` instance can also be passed. The :class:`.Version` should be related to this :class:`.Task`. Raises: StatusError: If the current task status is not WIP a StatusError will be raised as the task has either not been started on being worked yet, it is already on review, a dependency might be under review or this is stopped, hold or completed. Returns: List[Review]: The list of :class:`stalker.models.review.Review` instances created. """ # check task status with DBSession.no_autoflush: wip = self.status_list["WIP"] prev = self.status_list["PREV"] if self.status != wip: raise StatusError( "{task} (id:{id}) is a {status} task, and it is not suitable for " "requesting a review, please supply a WIP task instead.".format( task=self.name, id=self.id, status=self.status.code ) ) # create Review instances for each Responsible of this task reviews = [] for responsible in self.responsible: reviews.append(Review(task=self, version=version, reviewer=responsible)) # update the status to PREV self.status = prev # no need to update parent or dependent task statuses return reviews def request_revision( self, reviewer: Optional[User] = None, description: str = "", schedule_timing: int = 1, schedule_unit: Union[str, TimeUnit] = TimeUnit.Hour, ) -> Review: """Request revision. Applicable to PREV or CMPL leaf tasks. This method will expand the schedule timings of the task according to the supplied arguments. When request_revision is called on a PREV task, the other NEW Review instances (those created when request_review on a WIP task is called and still waiting a review) will be deleted. This method at the end will return a new Review instance with correct attributes (reviewer, description, schedule_timing, schedule_unit and review_number attributes). Args: reviewer (User): This is the user that requested the revision. They don't need to be the responsible, anybody that has a Permission to create a Review instance can request a revision. description (str): The description of the requested revision. schedule_timing (int): The timing value of the requested revision. The task will be extended this much of duration. Works along with the ``schedule_unit`` parameter. The default value is 1. schedule_unit (Union[str, TimeUnit]): The timing unit value of the requested revision. The task will be extended this much of duration. Works along with the ``schedule_timing`` parameter. The default value is `TimeUnit.Hour`. Raises: StatusError: If the status of the current task is not PREV or CMPL. Returns: Review: The newly created :class:`stalker.models.review.Review` instance. """ # check status with DBSession.no_autoflush: prev = self.status_list["PREV"] cmpl = self.status_list["CMPL"] if self.status not in [prev, cmpl]: raise StatusError( "{task} (id: {id}) is a {status} task, and it is not suitable " "for requesting a revision, please supply a PREV or CMPL " "task".format(task=self.name, id=self.id, status=self.status.code) ) # ********************************************************************* # TODO: I don't like this part, find another way to delete them # directly # find other NEW Reviews and delete them reviews_to_be_deleted = [] for r in self.reviews: if r.status.code == "NEW": reviews_to_be_deleted.append(r) for r in reviews_to_be_deleted: logger.debug(f"removing {r} from task.reviews") self.reviews.remove(r) r.task = None try: DBSession.delete(r) except InvalidRequestError: # not persisted yet # do nothing pass # ********************************************************************* # create a Review instance with the given data review = Review(reviewer=reviewer, task=self) # and call request_revision in the Review instance review.request_revision( schedule_timing=schedule_timing, schedule_unit=schedule_unit, description=description, ) return review def hold(self) -> None: """Pause the execution of this task by setting its status to OH. Only applicable to RTS and WIP tasks, any task with other statuses will raise a StatusError. Also sets the priority to 0. Raises: StatusError: If the status of the task is not RTS or WIP. """ # check if status is WIP with DBSession.no_autoflush: wip = self.status_list["WIP"] drev = self.status_list["DREV"] oh = self.status_list["OH"] if self.status not in [wip, drev, oh]: raise StatusError( "{task} (id:{id}) is a {status} task, only WIP or DREV tasks can be " "set to On Hold".format( task=self.name, id=self.id, status=self.status.code ) ) # update the status to OH self.status = oh # set the priority to 0 self.priority = 0 # no need to update the status of dependencies nor parents def stop(self) -> None: """Stop this task. It is nearly equivalent to deleting this task. But this will at least preserve the TimeLogs entered for this task. It is only possible to stop WIP tasks. You can use :meth:`.resume` to resume the task. The only difference between :meth:`.hold` (other than setting the task to different statuses) is the schedule info, while the :meth:`.hold` method will preserve the schedule info, stop() will set the schedule info to the current effort. So if 2 days of effort has been entered for a 4 days task, when stopped the task effort will be capped to 2 days, thus TaskJuggler will not try to reserve any resource for this task anymore. Also, STOP tasks will be ignored in dependency relations. Raises: StatusError: If the task status is not WIP, DREV or STOP. """ # check the status with DBSession.no_autoflush: wip = self.status_list["WIP"] drev = self.status_list["DREV"] stop = self.status_list["STOP"] if self.status not in [wip, drev, stop]: raise StatusError( "{task} (id:{id})is a {status} task and it is not possible to stop a " "{status} task.".format( task=self.name, id=self.id, status=self.status.code ) ) # set the status self.status = stop # clamp schedule values self.schedule_timing, self.schedule_unit = self.least_meaningful_time_unit( self.total_logged_seconds ) # update parent statuses self.update_parent_statuses() # update dependent task statuses for dependency in self.dependent_of: dependency.update_status_with_dependent_statuses() def resume(self) -> None: """Resume the execution of this task. Resume the task by setting its status to RTS or WIP depending on its time_logs attribute, so if it has TimeLogs then it will resume as WIP and if it doesn't then it will resume as RTS. Only applicable to Tasks with status OH. Raises: StatusError: If the task status is not OH or STOP. """ # check status with DBSession.no_autoflush: wip = self.status_list["WIP"] oh = self.status_list["OH"] stop = self.status_list["STOP"] if self.status not in [oh, stop]: raise StatusError( "{task} (id:{id}) is a {status} task, and it is not suitable to be " "resumed, please supply an OH or STOP task".format( task=self.name, id=self.id, status=self.status.code ) ) else: # set to WIP self.status = wip # now update the status with dependencies self.update_status_with_dependent_statuses() # and update parents statuses self.update_parent_statuses() def review_set(self, review_number: Union[None, int] = None) -> List[Review]: """Return the reviews with the given review_number. Args: review_number (Union[None, int]): The review number. If review_number is skipped it will return the latest set of reviews. Raises: TypeError: If the review_number is not None and not an integer. ValueError: If the review_number is less than 0. Returns: List[Review]: The reviews with the given review number or the latest set of :class:`stalker.models.review.Review` instances if the review number is is skipped or None. """ review_set = [] if review_number is None: if self.status.code == "PREV": review_number = self.review_number + 1 else: review_number = self.review_number if not isinstance(review_number, int): raise TypeError( "review_number argument in {}.review_set should be a positive " "integer, not {}: '{}'".format( self.__class__.__name__, review_number.__class__.__name__, review_number, ) ) if review_number < 1: raise ValueError( "review_number argument in {}.review_set should be a positive " "integer, not {}".format(self.__class__.__name__, review_number) ) for review in self.reviews: if review.review_number == review_number: review_set.append(review) return review_set def update_status_with_dependent_statuses( self, removing: Optional["Task"] = None, ) -> None: # noqa: C901 """Update the status by looking at the dependent tasks. Args: removing (Task): The item that is being removed right now, used for the remove event to overcome the update issue. """ if self.is_container: # do nothing, its status will be decided by its children return # in case there is no database # try to find the statuses from the status_list attribute with DBSession.no_autoflush: wfd = self.status_list["WFD"] rts = self.status_list["RTS"] wip = self.status_list["WIP"] hrev = self.status_list["HREV"] drev = self.status_list["DREV"] cmpl = self.status_list["CMPL"] if removing: self._previously_removed_dependent_tasks.append(removing) else: self._previously_removed_dependent_tasks = [] # create a new list from depends_on and skip_list dependency_list = [] for dependency in self.depends_on: if dependency not in self._previously_removed_dependent_tasks: dependency_list.append(dependency) logger.debug(f"self : {self}") logger.debug(f"self.depends_on: {self.depends_on}") logger.debug(f"dependency_list: {dependency_list}") # if not self.depends_on: if not dependency_list: # doesn't have any dependency # convert its status from WFD to RTS if necessary if self.status == wfd: self.status = rts elif self.status in [wip, drev]: if len(self.time_logs): self.status = wip else: # doesn't have any TimeLogs return back to rts self.status = rts return # Keep this part for future reference # if self.id: # # use pure sql # logger.debug('using pure SQL to query dependency statuses') # sql_query = """ # select # "Statuses".code # from "Tasks" # join "Task_Dependencies" # on "Tasks".id = "Task_Dependencies".task_id # join "Tasks" as "Dependent_Tasks" # on "Task_Dependencies".depends_on_id = "Dependent_Tasks".id # join "Statuses" on "Dependent_Tasks".status_id = "Statuses".id # where "Tasks".id = {} # group by "Statuses".code # """.format(self.id) # # result = DBSession.connection().execute(sql_query) # # # convert to a binary value # binary_status = reduce( # lambda x, y: x+y, # map(lambda x: binary_status_codes[x[0]], result.fetchall()), # 0 # ) # # else: # task is not committed yet, use Python version logger.debug("using pure Python to query dependency statuses") binary_status = 0 dep_statuses = [] # with DBSession.no_autoflush: logger.debug( "self.depends_on in update_status_with_dependent_statuses: " f"{self.depends_on}" ) for dependency in dependency_list: # consider every status only once if dependency.status not in dep_statuses: dep_statuses.append(dependency.status) binary_status += BINARY_STATUS_VALUES[dependency.status.code] logger.debug(f"status of the task : {self.status.code}") logger.debug(f"binary status for dependency statuses: {binary_status}") work_alone = binary_status < 4 status = self.status if work_alone: if self.status == wfd: status = rts elif self.status == drev: status = hrev # Expand task timing with the timing resolution if there is no # time left for this task if self.total_logged_seconds == self.schedule_seconds: from stalker import defaults total_seconds = ( self.schedule_seconds + defaults.timing_resolution.seconds ) timing, unit = self.least_meaningful_time_unit(total_seconds) self.schedule_timing = timing self.schedule_unit = unit else: if self.status == rts: status = wfd elif self.status == wip: status = drev elif self.status == hrev: status = drev elif self.status == cmpl: status = drev logger.debug(f"setting status from {self.status} to {status}: ") self.status = status # also update parent statuses self.update_parent_statuses() # # also update dependent tasks # for dependency in dependency_list: # dependency.update_status_with_dependent_statuses() def update_parent_statuses(self) -> None: """Update the parent statuses of this task if any.""" # prevent query-invoked auto-flush with DBSession.no_autoflush: if self.parent: self.parent.update_status_with_children_statuses() def update_status_with_children_statuses(self) -> None: """Update the task status according to its children statuses.""" logger.debug(f"setting statuses with child statuses for: {self.name}") if not self.is_container: # do nothing logger.debug("not a container returning!") return with DBSession.no_autoflush: wfd = self.status_list["WFD"] rts = self.status_list["RTS"] wip = self.status_list["WIP"] cmpl = self.status_list["CMPL"] parent_statuses_map = [wfd, rts, wip, cmpl] # use Python logger.debug("using pure Python to query children statuses") binary_status = 0 children_statuses = [] for child in self.children: # consider every status only once if child.status not in children_statuses: children_statuses.append(child.status) binary_status += BINARY_STATUS_VALUES[child.status.code] # any condition not listed above should return the status_index of "2=WIP" status_index = CHILDREN_TO_PARENT_STATUSES_MAP.get(binary_status, 2) status = parent_statuses_map[status_index] logger.debug(f"binary statuses value : {binary_status}") logger.debug(f"setting status to : {status.code}") self.status = status # # update dependent task statuses # for dependent in self.dependent_of: # dependent.update_status_with_dependent_statuses() # go to parents self.update_parent_statuses() def _review_number_getter(self) -> None: """Return the revision number value. Returns: int: The current revision number. """ return self._review_number review_number: Mapped[Optional[int]] = synonym( "_review_number", descriptor=property(_review_number_getter), doc="returns the _review_number attribute value", ) def _template_variables(self) -> dict: """Return variables used in rendering the filename template. Returns: dict: The template variables. """ # TODO: add test for this template variables from stalker import Asset, Shot asset = None sequence = None scene = None shot = None if isinstance(self, Shot): shot = self sequence = self.sequence scene = self.scene elif isinstance(self, Asset): asset = self else: # Look for shots in parents for parent in self.parents: if isinstance(parent, Shot): sequence = parent.sequence scene = parent.scene break elif isinstance(parent, Asset): asset = parent break # get the parent tasks task = self parent_tasks = task.parents parent_tasks.append(task) return { "project": self.project, "sequence": sequence, "scene": scene, "shot": shot, "asset": asset, "task": self, "parent_tasks": parent_tasks, "type": self.type, } @property def path(self) -> str: """Return the rendered file path of this Task. The path attribute will generate a path suitable for placing the files under it. It will use the :class:`.FilenameTemplate` class related to the :class:`.Project` :class:`.Structure` with the ``target_entity_type`` is set to the type of this instance. Raises: RuntimeError: If no :class:`stalker.models.template.FilenameTemplate` instance found in the :class:`stalker.models.structure.Structure` of the related :class:`stalker.models.project.Project`. Returns: str: The rendered file path of this Task. """ # get a suitable FilenameTemplate structure = self.project.structure task_template = None if structure: for template in structure.templates: if template.target_entity_type == self.entity_type: task_template = template break if not task_template: raise RuntimeError( "There are no suitable FilenameTemplate " "(target_entity_type == '{entity_type}') defined in the " "Structure of the related Project instance, please create a " "new stalker.models.template.FilenameTemplate instance with " "its 'target_entity_type' attribute is set to '{entity_type}' " "and assign it to the `templates` attribute of the structure " "of the project".format(entity_type=self.entity_type) ) return os.path.normpath( Template(task_template.path).render( **self._template_variables(), trim_blocks=True, lstrip_blocks=True, ) ).replace("\\", "/") @property def absolute_path(self) -> str: """Return the absolute file path of this task. This is the absolute version of the :attr:`.Task.path` attribute and depends on the :class:`stalker.models.template.FilenameTemplate` found in the :class:`stalker.models.structure.Structure` instance of the related :class:`stalker.models.project.Project` instance. Returns: str: The rendered absolute file path of this task. """ return os.path.normpath(os.path.expandvars(self.path)).replace("\\", "/") class TaskDependency(Base, ScheduleMixin): """The association object used in Task-to-Task dependency relation.""" from stalker import defaults __default_schedule_attr_name__ = "gap" # used in docstring of ScheduleMixin __default_schedule_model__ = ScheduleModel.Length __default_schedule_timing__ = 0 __default_schedule_unit__ = TimeUnit.Hour __tablename__ = "Task_Dependencies" # depends_on_id depends_on_id: Mapped[int] = mapped_column( ForeignKey("Tasks.id"), primary_key=True, ) # depends_on depends_on: Mapped[Task] = relationship( back_populates="task_dependent_of", primaryjoin="Task.task_id==TaskDependency.depends_on_id", ) # task_id task_id: Mapped[int] = mapped_column(ForeignKey("Tasks.id"), primary_key=True) # task task: Mapped[Task] = relationship( back_populates="task_depends_on", primaryjoin="Task.task_id==TaskDependency.task_id", ) dependency_target: Mapped[DependencyTarget] = mapped_column( DependencyTargetDecorator(), nullable=False, doc="""The dependency target of the relation. The default value is "onend", which will create a dependency between two tasks so that the depending task will start after the task that it is depending on is finished. The dependency_target attribute is updated to :attr:`.DependencyTarget.OnStart` when a task has a revision and needs to work together with its depending tasks. """, default=DependencyTarget.OnStart, ) gap_timing: Mapped[Optional[float]] = synonym( "schedule_timing", doc="""A positive float value showing the desired gap between the dependent and dependee tasks. The meaning of the gap value, either is it *work time* or *calendar time* is defined by the :attr:`.gap_model` attribute. So when the gap model is "duration" then the value of `gap` is in calendar time, if `gap` is "length" then it is considered as work time. """, ) gap_unit: Mapped[Optional[str]] = synonym("schedule_unit") gap_model: Mapped[str] = synonym( "schedule_model", doc="""An enumeration value one of [:attr:`.ScheduleModel.Length`, :attr:`.ScheduleModel.Duration`]. The value of this attribute defines if the :attr:`.gap` value is in *Work Time* or *Calendar Time*. The default value is :attr:`.ScheduleModel.Length` so the gap value defines a time interval in work time. """, ) def __init__( self, task: Optional["Task"] = None, depends_on: Optional["Task"] = None, dependency_target: Optional[str] = None, gap_timing: Optional[Union[float, int]] = 0, gap_unit: Optional[TimeUnit] = TimeUnit.Hour, gap_model: Optional[ScheduleModel] = ScheduleModel.Length, ) -> None: ScheduleMixin.__init__( self, schedule_timing=gap_timing, schedule_unit=gap_unit, schedule_model=gap_model, ) self.task = task self.depends_on = depends_on self.dependency_target = dependency_target @validates("task") def _validate_task(self, key: str, task: Task) -> Task: """Validate the task value. Args: key (str): The name of the validated column. task (Task): The task value to be validated. Raises: TypeError: If the given task value is not None and not a :class:`stalker.models.task.Task` instance. Returns: Task: The validated task value. """ # trust to the session for checking the task if task is not None and not isinstance(task, Task): raise TypeError( "{}.task should be and instance of stalker.models.task.Task, " "not {}: '{}'".format( self.__class__.__name__, task.__class__.__name__, task ) ) return task @validates("depends_on") def _validate_depends_on(self, key: str, dependency: Task) -> Task: """Validate the task value. Args: key (str): The name of the validated column. dependency (Task): The depends_on value to be validated. Raises: TypeError: If the given depends_on value is not None and not a :class:`stalker.models.task.Task` instance. Returns: Task: The validated depends_on value. """ # trust to the session for checking the depends_on attribute if dependency is not None and not isinstance(dependency, Task): raise TypeError( "{}.depends_on should be and instance of stalker.models.task.Task, " "not {}: '{}'".format( self.__class__.__name__, dependency.__class__.__name__, dependency ) ) return dependency @validates("dependency_target") def _validate_dependency_target( self, key: str, dependency_target: Union[None, str, DependencyTarget] ) -> DependencyTarget: """Validate the given dependency_target value. Args: key (str): The name of the validated column. dependency_target (Union[None, str, DependencyTarget]): The dependency_target value to be validated. Returns: DependencyTarget: The validated dependency_target value. """ from stalker import defaults if dependency_target is None: dependency_target = defaults.task_dependency_targets[0] dependency_target = DependencyTarget.to_target(dependency_target) return dependency_target @property def to_tjp(self) -> str: """Return the TaskJuggler representation of this TaskDependency. Returns: str: The TaskJuggler representation of this TaskDependency. """ tjp = f"{self.depends_on.tjp_abs_id} {{{self.dependency_target}" if self.gap_timing: tjp += f" gap{self.gap_model} {self.gap_timing}{self.gap_unit}" tjp += "}" return tjp # TASK_RESOURCES Task_Resources = Table( "Task_Resources", Base.metadata, Column("task_id", Integer, ForeignKey("Tasks.id"), primary_key=True), Column("resource_id", Integer, ForeignKey("Users.id"), primary_key=True), ) # TASK_ALTERNATIVE_RESOURCES Task_Alternative_Resources = Table( "Task_Alternative_Resources", Base.metadata, Column("task_id", Integer, ForeignKey("Tasks.id"), primary_key=True), Column("resource_id", Integer, ForeignKey("Users.id"), primary_key=True), ) # TASK_COMPUTED_RESOURCES Task_Computed_Resources = Table( "Task_Computed_Resources", Base.metadata, Column("task_id", Integer, ForeignKey("Tasks.id"), primary_key=True), Column("resource_id", Integer, ForeignKey("Users.id"), primary_key=True), ) # TASK_WATCHERS Task_Watchers = Table( "Task_Watchers", Base.metadata, Column("task_id", Integer, ForeignKey("Tasks.id"), primary_key=True), Column("watcher_id", Integer, ForeignKey("Users.id"), primary_key=True), ) # TASK_RESPONSIBLE Task_Responsible = Table( "Task_Responsible", Base.metadata, Column("task_id", Integer, ForeignKey("Tasks.id"), primary_key=True), Column("responsible_id", Integer, ForeignKey("Users.id"), primary_key=True), ) # ***************************************************************************** # Register Events # ***************************************************************************** # ***************************************************************************** # TimeLog updates the owner tasks parents total_logged_seconds attribute # with new duration @event.listens_for(TimeLog._start, "set") def update_time_log_task_parents_for_start( timelog: TimeLog, new_start: datetime.datetime, old_start: datetime.datetime, initiator: AttributeEvent, ) -> None: """Update the parent task of the TimeLog.task if the new_start value is changed. Args: timelog (TimeLog): The TimeLog instance. new_start (datetime.datetime): The datetime.datetime instance showing the new value. old_start (datetime.datetime): The datetime.datetime instance showing the old value. initiator (AttributeEvent): Currently not used. """ logger.debug(f"Received set event for new_start in target : {timelog}") if timelog.end and old_start and new_start: old_duration = timelog.end - old_start new_duration = timelog.end - new_start __update_total_logged_seconds__(timelog, new_duration, old_duration) @event.listens_for(TimeLog._end, "set") def update_time_log_task_parents_for_end( timelog: TimeLog, new_end: datetime.datetime, old_end: datetime.datetime, initiator: sqlalchemy.orm.attributes.AttributeEvent, ) -> None: """Update the parent task of the TimeLog.task if the new_end value is changed. Args: timelog (TimeLog): The TimeLog instance. new_end (datetime.datetime): The datetime.datetime instance showing the new value. old_end (datetime.datetime): The datetime.datetime instance showing the old value. initiator (sqlalchemy.orm.attributes.AttributeEvent): Currently not used. """ logger.debug(f"Received set event for new_end in target: {timelog}") if ( timelog.start and isinstance(old_end, datetime.datetime) and isinstance(new_end, datetime.datetime) ): old_duration = old_end - timelog.start new_duration = new_end - timelog.start __update_total_logged_seconds__(timelog, new_duration, old_duration) def __update_total_logged_seconds__( time_log: TimeLog, new_duration: datetime.timedelta, old_duration: datetime.timedelta, ) -> None: """Update the given parent tasks total_logged_seconds attr with the new duration. Args: time_log (TimeLog): A :class:`.Task` instance which is the parent of the. new_duration (datetime.timedelta): The new duration value. old_duration (datetime.timedelta): The old duration value. """ # if not time_log.task: # logger.debug(f"TimeLog doesn't have a task yet: {time_log}") # return logger.debug(f"TimeLog has a task: {time_log.task}") parent = time_log.task.parent if not parent: logger.debug("TimeLog.task doesn't have a parent!") return logger.debug(f"TImeLog.task has a parent: {parent}") logger.debug(f"old_duration: {old_duration}") logger.debug(f"new_duration: {new_duration}") old_total_seconds = old_duration.days * 86400 + old_duration.seconds new_total_seconds = new_duration.days * 86400 + new_duration.seconds parent.total_logged_seconds = ( parent.total_logged_seconds - old_total_seconds + new_total_seconds ) # ***************************************************************************** # Task.schedule_timing updates Task.parent.schedule_seconds attribute # ***************************************************************************** @event.listens_for(Task.schedule_timing, "set", propagate=True) def update_parents_schedule_seconds_with_schedule_timing( task: Task, new_schedule_timing: int, old_schedule_timing: int, initiator: sqlalchemy.orm.attributes.AttributeEvent, ) -> None: """Update parent task's schedule_seconds attr if schedule_timing attr is updated. Args: task (Task): The base task. new_schedule_timing (int): An integer showing the schedule_timing of the task. old_schedule_timing (int): The old value of schedule_timing. initiator (sqlalchemy.orm.attribute.AttributeEvent): Currently not used. """ logger.debug(f"Received set event for new_schedule_timing in target: {task}") # update parents schedule_seconds attribute if not task.parent: return old_schedule_seconds = task.to_seconds( old_schedule_timing, task.schedule_unit, task.schedule_model ) new_schedule_seconds = task.to_seconds( new_schedule_timing, task.schedule_unit, task.schedule_model ) # remove the old and add the new one task.parent.schedule_seconds = ( task.parent.schedule_seconds - old_schedule_seconds + new_schedule_seconds ) # ***************************************************************************** # Task.schedule_unit updates Task.parent.schedule_seconds attribute # ***************************************************************************** @event.listens_for(Task.schedule_unit, "set", propagate=True) def update_parents_schedule_seconds_with_schedule_unit( task: Task, new_schedule_unit: str, old_schedule_unit: str, initiator: sqlalchemy.orm.attributes.AttributeEvent, ) -> None: """Update parent task's schedule_seconds attr if new_schedule_unit attr is updated. Args: task (Task): The base task that the schedule unit is updated of. new_schedule_unit (str): A string with a value of 'min', 'h', 'd', 'w', 'm' or 'y' showing the timing unit. old_schedule_unit (str): The old value of new_schedule_unit. initiator (sqlalchemy.orm.attribute.AttributeEvent): Currently not used. """ logger.debug(f"Received set event for new_schedule_unit in target: {task}") # update parents schedule_seconds attribute if not task.parent: return schedule_timing = 0 if task.schedule_timing: schedule_timing = task.schedule_timing old_schedule_seconds = task.to_seconds( schedule_timing, old_schedule_unit, task.schedule_model ) new_schedule_seconds = task.to_seconds( schedule_timing, new_schedule_unit, task.schedule_model ) # remove the old and add the new one parent_schedule_seconds = 0 if task.parent.schedule_seconds: parent_schedule_seconds = task.parent.schedule_seconds task.parent.schedule_seconds = ( parent_schedule_seconds - old_schedule_seconds + new_schedule_seconds ) # ***************************************************************************** # Task.children removed # ***************************************************************************** @event.listens_for(Task.children, "remove", propagate=True) def update_task_date_values( task: Task, removed_child: Task, initiator: sqlalchemy.orm.attributes.AttributeEvent ) -> None: """Run when a child is removed from parent. Args: task (Task): The task that a child is removed from. removed_child (Task): The removed child. initiator (sqlalchemy.orm.attribute.AttributeEvent): Currently not used. """ # update start and end date values of the task with DBSession.no_autoflush: start = datetime.datetime.max.replace(tzinfo=pytz.utc) end = datetime.datetime.min.replace(tzinfo=pytz.utc) for child in task.children: if child is not removed_child: if child.start < start: start = child.start if child.end > end: end = child.end max_date = datetime.datetime.max.replace(tzinfo=pytz.utc) min_date = datetime.datetime.min.replace(tzinfo=pytz.utc) if start != max_date and end != min_date: task.start = start task.end = end else: # no child left # set it to now task.start = datetime.datetime.now(pytz.utc) # this will also update end # ***************************************************************************** # Task.depends_on set # ***************************************************************************** @event.listens_for(Task.task_depends_on, "remove", propagate=True) def removed_a_dependency( task: Task, task_dependency: TaskDependency, initiator: sqlalchemy.orm.attributes.AttributeEvent, ) -> None: """Update statuses when a task is removed from another tasks dependency list. Args: task (Task): The task that a dependent is being removed from. task_dependency (TaskDependency): The association object that has the relation. initiator (sqlalchemy.orm.attributes.AttributeEvent): Currently not used. """ # update task status with dependencies task.update_status_with_dependent_statuses(removing=task_dependency.depends_on) @event.listens_for(TimeLog.__table__, "after_create") def add_exclude_constraint( table: sqlalchemy.sql.schema.Table, connection: sqlalchemy.engine.base.Connection, **kwargs, ) -> None: """Add the PostgreSQL specific ExcludeConstraint. Args: table (sqlalchemy.sql.schema.Table): The table that this event is triggered on. connection (sqlalchemy.engine.base.Connection): The connection instance. **kwargs (Any): Extra kwargs that are passed to the event. """ if connection.engine.dialect.name != "postgresql": logger.debug("it is not a PostgreSQL database not creating Exclude Constraint") return logger.debug("add_exclude_constraint is Running!") # try to create the extension first create_extension = DDL("CREATE EXTENSION btree_gist;") try: logger.debug('running "btree_gist" extension creation!') connection.execute(create_extension) logger.debug('successfully created "btree_gist" extension!') except (ProgrammingError, InternalError) as e: logger.debug(f"add_exclude_constraint: {e}") # create the ts_to_box sql function ts_to_box = DDL( """CREATE FUNCTION ts_to_box(TIMESTAMPTZ, TIMESTAMPTZ) RETURNS BOX AS $$ SELECT BOX( POINT(DATE_PART('epoch', $1), 0), POINT(DATE_PART('epoch', $2 - interval '1 minute'), 1) ) $$ LANGUAGE 'sql' IMMUTABLE; """ ) try: logger.debug("creating ts_to_box function!") connection.execute(ts_to_box) logger.debug("successfully created ts_to_box function") except (ProgrammingError, InternalError) as e: logger.debug(f"failed creating ts_to_box function!: {e}") # create exclude constraint exclude_constraint = DDL( """ALTER TABLE "TimeLogs" ADD CONSTRAINT overlapping_time_logs EXCLUDE USING GIST ( resource_id WITH =, ts_to_box(start, "end") WITH && )""" ) try: logger.debug('running ExcludeConstraint for "TimeLogs" table creation!') connection.execute(exclude_constraint) logger.debug('successfully created ExcludeConstraint for "TimeLogs" table!') except (ProgrammingError, InternalError) as e: logger.debug(f"failed creating ExcludeConstraint for TimeLogs table!: {e}") ================================================ FILE: src/stalker/models/template.py ================================================ # -*- coding: utf-8 -*- """FilenameTemplate related functions and classes are situated here.""" from typing import Any, Dict, Optional, Union from sqlalchemy import ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, validates from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import TargetEntityTypeMixin logger = get_logger(__name__) class FilenameTemplate(Entity, TargetEntityTypeMixin): """Holds templates for filename and path conventions. FilenameTemplate objects help to specify where to place a :class:`.Version` related file. Although, it is mainly used by Stalker to define :class:`.Version` related file paths and file names to place them in to proper places inside a :class:`.Project`'s :attr:`.Project.structure`, the idea behind is open to endless possibilities. Here is an example:: p1 = Project(name="Test Project") # shortened for this example # shortened for this example s1 = Structure(name="Commercial Project Structure") # this is going to be used by Stalker to decide the :stalker:`.File` # :stalker:`.File.filename` and :stalker:`.File.path` (which is the way # Stalker links external files to Version instances) f1 = FilenameTemplate( name="Asset Version Template", target_entity_type="Asset", path='$REPO{{project.repository.id}}/{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%}", filename="{{version.nice_name}}_v{{"%03d"|format(version.version_number)}}" ) s1.templates.append(f1) p1.structure = s1 # now because we have defined a FilenameTemplate for Assets, # Stalker is now able to produce a path and a filename for any Version # related to an asset in this project. Args: target_entity_type (str): The class name that this FilenameTemplate is designed for. You can also pass the class itself. So both of the examples below can work:: new_filename_template1 = FilenameTemplate(target_entity_type="Asset") new_filename_template2 = FilenameTemplate(target_entity_type=Asset) A TypeError will be raised when it is skipped or it is None and a ValueError will be raised when it is given as and empty string. path (str): A `Jinja2`_ template code which specifies the path of the given item. It is relative to the repository root. A typical example could be:: '$REPO{{project.repository.id}}/{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%}' filename (str): A `Jinja2`_ template code which specifies the file name of the given item. It is relative to the :attr:`.FilenameTemplate.path`. A typical example could be:: '{{version.nice_name}}_v{{"%03d"|format(version.version_number)}}' Could be set to an empty string or None, the default value is None. It can be None, or an empty string, or it can be skipped. .. _Jinja2: http://jinja.pocoo.org/docs/ """ # noqa: B950 __auto_name__ = False __strictly_typed__ = False __tablename__ = "FilenameTemplates" __mapper_args__ = {"polymorphic_identity": "FilenameTemplate"} filenameTemplate_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) path: Mapped[Optional[str]] = mapped_column( Text, doc="""The template code for the path of this FilenameTemplate.""" ) filename: Mapped[Optional[str]] = mapped_column( Text, doc="""The template code for the file part of the FilenameTemplate.""" ) def __init__( self, target_entity_type: Optional[str] = None, path: Optional[str] = None, filename: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: super(FilenameTemplate, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, target_entity_type, **kwargs) self.path = path self.filename = filename @validates("path") def _validate_path(self, key: str, path: Union[None, str]) -> str: """Validate the given path value. Args: key (str): The name of the validated column. path (Union[None, str]): The path value to be validated. Raises: TypeError: If the given path value is not None and not a string. Returns: str: The validated path value. """ # check if it is None if path is None: path = "" if not isinstance(path, str): raise TypeError( "{}.path attribute should be string, not {}: '{}'".format( self.__class__.__name__, path.__class__.__name__, path ) ) return path @validates("filename") def _validate_filename(self, key: str, filename: Union[None, str]) -> str: """Validate the given filename value. Args: key (str): The name of the validated column. filename (Union[None, str]): The filename value to be validated. Raises: TypeError: If the given filename value is not None and not a string. Returns: str: The validated filename value. """ # check if it is None if filename is None: filename = "" if not isinstance(filename, str): raise TypeError( "{}.filename attribute should be string, not {}: '{}'".format( self.__class__.__name__, filename.__class__.__name__, filename ) ) return filename def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a FilenameTemplate instance and has the same target_entity_type, path and filename. """ return ( super(FilenameTemplate, self).__eq__(other) and isinstance(other, FilenameTemplate) and self.target_entity_type == other.target_entity_type and self.path == other.path and self.filename == other.filename ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(FilenameTemplate, self).__hash__() ================================================ FILE: src/stalker/models/ticket.py ================================================ # -*- coding: utf-8 -*- """Ticket related functions and classes are situated here.""" import uuid from typing import Any, Dict, List, Optional, Union from sqlalchemy import Column, Integer, String, Text from sqlalchemy.exc import OperationalError, UnboundExecutionError from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym from sqlalchemy.orm.mapper import validates from sqlalchemy.schema import ForeignKey, Table from sqlalchemy.types import Enum from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError from stalker.log import get_logger from stalker.models.auth import User from stalker.models.entity import Entity, SimpleEntity from stalker.models.mixins import StatusMixin from stalker.models.note import Note from stalker.models.project import Project from stalker.models.status import Status logger = get_logger(__name__) # RESOLUTIONS FIXED = "fixed" INVALID = "invalid" WONTFIX = "wontfix" DUPLICATE = "duplicate" WORKSFORME = "worksforme" CANTFIX = "cantfix" class Ticket(Entity, StatusMixin): """Tickets are the way of reporting errors or asking for changes. The Stalker Ticketing system is based on Trac Basic Workflow. For more information please visit `Trac Workflow`_ _`Trac Workflow`:: http://trac.edgewall.org/wiki/TracWorkflow Stalker Ticket system is very flexible, to customize the workflow please update the :class:`.Config.ticket_workflow` dictionary. In the default setup, there are four actions available; ``accept``, ``resolve``, ``reopen``, ``reassign``, and five statuses available ``New``, ``Assigned``, ``Accepted``, ``Reopened``, ``Closed``. Args: project (Project): The Project that this Ticket is assigned to. A Ticket in Stalker must be assigned to a Project. ``project`` argument cannot be skipped or cannot be None. summary (str): A string which contains the title or a short description of this Ticket. priority (str): The priority of the Ticket which is an enum value. Possible values are: +--------------+-------------------------------------------------+ | 0 / TRIVIAL | defect with little or no impact / cosmetic | | | enhancement | +--------------+-------------------------------------------------+ | 1 / MINOR | defect with minor impact / small enhancement | +--------------+-------------------------------------------------+ | 2 / MAJOR | defect with major impact / big enhancement | +--------------+-------------------------------------------------+ | 3 / CRITICAL | severe loss of data due to the defect or highly | | | needed enhancement | +--------------+-------------------------------------------------+ | 4 / BLOCKER | basic functionality is not available until this | | | is fixed | +--------------+-------------------------------------------------+ reported_by (User): A :class:`.User` instance who created this Ticket. It is basically a synonym for the :attr:`.SimpleEntity.created_by` attribute. Changing the :attr`.Ticket.status` will create a new :class:`.TicketLog` instance showing the previous operation. Even though Tickets needs statuses they don't need to be supplied a :class:`.StatusList` nor :class:`.Status` for the Tickets. It will be automatically filled accordingly. For newly created Tickets the status of the ticket is ``NEW`` and can be changed to other statuses as follows: Status -> Action -> New Status NEW -> resolve -> CLOSED NEW -> accept -> ACCEPTED NEW -> reassign -> ASSIGNED ASSIGNED -> resolve -> CLOSED ASSIGNED -> accept -> ACCEPTED ASSIGNED -> reassign -> ASSIGNED ACCEPTED -> resolve -> CLOSED ACCEPTED -> accept -> ACCEPTED ACCEPTED -> reassign -> ASSIGNED REOPENED -> resolve -> CLOSED REOPENED -> accept -> ACCEPTED REOPENED -> reassign -> ASSIGNED CLOSED -> reopen -> REOPENED actions available: resolve reassign accept reopen The :attr:`.Ticket.name` is automatically generated by using the ``stalker.config.Config.ticket_label`` attribute and :attr:`.Ticket.ticket_number` . So if defaults are used the first ticket name will be "Ticket#1" and the second "Ticket#2" and so on. For every project the number will restart from 1. Use the :meth:`.Ticket.resolve`, :meth:`.Ticket.reassign`, :meth:`.Ticket.accept`, :meth:`.Ticket.reopen` methods to change the status of the current Ticket. Changing the status of the Ticket will create :class:`.TicketLog` entries reflecting the change made. """ # logs attribute __auto_name__ = True __tablename__ = "Tickets" # __table_args__ = ( # UniqueConstraint("project_id", 'number'), {} # ) __mapper_args__ = {"polymorphic_identity": "Ticket"} ticket_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) # TODO: use ProjectMixin project_id: Mapped[int] = mapped_column("project_id", ForeignKey("Projects.id")) _project: Mapped[Project] = relationship( primaryjoin="Tickets.c.project_id==Projects.c.id", back_populates="tickets", ) _number: Mapped[int] = mapped_column( "number", autoincrement=True, default=1, nullable=False, unique=True, ) related_tickets: Mapped[Optional[List["Ticket"]]] = relationship( secondary="Ticket_Related_Tickets", primaryjoin="Tickets.c.id==Ticket_Related_Tickets.c.ticket_id", secondaryjoin="Ticket_Related_Tickets.c.related_ticket_id==" "Tickets.c.id", doc="""A list of other Ticket instances which are related to this one. Can be used to related Tickets to point to a common problem. The Ticket itself cannot be assigned to this list """, ) summary: Mapped[Optional[str]] = mapped_column(Text) logs: Mapped[Optional[List["TicketLog"]]] = relationship( primaryjoin="Tickets.c.id==TicketLogs.c.ticket_id", back_populates="ticket", cascade="all, delete-orphan", ) links: Mapped[Optional[List["SimpleEntity"]]] = relationship( secondary="Ticket_SimpleEntities" ) comments: Mapped[Optional[List["Note"]]] = synonym( "notes", doc="""A list of :class:`.Note` instances showing the comments made for this Ticket instance. It is a synonym for the :attr:`.Ticket.notes` attribute. """, ) reported_by: Mapped[Optional["User"]] = synonym( "created_by", doc="Shows who created this Ticket" ) owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Users.id")) owner: Mapped["User"] = relationship(primaryjoin="Tickets.c.owner_id==Users.c.id") resolution: Mapped[Optional[str]] = mapped_column(String(128)) priority: Mapped[Optional[str]] = mapped_column( Enum("TRIVIAL", "MINOR", "MAJOR", "CRITICAL", "BLOCKER", name="PriorityType"), default="TRIVIAL", doc="""The priority of the Ticket which is an enum value. Possible values are: +--------------+-------------------------------------------------+ | 0 / TRIVIAL | defect with little or no impact / cosmetic | | | enhancement | +--------------+-------------------------------------------------+ | 1 / MINOR | defect with minor impact / small enhancement | +--------------+-------------------------------------------------+ | 2 / MAJOR | defect with major impact / big enhancement | +--------------+-------------------------------------------------+ | 3 / CRITICAL | severe loss of data due to the defect or highly | | | needed enhancement | +--------------+-------------------------------------------------+ | 4 / BLOCKER | basic functionality is not available until this | | | is fixed | +--------------+-------------------------------------------------+ """, ) def __init__( self, project: Optional[Project] = None, links: Optional[List[SimpleEntity]] = None, priority: str = "TRIVIAL", summary: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: # just force auto name generation self._number = self._generate_ticket_number() from stalker import defaults kwargs["name"] = "{:s} #{:d}".format(defaults.ticket_label, self.number) super(Ticket, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) self._project = project self.priority = priority if links is None: links = [] self.links = links self.summary = summary def _number_getter(self) -> int: """Return the number attribute value. Returns: int: The number attribute value. """ return self._number number = synonym( "_number", descriptor=property(_number_getter), doc="""The automatically generated number for the tickets. """, ) def _project_getter(self) -> Project: """Return the project attribute value. Returns: Project: The project attribute value. """ return self._project project = synonym("_project", descriptor=property(_project_getter)) @classmethod def _maximum_number(cls) -> int: """Return the maximum available number from the database. Returns: int: The maximum ticket number. """ try: # do your query with DBSession.no_autoflush: max_ticket = Ticket.query.order_by(Ticket.number.desc()).first() except (UnboundExecutionError, OperationalError): max_ticket = None return max_ticket.number if max_ticket is not None else 0 def _generate_ticket_number(self) -> int: """Auto generate a number for the ticket. Returns: int: The auto generated ticket number. """ # TODO: try to make it atomic return self._maximum_number() + 1 @validates("related_tickets") def _validate_related_tickets(self, key: str, related_ticket: "Ticket") -> "Ticket": """Validate the given related_ticket value. Args: key (str): The name of the validated column. related_ticket (Ticket): The related_ticket value to be validated. Raises: TypeError: If the related_ticket value is not a Ticket instance. CircularDependencyError: If the related_ticket value is the same with this Ticket. Returns: Ticket: The validated related_ticket value. """ if not isinstance(related_ticket, Ticket): raise TypeError( "{}.related_ticket should only contain instances of " "stalker.models.ticket.Ticket, not {}: '{}'".format( self.__class__.__name__, related_ticket.__class__.__name__, related_ticket, ) ) if related_ticket is self: raise CircularDependencyError( "{}.related_ticket attribute cannot have itself in the list".format( self.__class__.__name__ ) ) return related_ticket @validates("_project") def _validate_project( self, key: str, project: Union[None, Project] ) -> Union[None, Project]: """Validate the given project value. Args: key (str): The name of the validated column. project (Union[None, Project]): The project value to be validated. Raises: TypeError: If the given project is not a Project instance. Returns: Union[None, Project]: The validated project value. """ if project is None or not isinstance(project, Project): raise TypeError( "{}.project should be an instance of " "stalker.models.project.Project, not {}: '{}'".format( self.__class__.__name__, project.__class__.__name__, project ) ) return project @validates("summary") def _validate_summary(self, key: str, summary: Union[None, str]) -> str: """Validate the given summary value. Args: key (str): The name of the validated column. summary (Union[None, str]): The summary value to be validated. Raises: TypeError: If the given summary is not None and not a string. Returns: str: The validated summary value. """ if summary is None: summary = "" if not isinstance(summary, str): raise TypeError( "{}.summary should be an instance of str, not {}: '{}'".format( self.__class__.__name__, summary.__class__.__name__, summary ) ) return summary def __action__( self, action: str, created_by: User, action_arg: Any = None ) -> "TicketLog": """Update the ticket status and create a ticket log. The log is created according to the Ticket.__available_actions__ dictionary. Args: action (str): The name of the action. created_by (User): The User creating this action. action_arg (Any): The argument to pass to the action. Returns: TicketLog: The TicketLog instance created. """ from stalker import defaults statuses = defaults.ticket_workflow[action].keys() status = self.status.name return_value = None if status in statuses: action_data = defaults.ticket_workflow[action][status] new_status_code = action_data["new_status"] action_name = action_data["action"] # there is an action defined for this status # get the to_status from_status = self.status to_status = self.status_list[new_status_code] self.status = to_status # call the action with action_arg func = getattr(self, action_name) func(action_arg) ticket_log = TicketLog( self, from_status, to_status, action, created_by=created_by ) # create log entry self.logs.append(ticket_log) return_value = ticket_log return return_value def resolve( self, created_by: Union[None, User] = None, resolution: str = "" ) -> "TicketLog": """Resolve the ticket. Args: created_by (User): The User instance who is taking this action. resolution (str): The resolution. Returns: TicketLog: The newly created TicketLog instance. """ return self.__action__("resolve", created_by, resolution) def accept(self, created_by: Union[None, User] = None) -> "TicketLog": """Accept the ticket. Args: created_by (User): The User instance who is taking this action. Returns: TicketLog: The newly created TicketLog instance. """ return self.__action__("accept", created_by, created_by) def reassign( self, created_by: Union[None, User] = None, assign_to: Union[None, User] = None ) -> "TicketLog": """Reassign the ticket to another User. Args: created_by (User): The User that is doing the action. assign_to (User): The new owner of the ticket. Returns: TicketLog: The newly created TicketLog instance. """ return self.__action__("reassign", created_by, assign_to) def reopen(self, created_by: Union[None, User] = None) -> "TicketLog": """Reopen the ticket. Args: created_by (User): The User who is doing the action. Returns: TicketLog: The newly created TicketLog instance. """ return self.__action__("reopen", created_by) # actions def set_owner(self, *args) -> None: """Set the owner. Args: args (Any): Set the owner. """ self.owner = args[0] def set_resolution(self, *args) -> None: """Set the timing_resolution. Args: args (Any): Any argument passed to this method. """ self.resolution = args[0] def del_resolution(self, *args) -> None: """Delete the timing_resolution. Args: args (Any): Any arguments passed to this method. """ self.resolution = "" def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is a Ticket instance and has the same name, number, status, logs and priority. """ return ( super(Ticket, self).__eq__(other) and isinstance(other, Ticket) and other.name == self.name and other.number == self.number and other.status == self.status and other.logs == self.logs and other.priority == self.priority ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Ticket, self).__hash__() class TicketLog(SimpleEntity): """Holds :attr:`.Ticket.status` change operations. Args: ticket (Ticket): A :class:`.Ticket` instance which is the subject of the operation. from_status (Status): Holds a reference to a :class:`.Status` instance which is the previous status of the :class:`.Ticket` . to_status (Status): Holds a reference to a :class:`.Status` instance which is the new status of the :class;`.Ticket` . action (str): An Enumerator holding the type of the operation should be one of ["resolve", "accept", "reassign", "reopen"]. Operations follow the `Track Workflow`_ , .. image:: http://trac.edgewall.org/chrome/common/guide/original-workflow.png :width: 787 px :height: 509 px :align: left .. _Track Workflow: http://trac.edgewall.org/wiki/TracWorkflow """ from stalker import defaults # need to limit it with a scope # TODO: there are no tests for the TicketLog class __tablename__ = "TicketLogs" __mapper_args__ = {"polymorphic_identity": "TicketLog"} ticket_log_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) from_status_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Statuses.id")) to_status_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Statuses.id")) from_status: Mapped[Status] = relationship( primaryjoin="TicketLogs.c.from_status_id==Statuses.c.id" ) to_status: Mapped[Status] = relationship( primaryjoin="TicketLogs.c.to_status_id==Statuses.c.id" ) action: Mapped[Optional[str]] = mapped_column( Enum(*defaults.ticket_workflow.keys(), name="TicketActions") ) ticket_id: Mapped[Optional[int]] = mapped_column(ForeignKey("Tickets.id")) ticket: Mapped[Optional[Ticket]] = relationship( primaryjoin="TicketLogs.c.ticket_id==Tickets.c.id", back_populates="logs", ) def __init__( self, ticket: Optional[Ticket] = None, from_status: Optional[Status] = None, to_status: Optional[Status] = None, action: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["name"] = "TicketLog_" + uuid.uuid4().hex super(TicketLog, self).__init__(**kwargs) self.ticket = ticket self.from_status = from_status self.to_status = to_status self.action = action # A secondary Table for Ticket to Ticket relations Ticket_Related_Tickets = Table( "Ticket_Related_Tickets", Base.metadata, Column("ticket_id", Integer, ForeignKey("Tickets.id"), primary_key=True), Column("related_ticket_id", Integer, ForeignKey("Tickets.id"), primary_key=True), extend_existing=True, ) # Ticket SimpleEntity Relation, link anything to a ticket Ticket_SimpleEntities = Table( "Ticket_SimpleEntities", Base.metadata, Column("ticket_id", Integer, ForeignKey("Tickets.id"), primary_key=True), Column( "simple_entity_id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ), ) ================================================ FILE: src/stalker/models/type.py ================================================ # -*- coding: utf-8 -*- """Type related functions and classes are situated here.""" from typing import Any, Dict, Optional from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column from stalker.db.declarative import Base from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.mixins import CodeMixin, TargetEntityTypeMixin logger = get_logger(__name__) class Type(Entity, TargetEntityTypeMixin, CodeMixin): """Everything can have a type. .. versionadded:: 0.1.1 Types Type is a generalized version of the previous design that defines types for specific classes. The purpose of the :class:`.Type` class is just to define a new type for a specific :class:`.Entity`. For example, you can have a ``Character`` :class:`.Asset` or you can have a ``Commercial`` :class:`.Project` or you can define a :class:`.File` as an ``Image`` etc., to create a new :class:`.Type` for various classes: ..code-block: Python Type(name="Character", target_entity_type="Asset") Type(name="Commercial", target_entity_type="Project") Type(name="Image", target_entity_type="File") or: ..code-block: Python Type(name="Character", target_entity_type=Asset.entity_type) Type(name="Commercial", target_entity_type=Project.entity_type) Type(name="Image", target_entity_type=File.entity_type) or even better: ..code-block: Python Type(name="Character", target_entity_type=Asset) Type(name="Commercial", target_entity_type=Project) Type(name="Image", target_entity_type=File) By using :class:`.Type` s, one can able to sort and group same type of entities. :class:`.Type` s are generally used in :class:`.Structure` s. Args: target_entity_type (str): The string defining the target type of this :class:`.Type`. """ __auto_name__ = False __tablename__ = "Types" __mapper_args__ = {"polymorphic_identity": "Type"} type_id_local: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) def __init__( self, name: Optional[str] = None, code: Optional[str] = None, target_entity_type: Optional[str] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["name"] = name kwargs["target_entity_type"] = target_entity_type super(Type, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, **kwargs) # CodeMixin.__init__(self, **kwargs) self.code = code def __eq__(self, other: Any) -> bool: """Check the equality. Args: other (Any): The other object. Returns: bool: True if the other object is equal to this Type instance as an Entity and has the same target_entity_type. """ return ( super(Type, self).__eq__(other) and isinstance(other, Type) and self.target_entity_type == other.target_entity_type ) def __hash__(self) -> int: """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Type, self).__hash__() class EntityType(Base): """A simple class just to hold the registered class names in Stalker.""" __tablename__ = "EntityTypes" __table_args__ = {"extend_existing": True} id: Mapped[int] = mapped_column("id", primary_key=True) name: Mapped[str] = mapped_column( String(128), nullable=False, unique=True, ) statusable: Mapped[Optional[bool]] = mapped_column(default=False) dateable: Mapped[Optional[bool]] = mapped_column(default=False) schedulable: Mapped[Optional[bool]] = mapped_column(default=False) accepts_references: Mapped[Optional[bool]] = mapped_column(default=False) def __init__( self, name: str, statusable: bool = False, schedulable: bool = False, accepts_references: bool = False, ) -> None: self.name = name self.statusable = statusable self.schedulable = schedulable self.accepts_references = accepts_references # TODO: add tests for the name attribute ================================================ FILE: src/stalker/models/variant.py ================================================ # -*- coding: utf-8 -*- """Variant related functions and classes are situated here.""" from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker.models.task import Task class Variant(Task): """A Task derivative to keep track of Variants in a Task hierarchy. The basic reason to have the Variant class is to upgrade the variants, into a Task derivative so that it is possible to create dependencies between different variants and being able to review them individually. You see, in previous versions of Stalker, the variants were handled as a part of the Version instances with a str attribute. The down side of that design was not being able to distinguish any reviews per variant. So, when a Model task is approved, all its variant approved all together, even if one of the variants were still getting worked on. The new design prevents that and gives the variant the level of attention they deserved. Variants doesn't introduce any new arguments or attributes. They are just initialized like any other Tasks. """ __tablename__ = "Variants" __mapper_args__ = {"polymorphic_identity": "Variant"} variant_id: Mapped[int] = mapped_column( "id", ForeignKey("Tasks.id"), primary_key=True, ) ================================================ FILE: src/stalker/models/version.py ================================================ # -*- coding: utf-8 -*- """Version related functions and classes are situated here.""" import os from pathlib import Path from typing import Any, Dict, List, Optional import jinja2 from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.exc import OperationalError, UnboundExecutionError from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym, validates from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.log import get_logger from stalker.models.entity import Entity from stalker.models.file import File from stalker.models.mixins import DAGMixin from stalker.models.review import Review from stalker.models.task import Task logger = get_logger(__name__) class Version(Entity, DAGMixin): """Holds information about the versions created for a class:`.Task`. A :class:`.Version` instance holds information about the versions created related for a class:`.Task`. This is not directly related to the stored files, but instead holds the information about the incremental change itself (i.e who has created it, when it is created, the revision and version numbers etc.). All the related files are stored in the :attr:`.Version.files` attribute. .. versionadded: 0.2.13 After Stalker 0.2.13 the :attr:`.path` become an absolute path which is not anymore merged with the project repository in anyway. .. warning: For projects those are created prior to Stalker version 0.2.13 and that has a :class:`.Structure` with :class:`.FilenameTemplate` that doesn't include the repository info, it is suggested to update the related ``FilenameTemplate`` s to include a the repository info manually. Example: pre 0.2.13 setup: FilenameTemplate with path attribute is set to: {{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%} Update to: {{project.repository.path}}/{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%} Or, let's have a setup with environment variables: $REPO{{project.repository.code}}/{{project.code}}/{%- for parent_task in parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%} .. versionadded: 1.0.0 Version instances now have an extra numeric counter, preceding the :attr:`.version_number` attribute to allow versions to be better organized alongside revisions or big changes, without relying on the now removed `variant_name` attribute. .. versionadded: 1.1.0 Version class is not deriving from File class anymore. So they are not directly related to any file. And the File relation is stored in the new :attr:`.Version.files` attribute. .. versionadded: 1.1.0 Added the `files` attribute, which replaces the `outputs` attribute and the `inputs` attribute is moved to the :class:`.File` class as the `references` attribute, which makes much more sense as individual files may reference different `Files` so storing the `references` in `Version` doesn't make much sense. Args: revision_number (Optional[int]): A positive non-zero integer number holding the major version counter. This can be set with an argument, allowing setting of the revision number as the Version instance is created. So, if a :class:`.Version` is created under the same :class:`Task` before, the newly created :class:`.Version` instances will start from the highest revision number unless it is set to another value. Non-sequential revision numbers can be set. So, one can start with 1 and then can jump to 3 and 10 from there. All the :class:`.Version` instances that have the same :attr:`.revision_number` under the same :class:`.Task` will be considered in the same version stream and version number attribute will be set accordingly. The default is 1. files (List[File]): A list of :class:`.File` instances that are created for this :class:`.Version` instance. This can be different representations (i.e. base, Alembic, USD, ASS, RS etc.) of the same data. task (Task): A :class:`.Task` instance showing the owner of this Version. parent (Version): A :class:`.Version` instance which is the parent of this Version. It is mainly used to see which Version is derived from which in the Version history of a :class:`.Task`. """ # noqa: B950 from stalker import defaults __auto_name__ = True __tablename__ = "Versions" __mapper_args__ = {"polymorphic_identity": "Version"} __dag_cascade__ = "save-update, merge" version_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True ) __id_column__ = "version_id" task_id: Mapped[int] = mapped_column(ForeignKey("Tasks.id"), nullable=False) task: Mapped[Task] = relationship( primaryjoin="Versions.c.task_id==Tasks.c.id", doc="The :class:`.Task` instance that this Version is created for.", uselist=False, back_populates="versions", ) _revision_number: Mapped[int] = mapped_column( "revision_number", default=1, nullable=False, ) version_number: Mapped[int] = mapped_column( default=1, nullable=False, doc="""The :attr:`.version_number` attribute is read-only. Trying to change it will produce an AttributeError. """, ) files: Mapped[Optional[List[File]]] = relationship( secondary="Version_Files", primaryjoin="Versions.c.id==Version_Files.c.version_id", secondaryjoin="Version_Files.c.file_id==Files.c.id", doc="""The files related to the current version. It is a list of :class:`.File` instances. """, ) reviews: Mapped[Optional[List[Review]]] = relationship( primaryjoin="Reviews.c.version_id==Versions.c.id" ) is_published: Mapped[Optional[bool]] = mapped_column(default=False) def __init__( self, task: Optional[Task] = None, files: Optional[List[File]] = None, parent: Optional["Version"] = None, full_path: Optional[str] = None, revision_number: Optional[int] = None, **kwargs: Dict[str, Any], ) -> None: # call supers __init__ kwargs["full_path"] = full_path super(Version, self).__init__(**kwargs) DAGMixin.__init__(self, parent=parent) self.task = task if revision_number is None: revision_number = 1 self.revision_number = revision_number self.version_number = None if files is None: files = [] self.files = files self.is_published = False def __repr__(self) -> str: """Return the str representation of the Version. Returns: str: The string representation of this Version instance. """ return ( "<{project_code}_{nice_name}_v{version_number:03d} ({entity_type})>".format( project_code=self.task.project.code, nice_name=self.nice_name, version_number=self.version_number, entity_type=self.entity_type, ) ) def _validate_revision_number(self, revision_number: int) -> int: """Validate the given revision_number value. Args: revision_number (int): The revision_number value to be validated. Raises: TypeError: If the given revision_number value is not an integer. ValueError: If the given revision_number value is not a positive integer. Returns: int: The validated revision_number value. """ error_message = ( f"{self.__class__.__name__}.revision_number should be a " f"positive integer, not {revision_number.__class__.__name__}: " f"'{revision_number}'" ) if not isinstance(revision_number, int): raise TypeError(error_message) if revision_number < 1: raise ValueError(error_message) return revision_number def _revision_number_getter(self) -> int: """Return the revision_number value. Returns: int: revision_number attribute value """ return self._revision_number def _revision_number_setter(self, revision_number: int): """Set the revision attribute value. Args: revision_number (int): The new revision number value. """ revision_number = self._validate_revision_number(revision_number) is_updating_revision_number = False if self._revision_number is not None: if revision_number != self._revision_number: logger.debug( "Updating revision_number from " f"{self._revision_number} -> {revision_number}" ) is_updating_revision_number = True else: logger.debug( "Revision number is the same... " f"{self._revision_number} == {revision_number}" ) else: logger.debug("revision_number is being set for the first time!") self._revision_number = revision_number if is_updating_revision_number and self.version_number is not None: # if we are updating the revision_number value, # also update reset the version_number logger.debug( "Updated revision_number! so, let's update version_number too!" ) logger.debug(f"current version_number is {self.version_number}") self.version_number = None revision_number: Mapped[int] = synonym( "_revision_number", descriptor=property(_revision_number_getter, _revision_number_setter), ) @property def latest_version(self) -> "Version": """Return the Version instance with the highest version number in this series. Returns: Version: The :class:`.Version` instance with the highest version number in this version series. """ latest_version = None try: with DBSession.no_autoflush: latest_version = ( Version.query.filter(Version.task == self.task) .filter(Version.revision_number == self.revision_number) .order_by(Version.version_number.desc()) .first() ) return latest_version except (UnboundExecutionError, OperationalError): all_versions = sorted( self.task.versions, key=lambda x: x.version_number if x.version_number else -1, ) return all_versions[-1] if all_versions else None @property def max_revision_number(self) -> int: """Return the maximum revision number for this Version. Returns: int: The maximum revision number for this Version. """ with DBSession.no_autoflush: result = ( DBSession.query(Version.revision_number) .filter(Version.task_id == self.task_id) .order_by(Version.revision_number.desc()) .first() ) return result[0] if result else 1 @property def max_version_number(self) -> int: """Return the maximum version number for this Version. Returns: int: The maximum version number for this Version. """ latest_version = self.latest_version return latest_version.version_number if latest_version else 0 @validates("version_number") def _validate_version_number(self, key: str, version_number: int) -> int: """Validate the given version_number value. Args: key (str): The name of the validated column. version_number (int): The version number to be validated. Returns: int: The validated version number. """ max_version_number = self.max_version_number logger.debug(f"max_version_number: {max_version_number}") logger.debug(f"given version_number: {version_number}") if version_number is not None and version_number > max_version_number: return version_number if self.latest_version == self: if self.version_number is not None: version_number = self.version_number else: version_number = 1 logger.debug( f"{self.__class__.__name__}.version_number is weirdly 'None', " "no database connection maybe?" ) logger.debug( "the version is the latest version in database, the " f"number will not be changed from {version_number}" ) else: version_number = max_version_number + 1 logger.debug( "given Version.version_number is too low," f"max version_number in the database is {max_version_number}, " f"setting the current version_number to {version_number}" ) return version_number @validates("task") def _validate_task(self, key, task) -> Task: """Validate the given task value. Args: key (str): The name of the validated column. task (Task): The task value to be validated. Raises: TypeError: If the task value is not a :class:`.Task` instance. Returns: Task: The validated :class:`.Task` instance. """ if task is None: raise TypeError("{}.task cannot be None".format(self.__class__.__name__)) if not isinstance(task, Task): raise TypeError( "{}.task should be a Task, Asset, Shot, Scene, Sequence or " "Variant instance, not {}: '{}'".format( self.__class__.__name__, task.__class__.__name__, task ) ) return task @validates("files") def _validate_files(self, key: str, file: File) -> File: """Validate the given file value. Args: key (str): The name of the validated column. file (File): The file value to be validated. Raises: TypeError: If the file is not a :class:`.File` instance. Returns: File: The validated file value. """ if not isinstance(file, File): raise TypeError( "{}.files should only contain instances of " "stalker.models.file.File, not {}: '{}'".format( self.__class__.__name__, file.__class__.__name__, file ) ) return file def _template_variables(self) -> dict: """Return the variables used in rendering the filename template. Returns: dict: The template variables. """ version_template_vars = { "version": self, # "extension": self.extension, } version_template_vars.update(self.task._template_variables()) return version_template_vars def generate_path(self, extension: Optional[str] = None) -> Path: """Generate a Path with the template variables from the parent project. Args: extension (Optional[str]): An optional string containing the extension for the resulting Path. Raises: TypeError: If the extension is not None and not a str. RuntimeError: If no Version related FilenameTemplate is found in the related `Project.structure`. Returns: Path: A `pathlib.Path` object. """ if extension is not None and not isinstance(extension, str): raise TypeError( "extension should be a str, " f"not {extension.__class__.__name__}: '{extension}'" ) kwargs = self._template_variables() # get a suitable FilenameTemplate structure = self.task.project.structure vers_template = None if structure: for template in structure.templates: if template.target_entity_type == self.task.entity_type: vers_template = template break if not vers_template: raise RuntimeError( "There are no suitable FilenameTemplate " "(target_entity_type == '{entity_type}') defined in the Structure of " "the related Project instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to '{entity_type}' and add " "it to the `templates` attribute of the structure of the " "project".format(entity_type=self.task.entity_type) ) path = Path( jinja2.Template(vers_template.path).render( **kwargs, trim_blocks=True, lstrip_blocks=True ) ) / Path( jinja2.Template(vers_template.filename).render( **kwargs, trim_blocks=True, lstrip_blocks=True ) ) if extension is not None: path = path.with_suffix(extension) return path @property def absolute_full_path(self) -> str: """Return the absolute full path of this version. This absolute full path includes the repository path of the related project. Returns: str: The absolute full path of this Version instance. """ return Path( os.path.normpath(os.path.expandvars(str(self.generate_path()))).replace( "\\", "/" ) ) @property def absolute_path(self) -> str: """Return the absolute path. Returns: str: The absolute path. """ return Path( os.path.normpath( os.path.expandvars(str(self.generate_path().parent)) ).replace("\\", "/") ) @property def full_path(self) -> Path: """Return the full path of this version. This full path includes the repository path of the related project as it is. Returns: Path: The full path of this Version instance. """ return self.generate_path() @property def path(self) -> Path: """Return the path. Returns: Path: The path. """ return self.full_path.parent @property def filename(self) -> str: """Return the filename bit of the path. Returns: str: The filename. """ return self.full_path.name def is_latest_published_version(self) -> bool: """Return True if this is the latest published Version False otherwise. Returns: bool: True if this is the latest published Version, False otherwise. """ if not self.is_published: return False return self == self.latest_published_version @property def latest_published_version(self) -> "Version": """Return the last published version. Returns: Version: The last published Version instance. """ return ( Version.query.filter_by(task=self.task) .filter(Version.revision_number == self.revision_number) .filter_by(is_published=True) .order_by(Version.version_number.desc()) .first() ) def __eq__(self, other): """Check the equality. Args: other (object): The other object. Returns: bool: True if the other object is equal to this one as an Entity, is a Version instance, has the same task and same version_number. """ return ( super(Version, self).__eq__(other) and isinstance(other, Version) and self.task == other.task and self.version_number == other.version_number ) def __hash__(self): """Return the hash value of this instance. Because the __eq__ is overridden the __hash__ also needs to be overridden. Returns: int: The hash value. """ return super(Version, self).__hash__() @property def naming_parents(self) -> List[Task]: """Return a list of parents starting from the nearest Asset, Shot or Sequence. Returns: List[Task]: List of naming parents. """ # find a Asset, Shot or Sequence, and store it as the significant # parent, and name the task starting from that entity all_parents = self.task.parents all_parents.append(self.task) naming_parents = [] if not all_parents: return naming_parents for parent in reversed(all_parents): naming_parents.insert(0, parent) if parent.entity_type in ["Asset", "Shot", "Sequence"]: break return naming_parents @property def nice_name(self) -> str: """Override the nice name method for Version class. Returns: str: The nice name. """ return self._format_nice_name( "_".join(map(lambda x: x.nice_name, self.naming_parents)) ) def request_review(self) -> List[Review]: """Request a review. This is a shortcut to the Task.request_review() method of the related task. Returns: List[Review]: The created Review instances. """ return self.task.request_review(version=self) # VERSION FILES Version_Files = Table( "Version_Files", Base.metadata, Column("version_id", Integer, ForeignKey("Versions.id"), primary_key=True), Column( "file_id", Integer, ForeignKey("Files.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True, ), ) ================================================ FILE: src/stalker/models/wiki.py ================================================ # -*- coding: utf-8 -*- """Wiki related functions and classes are situated here.""" from typing import Any, Dict, Optional, TYPE_CHECKING, Union from sqlalchemy import ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, validates from stalker import Entity, ProjectMixin if TYPE_CHECKING: # pragma: no cover from stalker.models.project import Project class Page(Entity, ProjectMixin): """A simple Wiki page implementation. Wiki in Stalker are managed per Project. That is, all Wiki pages are related to a Project. Stalker wiki pages are very simple in terms of data it holds. It has only one :attr:`.title` and one :attr:`.content` an some usual audit info coming from :class:`.SimpleEntity` and a :attr:`.project` coming from :class:`.ProjectMixin`. Args: title (str): The title of this Page. content (str): The content of this page. Can contain any kind of string literals including HTML tags etc. """ __auto_name__ = True __tablename__ = "Pages" __mapper_args__ = {"polymorphic_identity": "Page"} page_id: Mapped[int] = mapped_column( "id", ForeignKey("Entities.id"), primary_key=True, ) title: Mapped[Optional[str]] = mapped_column(Text) content: Mapped[Optional[str]] = mapped_column(Text) def __init__( self, title: str = "", content: str = "", project: Optional["Project"] = None, **kwargs: Dict[str, Any], ) -> None: kwargs["project"] = project super(Page, self).__init__(**kwargs) ProjectMixin.__init__(self, **kwargs) self.title = title self.content = content @validates("title") def _validate_title(self, key: str, title: str) -> str: """Validate the given title value. Args: key (str): The name of the validated column. title (str): The title value to be validated. Raises: TypeError: If the given title is not a string. ValueError: If the title is an empty string. Returns: str: The validated title value. """ if not isinstance(title, str): raise TypeError( "{}.title should be a string, not {}: '{}'".format( self.__class__.__name__, title.__class__.__name__, title ) ) if not title: raise ValueError(f"{self.__class__.__name__}.title cannot be empty") return title @validates("content") def _validate_content(self, key: str, content: Union[None, str]) -> str: """Validate the given content value. Args: key (str): The name of the validated column. content (Union[None, str]): The content value to be validated. Raises: TypeError: If the content is not None and not str. Returns: str: The validated content value. """ content = "" if content is None else content if not isinstance(content, str): raise TypeError( "{}.content should be a string, not {}: '{}'".format( self.__class__.__name__, content.__class__.__name__, content ) ) return content ================================================ FILE: src/stalker/py.typed ================================================ ================================================ FILE: src/stalker/utils.py ================================================ # -*- coding: utf-8 -*- """Utilities are situated here.""" import calendar from datetime import datetime, timedelta from typing import Any, Generator, Union import pytz from stalker.exceptions import CircularDependencyError from stalker.models.enum import TraversalDirection def make_plural(name: str) -> str: """Return the plural version of the given name argument. Args: name (str): The name to make plural. Returns: str: The plural version of the given name. """ plural_name = name + "s" if name[-1] == "y": plural_name = name[:-1] + "ies" elif name[-2:] == "ch": plural_name = name + "es" elif name[-1] == "f": plural_name = name[:-1] + "ves" elif name[-1] == "s": plural_name = name + "es" return plural_name def walk_hierarchy( entity: Any, attr: str, method: Union[int, str, TraversalDirection] = TraversalDirection.DepthFirst, ) -> Generator[Any, Any, Any]: """Walk the entity hierarchy over the given attribute and yield the entities found. It doesn't check for cycle, so if the attribute is not acyclic then this function will not find an exit point. The default method is Depth First Search (DFS), to walk with Breadth First Search (BFS) set the direction to :attr:`.TraversalDirection.BreadthFirst`. Args: entity (Any): Starting Entity. attr (str): The attribute name to walk over. method (Union[int, str, TraversalDirection]): Use TraversalDirection enum values, or one of the values listed here ["DepthFirst", "BreadthFirst", 0, 1]. The default is :attr:`.TraversalDirection.DepthFirst`. Yields: Any: List any entities found while traversing the hierarchy. """ entity_to_visit = [entity] method = TraversalDirection.to_direction(method) if method == TraversalDirection.DepthFirst: while len(entity_to_visit): current_entity = entity_to_visit.pop(0) for child in reversed(getattr(current_entity, attr)): entity_to_visit.insert(0, child) yield current_entity else: # TraversalDirection.BreadthFirst while len(entity_to_visit): current_entity = entity_to_visit.pop(0) entity_to_visit.extend(getattr(current_entity, attr)) yield current_entity def check_circular_dependency(entity: Any, other_entity: Any, attr_name: str) -> None: """Check circular dependency. Check if entity and other_entity are in circular dependency over the attr with the name attr_name. Args: entity (Any): Any Python object. other_entity (Any): Any Python object. attr_name (str): The name of the attribute to check the circular dependency of. Raises: CircularDependencyError: If the entities are in circular dependency over the attr with the name attr_name. """ for e in walk_hierarchy(entity, attr_name): if e is other_entity: raise CircularDependencyError( "{entity_name} ({entity_class}) and " "{other_entity_name} ({other_entity_class}) are in a " 'circular dependency in their "{attr_name}" attribute'.format( entity_name=entity, entity_class=entity.__class__.__name__, other_entity_name=other_entity, other_entity_class=other_entity.__class__.__name__, attr_name=attr_name, ) ) def utc_to_local(utc_datetime: datetime) -> datetime: """Convert utc time to local time. Based on the answer of J.F. Sebastian on http://stackoverflow.com/questions/4563272/how-to-convert-a-python-utc-datetime-to-a-local-datetime-using-only-python-stand/13287083#13287083 Args: utc_datetime (datetime): The UTC datetime instance. Returns: datetime: The local datetime instance. """ # get integer timestamp to avoid precision lost timestamp = calendar.timegm(utc_datetime.timetuple()) local_dt = datetime.fromtimestamp(timestamp) return local_dt.replace(microsecond=utc_datetime.microsecond) def local_to_utc(local_datetime: datetime) -> datetime: """Convert local datetime to utc datetime. Based on the answer of J.F. Sebastian on http://stackoverflow.com/questions/4563272/how-to-convert-a-python-utc-datetime-to-a-local-datetime-using-only-python-stand/13287083#13287083 Args: local_datetime (datetime): The local `datetime` instance. Returns: datetime: The UTC datetime instance. """ # get the utc_datetime as if the local_datetime is utc and calculate the timezone # difference and add it to the local datetime object return local_datetime - (utc_to_local(local_datetime) - local_datetime) def datetime_to_millis(dt: datetime) -> int: """Calculate the milliseconds since epoch for the given datetime value. This is used as the default JSON serializer for datetime objects. Code is based on the answer of Jay Taylor in http://stackoverflow.com/questions/11875770/how-to-overcome-datetime-datetime-not-json-serializable-in-python Args: dt (datetime): The ``datetime`` instance. Returns: int: The int value of milliseconds since epoch. """ if isinstance(dt, datetime) and dt.utcoffset() is not None: dt = dt - dt.utcoffset() millis = int(calendar.timegm(dt.timetuple()) * 1000 + dt.microsecond / 1000) return millis def millis_to_datetime(millis: int) -> datetime: """Calculate the datetime from the given milliseconds value. Args: millis (int): An int value showing the millis from unix EPOCH Returns: datetime: The corresponding ``datetime`` instance to the given milliseconds. """ epoch = datetime(1970, 1, 1, tzinfo=pytz.utc) return epoch + timedelta(milliseconds=millis) ================================================ FILE: src/stalker/version.py ================================================ # -*- coding: utf-8 -*- """Provides functionality to parse the version number from the VERSION file.""" import os from typing import Union VERSION: Union[None, str] = None VERSION_FILE: str = os.path.join(os.path.dirname(__file__), "VERSION") if os.path.isfile(VERSION_FILE): with open(VERSION_FILE, "r") as f: VERSION = f.read().strip() __version__ = VERSION or "0.0.0" """str: The version of the package.""" ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/benchmarks/__init__.py ================================================ ================================================ FILE: tests/benchmarks/task_total_logged_seonds.py ================================================ # -*- coding: utf-8 -*- """Benchmark total logged session computation.""" import datetime import logging import os import time import pytz from sqlalchemy.orm import close_all_sessions from sqlalchemy.pool import NullPool import stalker import stalker.db.setup from stalker import ( Project, Repository, Status, StatusList, Task, TimeLog, Type, User, db, log, ) from stalker.config import Config from stalker.db.declarative import Base from stalker.db.session import DBSession from stalker.models.enum import TimeUnit from stalker.models.enum import ScheduleModel from tests.utils import create_random_db, drop_db, get_server_details_from_url log.logging_level = logging.INFO logging.getLogger("stalker.models.task").setLevel(logging.INFO) # create a new database for this test only database_url = create_random_db() # update the config config = {"sqlalchemy.url": database_url, "sqlalchemy.poolclass": NullPool} try: os.environ.pop(Config.env_key) except KeyError: # already removed pass # regenerate the defaults # stalker.defaults = Config() stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(minutes=10) # init database stalker.db.setup.setup(config) stalker.db.setup.init() status_wfd = Status.query.filter_by(code="WFD").first() status_rts = Status.query.filter_by(code="RTS").first() status_wip = Status.query.filter_by(code="WIP").first() status_prev = Status.query.filter_by(code="PREV").first() status_hrev = Status.query.filter_by(code="HREV").first() status_drev = Status.query.filter_by(code="DREV").first() status_oh = Status.query.filter_by(code="OH").first() status_stop = Status.query.filter_by(code="STOP").first() status_cmpl = Status.query.filter_by(code="CMPL").first() task_status_list = StatusList.query.filter_by(target_entity_type="Task").first() test_movie_project_type = Type( name="Movie Project", code="movie", target_entity_type="Project", ) test_repository_type = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository", code="TR", type=test_repository_type, linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) test_user1 = User(name="User1", login="user1", email="user1@user1.com", password="1234") test_user2 = User(name="User2", login="user2", email="user2@user2.com", password="1234") test_user3 = User(name="User3", login="user3", email="user3@user3.com", password="1234") test_user4 = User(name="User4", login="user4", email="user4@user4.com", password="1234") test_user5 = User(name="User5", login="user5", email="user5@user5.com", password="1234") test_project1 = Project( name="Test Project1", code="tp1", type=test_movie_project_type, repositories=[test_repository], ) test_dependent_task1 = Task( name="Dependent Task1", project=test_project1, status_list=task_status_list, responsible=[test_user1], ) test_dependent_task2 = Task( name="Dependent Task2", project=test_project1, status_list=task_status_list, responsible=[test_user1], ) kwargs = { "name": "Modeling", "description": "A Modeling Task", "project": test_project1, "priority": 500, "responsible": [test_user1], "resources": [test_user1, test_user2], "alternative_resources": [test_user3, test_user4, test_user5], "allocation_strategy": "minloaded", "persistent_allocation": True, "watchers": [test_user3], "bid_timing": 4, "bid_unit": TimeUnit.Day, "schedule_timing": 1, "schedule_unit": TimeUnit.Day, "start": datetime.datetime(2013, 4, 8, 13, 0, tzinfo=pytz.utc), "end": datetime.datetime(2013, 4, 8, 18, 0, tzinfo=pytz.utc), "depends_on": [test_dependent_task1, test_dependent_task2], "time_logs": [], "versions": [], "is_milestone": False, "status": 0, "status_list": task_status_list, } DBSession.add_all( [ test_movie_project_type, test_repository_type, test_repository, test_user1, test_user2, test_user3, test_user4, test_user5, test_project1, test_dependent_task1, test_dependent_task2, ] ) DBSession.commit() kwargs["depends_on"] = [] dt = datetime.datetime td = datetime.timedelta now = dt(2017, 3, 15, 0, 30, tzinfo=pytz.utc) kwargs["schedule_model"] = ScheduleModel.Effort # -------------- HOURS -------------- kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) DBSession.add(new_task) # create 100000 of 10 minutes of TimeLogs benchmark_start = time.time() tl_count = 100000 start = now ten_minutes = datetime.timedelta(minutes=10) resource = kwargs["resources"][0] print(f"creating {tl_count} TimeLogs") for i in range(tl_count): end = start + ten_minutes tl = TimeLog( resource=resource, task=new_task, start=start, end=end, ) DBSession.add(tl) start = end if i % 1000 == 0: print(f"i: {i}") DBSession.flush() DBSession.commit() DBSession.flush() DBSession.commit() benchmark_end = time.time() print("data created in: {:0.3f} secs".format(benchmark_end - benchmark_start)) task_id = new_task.id # del all the TimeLogs del new_task.time_logs del new_task # now get back the task from db task_from_db = DBSession.get(Task, task_id) # now query the total_logged_seconds benchmark_start = time.time() total_logged_seconds = task_from_db.total_logged_seconds benchmark_end = time.time() print("total_logged_seconds: {:0.3f} sec".format(total_logged_seconds)) print("old way worked in: {:0.3f} sec".format(benchmark_end - benchmark_start)) # now use the new way of doing it benchmark_start = time.time() quick_total_logged_seconds = task_from_db.total_logged_seconds benchmark_end = time.time() print("quick_total_logged_seconds: {:0.3f} sec".format(quick_total_logged_seconds)) print("new way worked in: {:0.3f} sec".format(benchmark_end - benchmark_start)) assert total_logged_seconds == quick_total_logged_seconds # clean up test database DBSession.rollback() connection = DBSession.connection() engine = connection.engine connection.close() Base.metadata.drop_all(engine, checkfirst=True) DBSession.remove() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) close_all_sessions() drop_db(**get_server_details_from_url(database_url)) ================================================ FILE: tests/config/__init__.py ================================================ ================================================ FILE: tests/config/test_config.py ================================================ # -*- coding: utf-8 -*- """stalker.config module.""" import datetime import logging import os import shutil import sys import tempfile import pytest from stalker import defaults, config from stalker.db.session import DBSession from stalker.models.studio import Studio logger = logging.getLogger("stalker") logger.setLevel(logging.DEBUG) @pytest.fixture(scope="function") def prepare_config_file(): """Set up the test.""" # so we need a temp directory to be specified as our config folder temp_config_folder = tempfile.mkdtemp() # we should set the environment variable os.environ["STALKER_PATH"] = temp_config_folder config_full_path = os.path.join(temp_config_folder, "config.py") yield config_full_path shutil.rmtree(temp_config_folder) def test_config_variable_updates_with_user_config(prepare_config_file): """database_file_name will be updated by the user config.""" # now create a config.py file and fill it with the desired values # like database_file_name = "test_value.db" config_full_path = prepare_config_file test_value = ".test_value.db" config_file = open(config_full_path, "w") config_file.writelines( [ "#-*- coding: utf-8 -*-\n", f'database_engine_settings = "{test_value}"\n', ] ) config_file.close() # now import the config.py and see if it updates the # database_file_name variable conf = config.Config() assert test_value == conf.database_engine_settings def test_config_variable_does_create_new_variables_with_user_config( prepare_config_file, ): """config will be updated by the user config by adding new variables.""" config_full_path = prepare_config_file # now create a config.py file and fill it with the desired values # like database_file_name = "test_value.db" test_value = ".test_value.db" config_file = open(config_full_path, "w") config_file.writelines( ["#-*- coding: utf-8 -*-\n", 'test_value = "' + test_value + '"\n'] ) config_file.close() # now import the config.py and see if it updates the # database_file_name variable conf = config.Config() assert conf.test_value == test_value def test_env_variable_with_vars_module_import_with_shortcuts(prepare_config_file): """module path has shortcuts like ~ and other env variables.""" config_full_path = prepare_config_file temp_config_folder = os.path.dirname(config_full_path) splits = os.path.split(temp_config_folder) var1 = splits[0] var2 = os.path.sep.join(splits[1:]) os.environ["var1"] = var1 os.environ["var2"] = var2 os.environ["STALKER_PATH"] = "$var1/$var2" test_value = "sqlite3:///.test_value.db" config_file = open(config_full_path, "w") config_file.writelines( ["#-*- coding: utf-8 -*-\n", 'database_url = "' + test_value + '"\n'] ) config_file.close() # now import the config.py and see if it updates the # database_file_name variable conf = config.Config() assert test_value == conf.database_url def test_env_variable_with_deep_vars_module_import_with_shortcuts(prepare_config_file): """module path has multiple shortcuts like ~ and other env variables.""" config_full_path = prepare_config_file temp_config_folder = os.path.dirname(config_full_path) splits = os.path.split(temp_config_folder) var1 = splits[0] var2 = os.path.sep.join(splits[1:]) var3 = os.path.join("$var1", "$var2") os.environ["var1"] = var1 os.environ["var2"] = var2 os.environ["var3"] = var3 os.environ["STALKER_PATH"] = "$var3" test_value = "sqlite:///.test_value.db" config_file = open(config_full_path, "w") config_file.writelines( ["#-*- coding: utf-8 -*-\n", 'database_url = "' + test_value + '"\n'] ) config_file.close() # now import the config.py and see if it updates the # database_file_name variable conf = config.Config() assert test_value == conf.database_url def test_non_existing_path_in_environment_variable(): """non-existing path situation will be handled gracefully by warning the user.""" os.environ["STALKER_PATH"] = "/tmp/non_existing_path" config.Config() def test_syntax_error_in_settings_file(prepare_config_file): """RuntimeError will be raised when there are syntax errors in the config.py file.""" config_full_path = prepare_config_file temp_config_folder = os.path.dirname(config_full_path) # now create a config.py file and fill it with the desired values # like database_file_name = "test_value.db" # but do a syntax error on purpose, like forgetting the last quote sign test_value = ".test_value.db" config_file = open(config_full_path, "w") config_file.writelines( ["#-*- coding: utf-8 -*-\n", 'database_file_name = "' + test_value + "\n"] ) config_file.close() # now import the config.py and see if it updates the # database_file_name variable with pytest.raises(RuntimeError) as cm: config.Config() error_message = { 8: "There is a syntax error in your configuration file: " "EOL while scanning string literal (, line 2)", 9: "There is a syntax error in your configuration file: " "EOL while scanning string literal (, line 2)", }.get( sys.version_info.minor, "There is a syntax error in your configuration file: " "unterminated string literal (detected at line 2) (, line 2)", ) assert str(cm.value) == error_message def test___setattr___cannot_set_config_values_directly(prepare_config_file): """config.Config.__setattr__() method cannot set config values directly.""" c = config.Config() test_value = 1 c.daily_working_hours = test_value assert c.config_values["daily_working_hours"] != test_value def test___getattr___is_working_as_expected(prepare_config_file): """config.Config.__getattr__() method is working as expected.""" c = config.Config() assert c.admin_name == "admin" def test___getitem___is_working_as_expected(prepare_config_file): """config.Config.__getitem__() method is working as expected.""" c = config.Config() assert c["admin_name"] == "admin" def test___setitem__is_working_as_expected(prepare_config_file): """config.Config.__setitem__() method is working as expected.""" c = config.Config() test_value = "administrator" assert c["admin_name"] != test_value c["admin_name"] = test_value assert c["admin_name"] == test_value def test___delitem__is_working_as_expected(prepare_config_file): """config.Config.__delitem__() method is working as expected.""" c = config.Config() assert c["admin_name"] is not None del c["admin_name"] assert "admin_name" not in c def test___contains___is_working_as_expected(prepare_config_file): """config.Config.__contains__() method is working as expected.""" c = config.Config() assert "admin_name" in c def test_update_with_studio_is_working_as_expected(setup_postgresql_db): """default values are updated with the Studio if there is a DB and a Studio.""" # check the defaults are still using them self assert defaults.timing_resolution == datetime.timedelta(hours=1) studio = Studio( name="Test Studio", timing_resolution=datetime.timedelta(minutes=15) ) DBSession.add(studio) DBSession.commit() # now check it again assert defaults.timing_resolution == studio.timing_resolution def test_old_style_repo_env_does_not_exist_anymore(): """repo_env_var_template_old doesn't exist anymore.""" assert "repo_env_var_template_old" not in defaults.config_values def test_default_working_hours_is_a_dictionary_with_list_values(): """default working_hours is a list of lists of two integers.""" assert isinstance(defaults.working_hours, dict) assert all( day in defaults.working_hours for day in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] ) assert all( isinstance(defaults.working_hours[day], list) for day in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] ) def test_default_filename_template_value(): """default filename_template includes revision_number.""" assert isinstance(defaults.filename_template, str) assert defaults.filename_template == ( "{{version.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}' ) ================================================ FILE: tests/conftest.py ================================================ # -*- coding: utf-8 -*- """Configure tests.""" import datetime import logging import os from subprocess import CalledProcessError import pytest from sqlalchemy.pool import NullPool import stalker import stalker.db.setup from stalker import db, defaults, log, User from stalker.config import Config from stalker.db.session import DBSession from tests.utils import create_random_db, tear_down_db logger = logging.getLogger(__name__) log.register_logger(logger) log.set_level(logging.DEBUG) HERE = os.path.dirname(__file__) @pytest.fixture(scope="function") def setup_sqlite3(): """Set up in memory SQLite3 database for tests.""" try: os.environ.pop(Config.env_key) except KeyError: # already removed pass # regenerate the defaults stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) # Enable Debug logging log.set_level(logging.DEBUG) yield tear_down_db({}) @pytest.fixture def get_data_file(request): """Request a specific datafile. Args: request: pytest.request object. Returns: str: Desired data file path. """ if isinstance(request.param, str): return os.path.join(HERE, "data", request.param) elif isinstance(request.param, list): output = [] for path in request.param: output.append(os.path.join(HERE, "data", path)) return output @pytest.fixture(scope="function") def setup_postgresql_db(): """Set up Postgresql database. Yields: dict: Test data storage. """ data = {"config": {}, "database_url": None} # create a new database for this test only while True: try: data["database_url"] = create_random_db() except CalledProcessError: # in very rare cases the create_random_db generates an already # existing database name # call it again pass else: break # update the config data["config"]["sqlalchemy.url"] = data["database_url"] data["config"]["sqlalchemy.poolclass"] = NullPool try: os.environ.pop(Config.env_key) except KeyError: # already removed pass # regenerate the defaults stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) # init database # remove anything beforehand stalker.db.setup.setup(data["config"]) stalker.db.setup.init() yield data tear_down_db(data) ================================================ FILE: tests/data/project_to_tjp_output.jinja2 ================================================ task Project_{{project.id}} "Project_{{project.id}}" { task Sequence_{{sequence1.id}} "Sequence_{{sequence1.id}}" { effort 1.0h allocate User_{{user1.id}} } task Sequence_{{sequence2.id}} "Sequence_{{sequence2.id}}" { effort 1.0h allocate User_{{user2.id}} } task Sequence_{{sequence3.id}} "Sequence_{{sequence3.id}}" { effort 1.0h allocate User_{{user3.id}} } task Sequence_{{sequence4.id}} "Sequence_{{sequence4.id}}" { task Task_{{task4.id}} "Task_{{task4.id}}" { effort 1.0h allocate User_{{user4.id}} } task Task_{{task5.id}} "Task_{{task5.id}}" { effort 1.0h allocate User_{{user5.id}} } task Task_{{task6.id}} "Task_{{task6.id}}" { effort 1.0h allocate User_{{user6.id}} } } task Sequence_{{sequence5.id}} "Sequence_{{sequence5.id}}" { task Task_{{task7.id}} "Task_{{task7.id}}" { effort 1.0h allocate User_{{user7.id}} } task Task_{{task8.id}} "Task_{{task8.id}}" { effort 1.0h allocate User_{{user8.id}} } task Task_{{task9.id}} "Task_{{task9.id}}" { effort 1.0h allocate User_{{user9.id}} } } task Sequence_{{sequence6.id}} "Sequence_{{sequence6.id}}" {} task Shot_{{shot1.id}} "Shot_{{shot1.id}}" { task Task_{{task10.id}} "Task_{{task10.id}}" { effort 10.0h allocate User_{{user10.id}} } task Task_{{task11.id}} "Task_{{task11.id}}" { effort 1.0h allocate User_{{user1.id}}, User_{{user2.id}} } task Task_{{task12.id}} "Task_{{task12.id}}" { effort 1.0h allocate User_{{user3.id}}, User_{{user4.id}} } } task Shot_{{shot2.id}} "Shot_{{shot2.id}}" { task Task_{{task13.id}} "Task_{{task13.id}}" { effort 1.0h allocate User_{{user5.id}}, User_{{user6.id}} } task Task_{{task14.id}} "Task_{{task14.id}}" { effort 1.0h allocate User_{{user7.id}}, User_{{user8.id}} } task Task_{{task15.id}} "Task_{{task15.id}}" { effort 1.0h allocate User_{{user9.id}}, User_{{user10.id}} } } task Sequence_{{sequence7.id}} "Sequence_{{sequence7.id}}" {} task Shot_{{shot3.id}} "Shot_{{shot3.id}}" { task Task_{{task16.id}} "Task_{{task16.id}}" { effort 1.0h allocate User_{{user1.id}}, User_{{user2.id}}, User_{{user3.id}} } task Task_{{task17.id}} "Task_{{task17.id}}" { effort 1.0h allocate User_{{user4.id}}, User_{{user5.id}}, User_{{user6.id}} } task Task_{{task18.id}} "Task_{{task18.id}}" { effort 1.0h allocate User_{{user7.id}}, User_{{user8.id}}, User_{{user9.id}} } } task Shot_{{shot4.id}} "Shot_{{shot4.id}}" { task Task_{{task19.id}} "Task_{{task19.id}}" { effort 1.0h allocate User_{{user1.id}}, User_{{user2.id}}, User_{{user10.id}} } task Task_{{task20.id}} "Task_{{task20.id}}" { effort 1.0h allocate User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} } task Task_{{task21.id}} "Task_{{task21.id}}" { effort 1.0h allocate User_{{user6.id}}, User_{{user7.id}}, User_{{user8.id}} } } task Asset_{{asset1.id}} "Asset_{{asset1.id}}" { effort 1.0h allocate User_{{user2.id}} } task Asset_{{asset2.id}} "Asset_{{asset2.id}}" {} task Asset_{{asset3.id}} "Asset_{{asset3.id}}" {} task Asset_{{asset4.id}} "Asset_{{asset4.id}}" { task Task_{{task22.id}} "Task_{{task22.id}}" { effort 1.0h allocate User_{{user1.id}}, User_{{user9.id}}, User_{{user10.id}} } task Task_{{task23.id}} "Task_{{task23.id}}" { effort 1.0h allocate User_{{user2.id}}, User_{{user3.id}} } task Task_{{task24.id}} "Task_{{task24.id}}" { effort 1.0h allocate User_{{user4.id}}, User_{{user5.id}} } } task Asset_{{asset5.id}} "Asset_{{asset5.id}}" { task Task_{{task25.id}} "Task_{{task25.id}}" { effort 1.0h allocate User_{{user6.id}}, User_{{user7.id}} } task Task_{{task26.id}} "Task_{{task26.id}}" { effort 1.0h allocate User_{{user8.id}}, User_{{user9.id}} } task Task_{{task27.id}} "Task_{{task27.id}}" { effort 1.0h allocate User_{{user1.id}}, User_{{user10.id}} } } task Task_{{task1.id}} "Task_{{task1.id}}" { effort 1.0h allocate User_{{user1.id}} } task Task_{{task2.id}} "Task_{{task2.id}}" { effort 1.0h allocate User_{{user2.id}} } task Task_{{task3.id}} "Task_{{task3.id}}" { effort 1.0h allocate User_{{user3.id}} } } ================================================ FILE: tests/data/project_to_tjp_output_formatted ================================================ task Project_1 "Project_1" { task Sequence_2 "Sequence_2" { effort 1.0h allocate User_3 } task Sequence_4 "Sequence_4" { effort 1.0h allocate User_5 } task Sequence_6 "Sequence_6" { effort 1.0h allocate User_7 } task Sequence_8 "Sequence_8" { task Task_9 "Task_9" { effort 1.0h allocate User_10 } task Task_11 "Task_11" { effort 1.0h allocate User_12 } task Task_13 "Task_13" { effort 1.0h allocate User_14 } } task Sequence_15 "Sequence_15" { task Task_16 "Task_16" { effort 1.0h allocate User_17 } task Task_18 "Task_18" { effort 1.0h allocate User_19 } task Task_20 "Task_20" { effort 1.0h allocate User_21 } } task Sequence_22 "Sequence_22" {} task Shot_23 "Shot_23" { task Task_24 "Task_24" { effort 10.0h allocate User_25 } task Task_26 "Task_26" { effort 1.0h allocate User_3, User_5 } task Task_27 "Task_27" { effort 1.0h allocate User_7, User_10 } } task Shot_28 "Shot_28" { task Task_29 "Task_29" { effort 1.0h allocate User_12, User_14 } task Task_30 "Task_30" { effort 1.0h allocate User_17, User_19 } task Task_31 "Task_31" { effort 1.0h allocate User_21, User_25 } } task Sequence_32 "Sequence_32" {} task Shot_33 "Shot_33" { task Task_34 "Task_34" { effort 1.0h allocate User_3, User_5, User_7 } task Task_35 "Task_35" { effort 1.0h allocate User_10, User_12, User_14 } task Task_36 "Task_36" { effort 1.0h allocate User_17, User_19, User_21 } } task Shot_37 "Shot_37" { task Task_38 "Task_38" { effort 1.0h allocate User_3, User_5, User_25 } task Task_39 "Task_39" { effort 1.0h allocate User_7, User_10, User_12 } task Task_40 "Task_40" { effort 1.0h allocate User_14, User_17, User_19 } } task Asset_41 "Asset_41" { effort 1.0h allocate User_5 } task Asset_42 "Asset_42" {} task Asset_43 "Asset_43" {} task Asset_44 "Asset_44" { task Task_45 "Task_45" { effort 1.0h allocate User_3, User_21, User_25 } task Task_46 "Task_46" { effort 1.0h allocate User_5, User_7 } task Task_47 "Task_47" { effort 1.0h allocate User_10, User_12 } } task Asset_48 "Asset_48" { task Task_49 "Task_49" { effort 1.0h allocate User_14, User_17 } task Task_50 "Task_50" { effort 1.0h allocate User_19, User_21 } task Task_51 "Task_51" { effort 1.0h allocate User_3, User_25 } } task Task_52 "Task_52" { effort 1.0h allocate User_3 } task Task_53 "Task_53" { effort 1.0h allocate User_5 } task Task_54 "Task_54" { effort 1.0h allocate User_7 } } ================================================ FILE: tests/data/project_to_tjp_output_rendered ================================================ task Project_1 "Project_1" { task Sequence_2 "Sequence_2" { effort 1.0h allocate User_3 } task Sequence_4 "Sequence_4" { effort 1.0h allocate User_5 } task Sequence_6 "Sequence_6" { effort 1.0h allocate User_7 } task Sequence_8 "Sequence_8" { task Task_9 "Task_9" { effort 1.0h allocate User_10 } task Task_11 "Task_11" { effort 1.0h allocate User_12 } task Task_13 "Task_13" { effort 1.0h allocate User_14 } } task Sequence_15 "Sequence_15" { task Task_16 "Task_16" { effort 1.0h allocate User_17 } task Task_18 "Task_18" { effort 1.0h allocate User_19 } task Task_20 "Task_20" { effort 1.0h allocate User_21 } } task Sequence_22 "Sequence_22" {} task Shot_23 "Shot_23" { task Task_24 "Task_24" { effort 10.0h allocate User_25 } task Task_26 "Task_26" { effort 1.0h allocate User_3, User_5 } task Task_27 "Task_27" { effort 1.0h allocate User_7, User_10 } } task Shot_28 "Shot_28" { task Task_29 "Task_29" { effort 1.0h allocate User_12, User_14 } task Task_30 "Task_30" { effort 1.0h allocate User_17, User_19 } task Task_31 "Task_31" { effort 1.0h allocate User_21, User_25 } } task Sequence_32 "Sequence_32" {} task Shot_33 "Shot_33" { task Task_34 "Task_34" { effort 1.0h allocate User_3, User_5, User_7 } task Task_35 "Task_35" { effort 1.0h allocate User_10, User_12, User_14 } task Task_36 "Task_36" { effort 1.0h allocate User_17, User_19, User_21 } } task Shot_37 "Shot_37" { task Task_38 "Task_38" { effort 1.0h allocate User_3, User_5, User_25 } task Task_39 "Task_39" { effort 1.0h allocate User_7, User_10, User_12 } task Task_40 "Task_40" { effort 1.0h allocate User_14, User_17, User_19 } } task Asset_41 "Asset_41" { effort 1.0h allocate User_5 } task Asset_42 "Asset_42" {} task Asset_43 "Asset_43" {} task Asset_44 "Asset_44" { task Task_45 "Task_45" { effort 1.0h allocate User_3, User_21, User_25 } task Task_46 "Task_46" { effort 1.0h allocate User_5, User_7 } task Task_47 "Task_47" { effort 1.0h allocate User_10, User_12 } } task Asset_48 "Asset_48" { task Task_49 "Task_49" { effort 1.0h allocate User_14, User_17 } task Task_50 "Task_50" { effort 1.0h allocate User_19, User_21 } task Task_51 "Task_51" { effort 1.0h allocate User_3, User_25 } } task Task_52 "Task_52" { effort 1.0h allocate User_3 } task Task_53 "Task_53" { effort 1.0h allocate User_5 } task Task_54 "Task_54" { effort 1.0h allocate User_7 } } ================================================ FILE: tests/db/__init__.py ================================================ ================================================ FILE: tests/db/test_db.py ================================================ # -*- coding: utf-8 -*- """Database and connection to the database.""" import datetime import json import logging import os import pytz import pytest import tzlocal import stalker import stalker.db.setup from stalker import defaults, log from stalker import ( Asset, AuthenticationLog, Budget, BudgetEntry, Client, Daily, DailyFile, Department, Entity, EntityGroup, FilenameTemplate, Good, Group, ImageFormat, Invoice, File, Note, Page, Payment, Permission, Project, PriceList, Repository, Review, Scene, Sequence, Shot, SimpleEntity, Status, StatusList, Studio, Structure, Tag, Task, Ticket, TicketLog, TimeLog, Type, User, Vacation, Variant, Version, WorkingHours, ) from stalker.config import Config from stalker.db.setup import create_entity_statuses, alembic_version from stalker.db.session import DBSession from stalker.models.auth import LOGIN, LOGOUT from sqlalchemy import text from sqlalchemy.pool import NullPool from sqlalchemy.exc import ( ArgumentError, IntegrityError, OperationalError, PendingRollbackError, ProgrammingError, ) from stalker.models.enum import ScheduleConstraint, TimeUnit from stalker.models.enum import ScheduleModel from tests.utils import create_random_db, get_admin_user, tear_down_db logger = log.get_logger(__name__) log.set_level(logging.DEBUG) CLASS_NAMES = [ "Asset", "AuthenticationLog", "Budget", "BudgetEntry", "Client", "Good", "Group", "Permission", "User", "Department", "SimpleEntity", "Entity", "EntityGroup", "ImageFormat", "File", "Message", "Note", "Page", "Project", "PriceList", "Repository", "Review", "Role", "Scene", "Sequence", "Shot", "Status", "StatusList", "Structure", "Studio", "Tag", "TimeLog", "Task", "FilenameTemplate", "Ticket", "TicketLog", "Type", "Vacation", "Version", "Daily", "Invoice", "Payment", "Variant", ] @pytest.fixture(scope="function") def auto_crate_admin_on(): """Toggle auto create admin value on.""" # set default admin creation to True default_value = defaults.auto_create_admin defaults["auto_create_admin"] = True yield defaults["auto_create_admin"] = default_value @pytest.fixture(scope="function") def auto_crate_admin_off(): """Toggle auto create admin value on.""" # set default admin creation to True default_value = defaults.auto_create_admin defaults["auto_create_admin"] = False yield defaults["auto_create_admin"] = default_value def test_default_admin_creation(setup_postgresql_db, auto_crate_admin_on): """Default admin is created.""" # check if there is an admin admin_db = User.query.filter(User.name == defaults.admin_name).first() assert admin_db.name == defaults.admin_name def test_default_admin_for_already_created_databases( setup_postgresql_db, auto_crate_admin_on ): """No extra admin is going to be created for already setup databases.""" # set default admin creation to True stalker.db.setup.init() # try to call the init() for a second time and see if there are more # than one admin stalker.db.setup.init() # and get how many admin is created, (it is impossible to create # second one because the tables.simpleEntity.c.nam.unique=True admins = User.query.filter_by(name=defaults.admin_name).all() assert len(admins) == 1 def test_no_default_admin_creation(setup_postgresql_db, auto_crate_admin_off): """There is no user if stalker.config.Conf.auto_create_admin is False.""" data = setup_postgresql_db tear_down_db(data) # Setup # update the config data["database_url"] = create_random_db() data["config"]["sqlalchemy.url"] = data["database_url"] data["config"]["sqlalchemy.poolclass"] = NullPool try: os.environ.pop(Config.env_key) except KeyError: # already removed pass # regenerate the defaults stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) # init the db stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # check if there is a use with name admin assert User.query.filter_by(name=defaults.admin_name).first() is None # check if there is an "admins" department assert ( Department.query.filter_by(name=defaults.admin_department_name).first() is None ) def test_non_unique_names_on_different_entity_type(setup_postgresql_db): """There can be non-unique names for different entity types.""" # try to create a user and an entity with same name # expect Nothing kwargs = { "name": "user1", # "created_by": admin } entity1 = Entity(**kwargs) DBSession.add(entity1) DBSession.commit() # let's create the second user kwargs.update( { "name": "User1 Name", "login": "user1", "email": "user1@gmail.com", "password": "user1", } ) user1 = User(**kwargs) DBSession.add(user1) # expect nothing, this should work without any error DBSession.commit() def test_ticket_status_list_initialization(setup_postgresql_db): """Ticket statuses are correctly created.""" ticket_status_list = StatusList.query.filter( StatusList.name == "Ticket Statuses" ).first() assert isinstance(ticket_status_list, StatusList) expected_status_names = ["New", "Reopened", "Closed", "Accepted", "Assigned"] assert len(ticket_status_list.statuses) == len(expected_status_names) assert all( status.name in expected_status_names for status in ticket_status_list.statuses ) def test_daily_status_list_initialization(setup_postgresql_db): """Daily statuses are correctly created.""" daily_status_list = StatusList.query.filter( StatusList.name == "Daily Statuses" ).first() assert isinstance(daily_status_list, StatusList) expected_status_names = ["Open", "Closed"] assert len(daily_status_list.statuses) == len(expected_status_names) admin = get_admin_user() assert all( status.name in expected_status_names for status in daily_status_list.statuses ) # check if the created_by and updated_by attributes # are set to admin assert all(status.created_by == admin for status in daily_status_list.statuses) assert all(status.updated_by == admin for status in daily_status_list.statuses) def test_variant_status_list_initialization(setup_postgresql_db): """Variant statuses are correctly created.""" variant_status_list = StatusList.query.filter( StatusList.target_entity_type == "Variant" ).first() # we do not create a specific StatusList for Variant's anymore assert variant_status_list is None def test_register_creates_suitable_permissions(setup_postgresql_db): """stalker.db.register is able to create suitable Permissions.""" # create a new dummy class class TestClass(object): pass stalker.db.setup.register(TestClass) # now check if the TestClass entry is created in Permission table permissions_db = Permission.query.filter(Permission.class_name == "TestClass").all() logger.debug(f"{permissions_db}") actions = defaults.actions assert all(action.action in actions for action in permissions_db) def test_register_raise_type_error_for_wrong_class_name_argument(setup_postgresql_db): """TypeError is raised if the class_name argument is not an instance of type or str.""" with pytest.raises(TypeError): stalker.db.setup.register(23425) @pytest.mark.parametrize("error_class", [IntegrityError]) def test_register_handles_integrity_errors( setup_postgresql_db, monkeypatch, error_class ): """create_ticket_statuses() handles IntegrityErrors.""" # create a new dummy class class TestClass(object): pass class PatchedDBSession(object): rollback_is_called = False @classmethod def patched_commit(cls, *args, **kwargs): raise error_class(statement="", params=[], orig=None) @classmethod def patched_rollback(cls): cls.rollback_is_called = True monkeypatch.setattr( "stalker.db.setup.DBSession.commit", PatchedDBSession.patched_commit, ) monkeypatch.setattr( "stalker.db.setup.DBSession.rollback", PatchedDBSession.patched_rollback, ) assert PatchedDBSession.rollback_is_called is False # this should raise the errors now stalker.db.setup.register(TestClass) assert PatchedDBSession.rollback_is_called is True def test_permissions_created_for_all_the_classes(setup_postgresql_db): """Permission instances are created for classes in the SOM.""" permission_db = Permission.query.all() assert len(permission_db) == len(CLASS_NAMES) * len(defaults.actions) * 2 assert all(permission.access in ["Allow", "Deny"] for permission in permission_db) assert all(permission.action in defaults.actions for permission in permission_db) assert all(permission.class_name in CLASS_NAMES for permission in permission_db) def test_permissions_not_created_over_and_over_again(setup_postgresql_db): """Permissions are created only once and trying to call __init_db__ will not raise any error.""" data = setup_postgresql_db DBSession.remove() # DBSession.close() stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # this should not give any error DBSession.remove() stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # and we still have correct amount of Permissions permissions = Permission.query.all() assert len(permissions) == 430 def test_ticket_statuses_are_not_created_over_and_over_again(setup_postgresql_db): """Ticket Statuses are created only once and calling init() don't raise an error.""" data = setup_postgresql_db # create the environment variable and point it to a temp directory DBSession.remove() stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # this should not give any error stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # this should not give any error stalker.db.setup.setup(data["config"]) stalker.db.setup.init() # and we still have correct amount of Statuses statuses = Status.query.all() assert len(statuses) == 17 status_list = StatusList.query.filter_by(target_entity_type="Ticket").first() assert status_list is not None assert status_list.name == "Ticket Statuses" def test_project_status_list_initialization(setup_postgresql_db): """Project statuses are correctly created.""" project_status_list = ( StatusList.query.filter(StatusList.name == "Project Statuses") .filter(StatusList.target_entity_type == "Project") .first() ) assert isinstance(project_status_list, StatusList) expected_status_names = ["Ready To Start", "Work In Progress", "Completed"] expected_status_codes = ["RTS", "WIP", "CMPL"] assert len(project_status_list.statuses) == len(expected_status_names) db_status_names = map(lambda x: x.name, project_status_list.statuses) db_status_codes = map(lambda x: x.code, project_status_list.statuses) assert sorted(expected_status_names) == sorted(db_status_names) assert sorted(expected_status_codes) == sorted(db_status_codes) # check if the created_by and updated_by attributes are correctly set # to the admin admin = get_admin_user() assert all(status.created_by == admin for status in project_status_list.statuses) assert all(status.updated_by == admin for status in project_status_list.statuses) def test_task_status_list_initialization(setup_postgresql_db): """Task statuses are correctly created.""" task_status_list = ( StatusList.query.filter(StatusList.name == "Task Statuses") .filter(StatusList.target_entity_type == "Task") .first() ) assert isinstance(task_status_list, StatusList) expected_status_names = [ "Waiting For Dependency", "Ready To Start", "Work In Progress", "Pending Review", "Has Revision", "Dependency Has Revision", "On Hold", "Stopped", "Completed", ] expected_status_codes = [ "WFD", "RTS", "WIP", "PREV", "HREV", "DREV", "OH", "STOP", "CMPL", ] assert len(task_status_list.statuses) == len(expected_status_names) db_status_names = map(lambda x: x.name, task_status_list.statuses) db_status_codes = map(lambda x: x.code, task_status_list.statuses) assert sorted(expected_status_names) == sorted(db_status_names) assert sorted(expected_status_codes) == sorted(db_status_codes) # check if the created_by and updated_by attributes are correctly set # to the admin admin = get_admin_user() assert all(status.created_by == admin for status in task_status_list.statuses) assert all(status.updated_by == admin for status in task_status_list.statuses) def test_asset_status_list_initialization(setup_postgresql_db): """Asset statuses are correctly created.""" asset_status_list = StatusList.query.filter( StatusList.target_entity_type == "Asset" ).first() # we do not generate a specific StatusList for Assets anymore # as Task specific StatusLists can be used. assert asset_status_list is None def test_shot_status_list_initialization(setup_postgresql_db): """Shot statuses are correctly created.""" shot_status_list = StatusList.query.filter( StatusList.target_entity_type == "Shot" ).first() # we do not generate a specific StatusList for Shots anymore # as Task specific StatusLists can be used. assert shot_status_list is None def test_sequence_status_list_initialization(setup_postgresql_db): """Sequence statuses are correctly created.""" sequence_status_list = StatusList.query.filter( StatusList.target_entity_type == "Sequence" ).first() # we do not generate a specific StatusList for Sequences anymore # as Task specific StatusLists can be used. assert sequence_status_list is None def test_scene_status_list_initialization(setup_postgresql_db): """Scene statuses are correctly created.""" scene_status_list = StatusList.query.filter( StatusList.target_entity_type == "Scene" ).first() # we do not generate a specific StatusList for Scenes anymore # as Task specific StatusLists can be used. assert scene_status_list is None def test_variant_status_list_initialization(setup_postgresql_db): """Variant statuses are correctly created.""" variant_status_list = StatusList.query.filter( StatusList.target_entity_type == "Variant" ).first() # we do not generate a specific StatusList for Variant anymore # as Task specific StatusLists can be used. assert variant_status_list is None def test_review_status_list_initialization(setup_postgresql_db): """Review statuses are correctly created.""" review_status_list = StatusList.query.filter( StatusList.name == "Review Statuses" ).first() assert isinstance(review_status_list, StatusList) expected_status_names = [ "New", "Requested Revision", "Approved", ] expected_status_codes = ["NEW", "RREV", "APP"] assert len(review_status_list.statuses) == len(expected_status_names) db_status_names = map(lambda x: x.name, review_status_list.statuses) db_status_codes = map(lambda x: x.code, review_status_list.statuses) assert sorted(expected_status_names) == sorted(db_status_names) assert sorted(expected_status_codes) == sorted(db_status_codes) # check if the created_by and updated_by attributes are correctly set # to the admin admin = get_admin_user() for status in review_status_list.statuses: assert status.created_by == admin assert status.updated_by == admin def test___create_entity_statuses_no_entity_type_supplied(setup_postgresql_db): """db.__create_entity_statuses() raise a ValueError if no entity_type is given.""" kwargs = {"status_names": ["A", "B"], "status_codes": ["A", "B"]} with pytest.raises(ValueError) as cm: create_entity_statuses(**kwargs) assert str(cm.value) == "Please supply entity_type" def test___create_entity_statuses_no_status_names_supplied(setup_postgresql_db): """db.__create_entity_statuses() raise a ValueError if no status_names is given.""" kwargs = {"entity_type": "Hede Hodo", "status_codes": ["A", "B"]} with pytest.raises(ValueError) as cm: create_entity_statuses(**kwargs) assert str(cm.value) == "Please supply status names" def test___create_entity_statuses_no_status_codes_supplied(setup_postgresql_db): """db.__create_entity_statuses() raise a ValueError if no status_codes is given.""" kwargs = {"entity_type": "Hede Hodo", "status_names": ["A", "B"]} with pytest.raises(ValueError) as cm: create_entity_statuses(**kwargs) assert str(cm.value) == "Please supply status codes" def test_initialization_of_alembic_version_table(setup_postgresql_db): """db.init() will also create a table called alembic_version.""" sql_query = 'select version_num from "alembic_version"' version_num = DBSession.connection().execute(text(sql_query)).fetchone()[0] assert alembic_version == version_num def test_initialization_of_alembic_version_table_multiple_times(setup_postgresql_db): """db.create_alembic_table() will handle initializing the table multiple times.""" sql_query = 'select version_num from "alembic_version"' version_num = DBSession.connection().execute(text(sql_query)).fetchone()[0] assert alembic_version == version_num DBSession.remove() stalker.db.setup.init() stalker.db.setup.init() stalker.db.setup.init() version_nums = DBSession.connection().execute(text(sql_query)).fetchall() # no additional version is created assert len(version_nums) == 1 def test_alembic_version_mismatch(setup_postgresql_db): """db.init() raise ValueError if DB alembic version don't match Stalker alembic_version.""" data = setup_postgresql_db stalker.db.setup.init() # now change the alembic_version sql = "update alembic_version set version_num='some_random_number'" DBSession.connection().execute(text(sql)) DBSession.commit() # check if it is updated correctly sql = "select version_num from alembic_version" result = DBSession.connection().execute(text(sql)).fetchone() assert result[0] == "some_random_number" # close the connection DBSession.connection().close() DBSession.remove() # re-setup with pytest.raises(ValueError) as cm: stalker.db.setup.setup(data["config"]) assert str(cm.value) == ( f"Please update the database to version: {stalker.db.setup.alembic_version}" ) # also it is not possible to continue with the current DBSession with pytest.raises(PendingRollbackError) as cm: DBSession.query(User.id).all() assert "Can't reconnect until invalid transaction is rolled back." in str(cm.value) # rollback and reconnect to the database DBSession.rollback() # expect it happen again with pytest.raises(ValueError) as cm: stalker.db.setup.setup(data["config"]) assert str(cm.value) == ( f"Please update the database to version: {stalker.db.setup.alembic_version}" ) # rollback and insert the correct alembic version number DBSession.rollback() sql = f"update alembic_version set version_num='{alembic_version}'" DBSession.connection().execute(text(sql)) DBSession.commit() # and now expect everything to work correctly stalker.db.setup.setup(data["config"]) all_users = DBSession.query(User).all() assert all_users is not None def test_initialization_of_repo_environment_variables(setup_postgresql_db): """db.create_repo_env_vars() creates envvars for each repository in the system.""" data = setup_postgresql_db # create a couple of repositories repo1 = Repository(name="Repo1", code="R1") repo2 = Repository(name="Repo2", code="R2") repo3 = Repository(name="Repo3", code="R3") all_repos = [repo1, repo2, repo3] DBSession.add_all(all_repos) DBSession.commit() # remove any auto created repo vars for repo in all_repos: try: os.environ.pop(f"REPO{repo.code}") except KeyError: pass # check if all removed for repo in all_repos: # check if environment vars are created assert (f"REPO{repo.code}") not in os.environ # remove db connection DBSession.remove() # reconnect stalker.db.setup.setup(data["config"]) all_repos = Repository.query.all() for repo in all_repos: # check if environment vars are created assert (f"REPO{repo.code}") in os.environ def test_db_init_with_studio_instance(setup_postgresql_db): """db.init() using existing Studio instance for config values.""" data = setup_postgresql_db # check the defaults assert defaults.daily_working_hours != 8 assert defaults.weekly_working_days != 4 assert defaults.weekly_working_hours != 32 assert defaults.yearly_working_days != 180 assert defaults.timing_resolution != datetime.timedelta(minutes=5) # check no studio studios = Studio.query.all() assert studios == [] wh = WorkingHours( working_hours={ "mon": [[10 * 60, 18 * 60]], "tue": [[10 * 60, 18 * 60]], "wed": [[10 * 60, 18 * 60]], "thu": [[10 * 60, 18 * 60]], "fri": [], "sat": [], "sun": [], } ) wh.daily_working_hours = 8 test_studio = Studio( name="Test Studio", ) test_studio.working_hours = wh test_studio.timing_resolution = datetime.timedelta(minutes=5) DBSession.add(test_studio) DBSession.commit() # remove everything DBSession.connection().close() DBSession.remove() # re-init db logger.debug('data["config"]: {}'.format(data["config"])) logger.debug("defaults: {}".format(defaults)) logger.debug("id(defaults) 1: {}".format(id(defaults))) stalker.db.setup.setup(data["config"]) logger.debug("id(defaults) 2: {}".format(id(defaults))) # and expect the defaults to be updated with studio defaults assert defaults.daily_working_hours == 8 assert defaults.weekly_working_days == 4 assert defaults.weekly_working_hours == 32 assert defaults.yearly_working_days == 209 assert defaults.timing_resolution == datetime.timedelta(minutes=5) def test_get_alembic_version_is_working_as_expected_when_there_is_no_alembic_version_table( setup_postgresql_db, ): """get_alembic_version() working as expected if there is no alembic_version table.""" # drop the table DBSession.connection().execute(text("DROP TABLE IF EXISTS alembic_version")) # now get the alembic_version # this should not raise an OperationalError alembic_version_ = stalker.db.setup.get_alembic_version() assert alembic_version_ is None @pytest.mark.parametrize("error_class", [OperationalError, ProgrammingError, TypeError]) def test_get_alembic_version_handles_errors(monkeypatch, error_class): """stalker.db.setup.get_alembic_version() handles errors db related.""" class PatchedDialect(object): def has_table(*args, **kwargs): return True class PatchedEngine(object): dialect = PatchedDialect() class PatchedConnection(object): engine = PatchedEngine() def execute(*args, **kwargs): if error_class in (OperationalError, ProgrammingError): raise error_class(statement="", params=[], orig=None) else: raise error_class class PatchedDBSession(object): rollback_called = False @classmethod def connection(cls): return PatchedConnection() @classmethod def rollback(cls): cls.rollback_called = True monkeypatch.setattr("stalker.db.setup.DBSession", PatchedDBSession) with pytest.raises(error_class) as cm: PatchedDBSession.connection().execute() assert PatchedDBSession.rollback_called is False return_value = stalker.db.setup.get_alembic_version() assert PatchedDBSession.rollback_called is True assert return_value is None def test_create_ticket_statuses_called_multiple_times(setup_postgresql_db): """no IntegrityError is raised if create_ticket_statuses() called multiple times.""" stalker.db.setup.create_ticket_statuses() stalker.db.setup.create_ticket_statuses() stalker.db.setup.create_ticket_statuses() def test_create_ticket_statuses_handles_integrity_errors( setup_postgresql_db, monkeypatch ): """create_ticket_statuses() handles IntegrityErrors.""" class PatchedDBSession(object): rollback_is_called = False @classmethod def patched_commit(cls, *args, **kwargs): raise IntegrityError(statement="", params=[], orig=None) @classmethod def patched_rollback(cls): cls.rollback_is_called = True monkeypatch.setattr( "stalker.db.setup.DBSession.commit", PatchedDBSession.patched_commit, ) monkeypatch.setattr( "stalker.db.setup.DBSession.rollback", PatchedDBSession.patched_rollback, ) assert PatchedDBSession.rollback_is_called is False # this should raise IntegrityError now stalker.db.setup.create_ticket_statuses() assert PatchedDBSession.rollback_is_called is True def test_create_entity_statuses_called_multiple_times(setup_postgresql_db): """no IntegrityError is raised if create_entity_statuses() called multiple times.""" # create statuses for Tickets ticket_names = defaults.ticket_status_names ticket_codes = defaults.ticket_status_codes admin = get_admin_user() stalker.db.setup.create_entity_statuses("Ticket", ticket_names, ticket_codes, admin) stalker.db.setup.create_entity_statuses("Ticket", ticket_names, ticket_codes, admin) @pytest.mark.parametrize("error_class", [IntegrityError, OperationalError]) def test_create_entity_statuses_handles_errors( setup_postgresql_db, monkeypatch, error_class ): """create_ticket_statuses() handles IntegrityErrors.""" ticket_names = defaults.ticket_status_names ticket_codes = defaults.ticket_status_codes admin = get_admin_user() class PatchedDBSession(object): rollback_is_called = False @classmethod def patched_commit(cls, *args, **kwargs): raise error_class(statement="", params=[], orig=None) @classmethod def patched_rollback(cls): cls.rollback_is_called = True monkeypatch.setattr( "stalker.db.setup.DBSession.commit", PatchedDBSession.patched_commit, ) monkeypatch.setattr( "stalker.db.setup.DBSession.rollback", PatchedDBSession.patched_rollback, ) assert PatchedDBSession.rollback_is_called is False # this should raise the errors now stalker.db.setup.create_entity_statuses("Ticket", ticket_names, ticket_codes, admin) assert PatchedDBSession.rollback_is_called is True def test_register_called_multiple_times(setup_postgresql_db): """calling db.register() multiple times will not raise any errors.""" stalker.db.setup.register(User) stalker.db.setup.register(User) stalker.db.setup.register(User) stalker.db.setup.register(User) def test_setup_without_settings(setup_postgresql_db): """db.setup() will use the default settings if no setting is supplied.""" stalker.db.setup.setup() # db.init() conn = DBSession.connection() engine = conn.engine assert str(engine.url) == "sqlite://" def test_setup_with_settings(setup_postgresql_db): """db.setup() will use the given settings if no setting is supplied.""" # the default setup is already using the with pytest.raises(ArgumentError) as cm: stalker.db.setup.setup({"sqlalchemy.url": "random url"}) assert "Could not parse SQLAlchemy URL from" in str(cm.value) # tests the database model with a PostgreSQL database # # NOTE TO DEVELOPERS: # # Most of the tests in this TestCase uses parts of the system which are # tested but probably not tested while running the individual tests. # # Incomplete isolation is against to the logic behind unit testing, every # test should only cover a unit of the code, and a complete isolation should # be created. But this cannot be done in persistence tests (AFAIK), it needs # to be done in this way for now. Mocks cannot be used because every created # object goes to the database, so they need to be real objects. def test_persistence_of_asset(setup_postgresql_db): """Persistence of Asset.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) asset_type = Type( name="A new asset type A", code="anata", target_entity_type="Asset" ) test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_project = Project( name="Test Project For Asset Creation", code="TPFAC", type=commercial_type, repository=test_repository, ) DBSession.add(test_project) DBSession.commit() kwargs = { "name": "Test Asset", "code": "test_asset", "description": "This is a test Asset object", "type": asset_type, "project": test_project, "created_by": test_user, "responsible": [test_user], } test_asset = Asset(**kwargs) # logger.debug(f'test_asset.project : {test_asset.project}') DBSession.add(test_asset) DBSession.commit() # logger.debug(f'test_asset.project (after commit): {test_asset.project}') test_task1 = Task( name="test task 1", status=0, parent=test_asset, ) test_task2 = Task( name="test task 2", status=0, parent=test_asset, ) test_task3 = Task( name="test task 3", status=0, parent=test_asset, ) DBSession.add_all([test_task1, test_task2, test_task3]) DBSession.commit() code = test_asset.code created_by = test_asset.created_by date_created = test_asset.date_created date_updated = test_asset.date_updated duration = test_asset.duration description = test_asset.description end = test_asset.end name = test_asset.name nice_name = test_asset.nice_name notes = test_asset.notes project = test_asset.project references = test_asset.references status = test_asset.status status_list = test_asset.status_list start = test_asset.start tags = test_asset.tags children = test_asset.children type_ = test_asset.type updated_by = test_asset.updated_by del test_asset test_asset_db = Asset.query.filter_by(name=kwargs["name"]).one() assert isinstance(test_asset_db, Asset) # assert test_asset, test_asset_DB) assert code == test_asset_db.code assert test_asset_db.created_by is not None assert created_by == test_asset_db.created_by assert date_created == test_asset_db.date_created assert date_updated == test_asset_db.date_updated assert description == test_asset_db.description assert duration == test_asset_db.duration assert end == test_asset_db.end assert name == test_asset_db.name assert nice_name == test_asset_db.nice_name assert notes == test_asset_db.notes assert project == test_asset_db.project assert references == test_asset_db.references assert start == test_asset_db.start assert status == test_asset_db.status assert status_list == test_asset_db.status_list assert tags == test_asset_db.tags assert children == test_asset_db.children assert type_ == test_asset_db.type assert updated_by == test_asset_db.updated_by # now test the deletion of the asset class DBSession.delete(test_asset_db) DBSession.commit() # we should still have the user assert User.query.filter(User.id == created_by.id).first() is not None # we should still have the project assert Project.query.filter(Project.id == project.id).first() is not None def test_persistence_of_variant(setup_postgresql_db): """Persistence of Variant.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_project = Project( name="Test Project For Asset Creation", code="TPFAC", type=commercial_type, repository=test_repository, ) DBSession.add(test_project) DBSession.commit() kwargs = { "name": "Base", "description": "This is a test Variant", "project": test_project, "created_by": test_user, } test_variant = Variant(**kwargs) DBSession.add(test_variant) DBSession.commit() test_task1 = Task( name="test task 1", status=0, project=test_project, ) test_task2 = Task( name="test task 2", status=0, parent=test_task1, ) test_task3 = Task( name="test task 3", status=0, parent=test_task2, ) test_variant.parent = test_task3 DBSession.add_all([test_task1, test_task2, test_task3]) DBSession.commit() created_by = test_variant.created_by date_created = test_variant.date_created date_updated = test_variant.date_updated duration = test_variant.duration description = test_variant.description end = test_variant.end name = test_variant.name nice_name = test_variant.nice_name notes = test_variant.notes project = test_variant.project references = test_variant.references status = test_variant.status status_list = test_variant.status_list start = test_variant.start tags = test_variant.tags children = test_variant.children parent = test_variant.parent type_ = test_variant.type updated_by = test_variant.updated_by del test_variant test_asset_db = Variant.query.filter_by(name=kwargs["name"]).one() assert isinstance(test_asset_db, Variant) # assert test_asset, test_asset_DB) assert test_asset_db.created_by is not None assert created_by == test_asset_db.created_by assert date_created == test_asset_db.date_created assert date_updated == test_asset_db.date_updated assert description == test_asset_db.description assert duration == test_asset_db.duration assert end == test_asset_db.end assert name == test_asset_db.name assert nice_name == test_asset_db.nice_name assert notes == test_asset_db.notes assert project == test_asset_db.project assert references == test_asset_db.references assert start == test_asset_db.start assert status == test_asset_db.status assert status_list == test_asset_db.status_list assert tags == test_asset_db.tags assert children == test_asset_db.children assert parent == test_asset_db.parent assert type_ == test_asset_db.type assert updated_by == test_asset_db.updated_by # now test the deletion of the asset class DBSession.delete(test_asset_db) DBSession.commit() # we should still have the user assert User.query.filter(User.id == created_by.id).first() is not None # we should still have the project assert Project.query.filter(Project.id == project.id).first() is not None def test_persistence_of_budget_and_budget_entry(setup_postgresql_db): """Persistence of Budget and BudgetEntry classes.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) status1 = Status.query.filter_by(code="NEW").first() status2 = Status.query.filter_by(code="WIP").first() status3 = Status.query.filter_by(code="CMPL").first() test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) budget_status_list = StatusList( name="Budget Statuses", statuses=[status1, status2, status3], target_entity_type="Budget", ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_project = Project( name="Test Project For Budget Creation", code="TPFBC", type=commercial_type, repository=test_repository, ) DBSession.add(test_project) DBSession.commit() kwargs = { "name": "Test Budget", "project": test_project, "created_by": test_user, "status_list": budget_status_list, "status": status1, } test_budget = Budget(**kwargs) DBSession.add(test_budget) DBSession.commit() good = Good(name="Some Good", cost=9, msrp=10, unit="$/hour") DBSession.add(good) DBSession.commit() # create some entries entry1 = BudgetEntry(budget=test_budget, good=good, amount=5.0) entry2 = BudgetEntry(budget=test_budget, good=good, amount=1.0) DBSession.add_all([entry1, entry2]) DBSession.commit() created_by = test_budget.created_by date_created = test_budget.date_created date_updated = test_budget.date_updated name = test_budget.name nice_name = test_budget.nice_name project = test_budget.project tags = test_budget.tags updated_by = test_budget.updated_by notes = test_budget.notes entries = test_budget.entries status = test_budget.status del test_budget test_budget_db = Budget.query.filter_by(name=kwargs["name"]).one() assert isinstance(test_budget_db, Budget) assert test_budget_db.created_by is not None assert created_by == test_budget_db.created_by assert date_created == test_budget_db.date_created assert date_updated == test_budget_db.date_updated assert name == test_budget_db.name assert nice_name == test_budget_db.nice_name assert notes == test_budget_db.notes assert project == test_budget_db.project assert tags == test_budget_db.tags assert updated_by == test_budget_db.updated_by assert entries == test_budget_db.entries assert status == status1 # and we should have our entries intact assert BudgetEntry.query.all() != [] # now test the deletion of the asset class DBSession.delete(test_budget_db) DBSession.commit() # we should still have the user assert User.query.filter(User.id == created_by.id).first() is not None # we should still have the project assert Project.query.filter(Project.id == project.id).first() is not None # and we should have our page deleted assert Budget.query.filter(Budget.name == kwargs["name"]).first() is None # and we should have our entries deleted assert BudgetEntry.query.all() == [] # we still should have the good good_db = Good.query.filter(Good.name == "Some Good").first() assert good_db is not None def test_persistence_of_invoice(setup_postgresql_db): """Persistence of Invoice instances.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) status1 = Status.query.filter_by(code="NEW").first() status2 = Status.query.filter_by(code="WIP").first() status3 = Status.query.filter_by(code="CMPL").first() test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) budget_status_list = StatusList( name="Budget Statuses", statuses=[status1, status2, status3], target_entity_type="Budget", ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_client = Client(name="Test Client") DBSession.add(test_client) test_project = Project( name="Test Project For Budget Creation", code="TPFBC", type=commercial_type, repository=test_repository, clients=[test_client], ) DBSession.add(test_project) DBSession.commit() test_budget = Budget( name="Test Budget", project=test_project, created_by=test_user, status_list=budget_status_list, status=status1, ) DBSession.add(test_budget) DBSession.commit() good = Good(name="Some Good", cost=9, msrp=10, unit="$/hour") DBSession.add(good) DBSession.commit() # create some entries entry1 = BudgetEntry(budget=test_budget, good=good, amount=5.0) entry2 = BudgetEntry(budget=test_budget, good=good, amount=1.0) DBSession.add_all([entry1, entry2]) DBSession.commit() # create an invoice test_invoice = Invoice( created_by=test_user, budget=test_budget, client=test_client, amount=1232.4, unit="TRY", ) DBSession.add(test_invoice) created_by = test_invoice.created_by date_created = test_invoice.date_created date_updated = test_invoice.date_updated name = test_invoice.name nice_name = test_invoice.nice_name tags = test_invoice.tags notes = test_invoice.notes updated_by = test_invoice.updated_by budget = test_budget client = test_client amount = 1232.4 unit = "TRY" del test_invoice test_invoice_db = Invoice.query.filter(Invoice.name == name).first() assert isinstance(test_invoice_db, Invoice) assert test_user == test_invoice_db.created_by assert created_by == test_invoice_db.created_by assert date_created == test_invoice_db.date_created assert date_updated == test_invoice_db.date_updated assert name == test_invoice_db.name assert nice_name == test_invoice_db.nice_name assert notes == test_invoice_db.notes assert tags == test_invoice_db.tags assert updated_by == test_invoice_db.updated_by assert budget == test_invoice_db.budget assert client == test_invoice_db.client assert amount == test_invoice_db.amount assert unit == test_invoice_db.unit # now test the deletion of the invoice instance DBSession.delete(test_invoice_db) DBSession.commit() # we should still have the budget assert Budget.query.filter(Budget.id == budget.id).first() == budget # we should still have the client assert Client.query.filter(Client.id == client.id).first() == client # and we should have the invoice deleted assert Invoice.query.filter(Invoice.name == test_invoice_db.name).first() is None def test_persistence_of_payment(setup_postgresql_db): """Persistence of Payment instances.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) status1 = Status.query.filter_by(code="NEW").first() status2 = Status.query.filter_by(code="WIP").first() status3 = Status.query.filter_by(code="CMPL").first() test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) budget_status_list = StatusList( name="Budget Statuses", statuses=[status1, status2, status3], target_entity_type="Budget", ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_client = Client(name="Test Client") DBSession.add(test_client) test_project = Project( name="Test Project For Budget Creation", code="TPFBC", type=commercial_type, repository=test_repository, clients=[test_client], ) DBSession.add(test_project) DBSession.commit() test_budget = Budget( name="Test Budget", project=test_project, created_by=test_user, status_list=budget_status_list, status=status1, ) DBSession.add(test_budget) DBSession.commit() good = Good(name="Some Good", cost=9, msrp=10, unit="$/hour") DBSession.add(good) DBSession.commit() # create some entries entry1 = BudgetEntry(budget=test_budget, good=good, amount=5.0) entry2 = BudgetEntry(budget=test_budget, good=good, amount=1.0) DBSession.add_all([entry1, entry2]) DBSession.commit() # create an invoice test_invoice = Invoice( created_by=test_user, budget=test_budget, client=test_client, amount=1232.4, unit="TRY", ) DBSession.save(test_invoice) test_payment = Payment( created_by=test_user, invoice=test_invoice, amount=123.4, unit="TRY" ) DBSession.save(test_payment) created_by = test_payment.created_by date_created = test_payment.date_created date_updated = test_payment.date_updated name = test_payment.name nice_name = test_payment.nice_name tags = test_payment.tags notes = test_payment.notes updated_by = test_payment.updated_by invoice = test_invoice amount = 123.4 unit = "TRY" del test_payment test_payment_db = Payment.query.filter(Payment.name == name).first() assert isinstance(test_payment_db, Payment) assert test_user == test_payment_db.created_by assert created_by == test_payment_db.created_by assert date_created == test_payment_db.date_created assert date_updated == test_payment_db.date_updated assert name == test_payment_db.name assert nice_name == test_payment_db.nice_name assert notes == test_payment_db.notes assert tags == test_payment_db.tags assert updated_by == test_payment_db.updated_by assert invoice == test_payment_db.invoice assert amount == test_payment_db.amount assert unit == test_payment_db.unit # now test the deletion of the invoice instance DBSession.delete(test_payment_db) DBSession.commit() # we should still have the budget assert Budget.query.filter(Budget.id == test_budget.id).first() == test_budget # we should still have the Invoice assert Invoice.query.filter(Invoice.id == test_invoice.id).first() == test_invoice # and we should have the payment deleted assert Payment.query.filter(Payment.name == test_payment_db.name).first() is None def test_persistence_of_page(setup_postgresql_db): """Persistence of Page.""" test_user = User( name="Test User", login="tu", email="test@user.com", password="secret" ) _status1 = Status.query.filter_by(code="NEW").first() _status2 = Status.query.filter_by(code="WIP").first() _status3 = Status.query.filter_by(code="CMPL").first() test_repository_type = Type( name="Test Repository Type A", code="trta", target_entity_type="Repository", ) test_repository = Repository( name="Test Repository A", code="TRA", type=test_repository_type ) commercial_type = Type( name="Commercial A", code="comm", target_entity_type="Project" ) test_project = Project( name="Test Project For Asset Creation", code="TPFAC", type=commercial_type, repository=test_repository, ) DBSession.add(test_project) DBSession.commit() kwargs = { "title": "Test Wiki Page", "content": "This is a test wiki page", "project": test_project, "created_by": test_user, } test_page = Page(**kwargs) DBSession.add(test_page) DBSession.commit() created_by = test_page.created_by date_created = test_page.date_created date_updated = test_page.date_updated name = test_page.name nice_name = test_page.nice_name project = test_page.project tags = test_page.tags updated_by = test_page.updated_by title = test_page.title content = test_page.content notes = test_page.notes del test_page test_page_db = Page.query.filter_by(title=kwargs["title"]).one() assert isinstance(test_page_db, Page) # assert test_asset, test_asset_DB) assert test_page_db.created_by is not None assert created_by == test_page_db.created_by assert date_created == test_page_db.date_created assert date_updated == test_page_db.date_updated assert content == test_page_db.content assert name == test_page_db.name assert nice_name == test_page_db.nice_name assert notes == test_page_db.notes assert project == test_page_db.project assert tags == test_page_db.tags assert title == test_page_db.title assert updated_by == test_page_db.updated_by # now test the deletion of the asset class DBSession.delete(test_page_db) DBSession.commit() # we should still have the user assert User.query.filter(User.id == created_by.id).first() is not None # we should still have the project assert Project.query.filter(Project.id == project.id).first() is not None # and we should have our page deleted assert Page.query.filter(Page.title == kwargs["title"]).first() is None def test_persistence_of_timelog(setup_postgresql_db): """Persistence of TimeLog.""" logger.setLevel(log.logging_level) description = "this is a time log" start = datetime.datetime(2013, 1, 10, tzinfo=pytz.utc) end = datetime.datetime(2013, 1, 13, tzinfo=pytz.utc) user1 = User(name="User1", login="user1", email="user1@users.com", password="pass") user2 = User(name="User2", login="user2", email="user2@users.com", password="pass") _stat1 = Status(name="Work In Progress", code="WIP") _stat2 = Status(name="Completed", code="CMPL") repo = Repository( name="Commercials Repository", code="CR", linux_path="/mnt/shows", windows_path="S:/", macos_path="/mnt/shows", ) projtype = Type( name="Commercial Project", code="comm", target_entity_type="Project" ) proj1 = Project(name="Test Project", code="tp", type=projtype, repository=repo) test_task = Task( name="Test Task", start=start, end=end, resources=[user1, user2], project=proj1, responsible=[user1], ) test_time_log = TimeLog( task=test_task, resource=user1, start=datetime.datetime(2013, 1, 10, tzinfo=pytz.utc), end=datetime.datetime(2013, 1, 13, tzinfo=pytz.utc), description=description, ) DBSession.add(test_time_log) DBSession.commit() tlog_id = test_time_log.id del test_time_log # now retrieve it back test_time_log_db = TimeLog.query.filter_by(id=tlog_id).first() assert description == test_time_log_db.description assert start == test_time_log_db.start assert end == test_time_log_db.end assert user1 == test_time_log_db.resource assert test_task == test_time_log_db.task def test_persistence_of_timelog_raw_sql(setup_postgresql_db): """Persistence of TimeLog.""" start = datetime.datetime(2013, 1, 10, tzinfo=pytz.utc) end = datetime.datetime(2013, 1, 13, tzinfo=pytz.utc) user1 = User(name="User1", login="user1", email="user1@users.com", password="pass") user2 = User(name="User2", login="user2", email="user2@users.com", password="pass") _stat1 = Status(name="Work In Progress", code="WIP") _stat2 = Status(name="Completed", code="CMPL") repo = Repository( name="Commercials Repository", code="CR", linux_path="/mnt/shows", windows_path="S:/", macos_path="/mnt/shows", ) projtype = Type( name="Commercial Project", code="comm", target_entity_type="Project" ) proj1 = Project(name="Test Project", code="tp", type=projtype, repository=repo) test_task = Task( name="Test Task", start=start, end=end, resources=[user1, user2], project=proj1, responsible=[user1], ) DBSession.add(test_task) DBSession.commit() # now insert a new TimeLog in to the Timelogs table which has the same # value for start and end arguments, which should automatically raise # an IntegrityError by the database itself. # try insert start = start new_tl1 = TimeLog(task=test_task, resource=user1, start=start, end=end) DBSession.add(new_tl1) DBSession.commit() # create a new TimeLog new_tl2 = TimeLog( task=test_task, resource=user1, start=end, end=end + datetime.timedelta(hours=3), ) DBSession.add(new_tl2) DBSession.commit() # update it to have overlapping timing values with new_tl1 new_tl2.start = start + datetime.timedelta(hours=2) with pytest.raises(IntegrityError): DBSession.commit() def test_persistence_of_client(setup_postgresql_db): """Persistence of Client.""" logger.setLevel(log.logging_level) name = "TestClient" description = "this is for testing purposes" created_by = None updated_by = None date_created = datetime.datetime.now(pytz.utc) date_updated = datetime.datetime.now(pytz.utc) test_client = Client( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, ) DBSession.add(test_client) DBSession.commit() # create three users # user1 user1 = User( name="User1 Test Persistence Department", login="u1tpd", initials="u1tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user1_tp_client", first_name="user1_first_name", last_name="user1_last_name", email="user1@client.com", companies=[test_client], password="password", ) # user2 user2 = User( name="User2 Test Persistence Client", login="u2tpd", initials="u2tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user2_tp_client", first_name="user2_first_name", last_name="user2_last_name", email="user2@client.com", companies=[test_client], password="password", ) # user3 user3 = User( name="User3 Test Persistence Client", login="u3tpd", initials="u3tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user3_tp_client", first_name="user3_first_name", last_name="user3_last_name", email="user3@client.com", companies=[test_client], password="password", ) good1 = Good(name="Test Good 1") good2 = Good(name="Test Good 2") good3 = Good(name="Test Good 3") good4 = Good(name="Test Good 4") good5 = Good(name="Test Good 5") test_client.goods = [good1, good2, good3, good5] DBSession.add_all([good1, good2, good3, good4, good5]) DBSession.add_all([user1, user2, user3, test_client]) DBSession.commit() assert test_client in DBSession created_by = test_client.created_by date_created = test_client.date_created date_updated = test_client.date_updated description = test_client.description users = [u for u in test_client.users] name = test_client.name nice_name = test_client.nice_name notes = test_client.notes tags = test_client.tags updated_by = test_client.updated_by goods = [good1, good2, good3] # not included good5 on purpose # remove the good5 from list to see if it will still exist in the db test_client.goods.remove(good5) DBSession.commit() del test_client # let's check the data # first get the client from the db client_db = Client.query.filter_by(name=name).first() assert isinstance(client_db, Client) assert created_by == client_db.created_by assert date_created == client_db.date_created assert date_updated == client_db.date_updated assert description == client_db.description assert users == client_db.users assert name == client_db.name assert nice_name == client_db.nice_name assert notes == client_db.notes assert tags == client_db.tags assert updated_by == client_db.updated_by assert sorted(goods, key=lambda x: x.id) == sorted( client_db.goods, key=lambda x: x.id ) # delete the client and expect the users are still there DBSession.delete(client_db) DBSession.commit() user1_db = User.query.filter_by(login="u1tpd").first() user2_db = User.query.filter_by(login="u2tpd").first() user3_db = User.query.filter_by(login="u3tpd").first() assert user1_db is not None assert user2_db is not None assert user3_db is not None # goods should be deleted with client good1 = Good.query.filter_by(name="Test Good 1").first() good2 = Good.query.filter_by(name="Test Good 2").first() good3 = Good.query.filter_by(name="Test Good 3").first() good4 = Good.query.filter_by(name="Test Good 4").first() good5 = Good.query.filter_by(name="Test Good 5").first() assert good1 is None assert good2 is None assert good3 is None assert good4 is not None assert good5 is not None def test_persistence_of_daily(setup_postgresql_db): """Persistence of a Daily instance.""" test_user1 = User( name="User1", login="user1", email="user1@user1.com", password="12345" ) test_repo = Repository(name="Test Repository", code="TR") test_project = Project( name="Test Project", code="TP", repository=test_repo, ) test_task1 = Task( name="Test Task 1", project=test_project, responsible=[test_user1] ) test_task2 = Task( name="Test Task 2", project=test_project, responsible=[test_user1] ) test_task3 = Task( name="Test Task 3", project=test_project, responsible=[test_user1] ) DBSession.add_all([test_task1, test_task2, test_task3]) DBSession.commit() test_version1 = Version(task=test_task1) DBSession.add(test_version1) DBSession.commit() test_version2 = Version(task=test_task1) DBSession.add(test_version2) DBSession.commit() test_version3 = Version(task=test_task1) DBSession.add(test_version3) DBSession.commit() test_version4 = Version(task=test_task2) DBSession.add(test_version4) DBSession.commit() test_file1 = File(original_filename="test_render1.jpg") test_file2 = File(original_filename="test_render2.jpg") test_file3 = File(original_filename="test_render3.jpg") test_file4 = File(original_filename="test_render4.jpg") test_version1.files = [test_file1, test_file2, test_file3] test_version4.files = [test_file4] DBSession.add_all( [ test_task1, test_task2, test_task3, test_version1, test_version2, test_version3, test_version4, test_file1, test_file2, test_file3, test_file4, ] ) DBSession.commit() # arguments name = "Test Daily" files = [test_file1, test_file2, test_file3] daily = Daily(name=name, project=test_project) daily.files = files DBSession.add(daily) DBSession.commit() daily_id = daily.id del daily daily_db = DBSession.get(Daily, daily_id) assert daily_db.name == name assert daily_db.files == files assert daily_db.project == test_project file1_id = test_file1.id file2_id = test_file2.id file3_id = test_file3.id file4_id = test_file4.id # delete tests DBSession.delete(daily_db) DBSession.commit() # test if files are still there test_file1_db = DBSession.get(File, file1_id) test_file2_db = DBSession.get(File, file2_id) test_file3_db = DBSession.get(File, file3_id) test_file4_db = DBSession.get(File, file4_id) assert test_file1_db is not None assert isinstance(test_file1_db, File) assert test_file2_db is not None assert isinstance(test_file2_db, File) assert test_file3_db is not None assert isinstance(test_file3_db, File) assert test_file4_db is not None assert isinstance(test_file4_db, File) assert DailyFile.query.all() == [] assert File.query.count() == 4 # Versions are not files anymore def test_persistence_of_department(setup_postgresql_db): """Persistence of Department.""" logger.setLevel(log.logging_level) name = "TestDepartment_test_persistence_Department" description = "this is for testing purposes" created_by = None updated_by = None date_created = datetime.datetime.now(pytz.utc) date_updated = datetime.datetime.now(pytz.utc) test_dep = Department( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, ) DBSession.save(test_dep) # create three users, one for lead and two for users # user1 user1 = User( name="User1 Test Persistence Department", login="u1tpd", initials="u1tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user1_tp_department", first_name="user1_first_name", last_name="user1_last_name", email="user1@department.com", departments=[test_dep], password="password", ) # user2 user2 = User( name="User2 Test Persistence Department", login="u2tpd", initials="u2tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user2_tp_department", first_name="user2_first_name", last_name="user2_last_name", email="user2@department.com", departments=[test_dep], password="password", ) # user3 # create three users, one for lead and two for users user3 = User( name="User3 Test Persistence Department", login="u3tpd", initials="u3tpd", description="this is for testing purposes", created_by=None, updated_by=None, login_name="user3_tp_department", first_name="user3_first_name", last_name="user3_last_name", email="user3@department.com", departments=[test_dep], password="password", ) DBSession.save([user1, user2, user3]) # add as the users test_dep.users = [user1, user2, user3] DBSession.save(test_dep) assert test_dep in DBSession created_by = test_dep.created_by date_created = test_dep.date_created date_updated = test_dep.date_updated description = test_dep.description users = [u for u in test_dep.users] name = test_dep.name nice_name = test_dep.nice_name notes = test_dep.notes tags = test_dep.tags updated_by = test_dep.updated_by del test_dep # let's check the data # first get the department from the db test_dep_db = Department.query.filter_by(name=name).first() assert isinstance(test_dep_db, Department) assert created_by == test_dep_db.created_by assert date_created == test_dep_db.date_created assert date_updated == test_dep_db.date_updated assert description == test_dep_db.description assert users == test_dep_db.users assert name == test_dep_db.name assert nice_name == test_dep_db.nice_name assert notes == test_dep_db.notes assert tags == test_dep_db.tags assert updated_by == test_dep_db.updated_by def test_persistence_of_entity(setup_postgresql_db): """Persistence of Entity.""" # create an Entity with a couple of tags # the Tag1 name = "Tag1_test_creating_an_Entity" description = "this is for testing purposes" created_by = None updated_by = None date_created = date_updated = datetime.datetime.now(pytz.utc) tag1 = Tag( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, ) # the Tag2 name = "Tag2_test_creating_an_Entity" description = "this is for testing purposes" created_by = None updated_by = None date_created = date_updated = datetime.datetime.now(pytz.utc) tag2 = Tag( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, ) # the note note1 = Note(content="content for note1") note2 = Note(content="content for note2") # the entity name = "TestEntity" description = "this is for testing purposes" created_by = None updated_by = None date_created = date_updated = datetime.datetime.now(pytz.utc) test_entity = Entity( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, tags=[tag1, tag2], notes=[note1, note2], ) # assign the note1 also to another entity test_entity2 = Entity(name="Test Entity 2", notes=[note1]) # persist it to the database DBSession.add_all([test_entity, test_entity2]) DBSession.commit() # store attributes created_by = test_entity.created_by date_created = test_entity.date_created date_updated = test_entity.date_updated description = test_entity.description name = test_entity.name nice_name = test_entity.nice_name notes = test_entity.notes tags = test_entity.tags updated_by = test_entity.updated_by # delete the previous test_entity del test_entity # now try to retrieve it test_entity_db = Entity.query.filter_by(name=name).first() assert isinstance(test_entity_db, Entity) assert created_by == test_entity_db.created_by assert date_created == test_entity_db.date_created assert date_updated == test_entity_db.date_updated assert description == test_entity_db.description assert name == test_entity_db.name assert nice_name == test_entity_db.nice_name assert sorted(notes, key=lambda x: x.name) == sorted( [note1, note2], key=lambda x: x.name ) assert notes == test_entity_db.notes assert tags == test_entity_db.tags assert updated_by == test_entity_db.updated_by # delete tests # Deleting an Entity should also delete the associated notes DBSession.delete(test_entity_db) DBSession.commit() test_entity2_db = Entity.query.filter_by(name="Test Entity 2").first() assert isinstance(test_entity2_db, Entity) assert sorted([note1, note2], key=lambda x: x.name) == sorted( Note.query.all(), key=lambda x: x.name ) assert sorted([note1], key=lambda x: x.name) == sorted( test_entity2_db.notes, key=lambda x: x.name ) def test_persistence_of_entity_group(setup_postgresql_db): """Persistence of EntityGroup.""" # create a couple of task user1 = User( name="User1", login="user1", email="user1@user.com", password="1234", ) user2 = User( name="User2", login="user2", email="user2@user.com", password="1234", ) user3 = User( name="User3", login="user3", email="user3@user.com", password="1234", ) repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/M/JOBs", windows_path="M:/JOBs", macos_path="/Users/Shared/Servers/M", ) project1 = Project( name="Tests Project", code="tp", repository=repo, ) char_asset_type = Type( name="Character Asset", code="char", target_entity_type="Asset" ) asset1 = Asset( name="Char1", code="char1", type=char_asset_type, project=project1, responsible=[user1], ) task1 = Task( name="Test Task", watchers=[user3], parent=asset1, ) child_task1 = Task( name="Child Task 1", resources=[user1, user2], parent=task1, ) child_task2 = Task( name="Child Task 2", resources=[user1, user2], parent=task1, ) task2 = Task( name="Another Task", project=project1, resources=[user1], responsible=[user2], ) entity_group1 = EntityGroup(name="My Tasks") entity_group1.entities = [task1, child_task2, task2] DBSession.add_all( [task1, child_task1, child_task2, task2, user1, user2, entity_group1] ) DBSession.commit() created_by = entity_group1.created_by date_created = entity_group1.date_created date_updated = entity_group1.date_updated name = entity_group1.name entities = entity_group1.entities tags = entity_group1.tags type_ = entity_group1.type updated_by = entity_group1.updated_by del entity_group1 # now query it back entity_group1_db = EntityGroup.query.filter_by(name=name).first() assert isinstance(entity_group1_db, EntityGroup) assert created_by == entity_group1_db.created_by assert date_created == entity_group1_db.date_created assert date_updated == entity_group1_db.date_updated assert name == entity_group1_db.name assert tags == entity_group1_db.tags assert sorted(entities, key=lambda x: x.name) == sorted( entity_group1_db.entities, key=lambda x: x.name ) assert sorted(entities, key=lambda x: x.id) == sorted( [task1, child_task2, task2], key=lambda x: x.id ) assert type_ == entity_group1_db.type assert updated_by == entity_group1_db.updated_by # delete tests # deleting entity group will not delete the contained entities DBSession.delete(entity_group1_db) DBSession.commit() assert sorted( [task1, asset1, child_task1, child_task2, task2], key=lambda x: x.name ) == sorted(Task.query.all(), key=lambda x: x.name) # We still should have the users intact admin = User.query.filter_by(name="admin").first() assert sorted([user1, user2, user3, admin], key=lambda x: x.name) == sorted( User.query.all(), key=lambda x: x.name ) assert sorted( [asset1, task1, child_task1, child_task2, task2], key=lambda x: x.name ) == sorted(Task.query.all(), key=lambda x: x.name) def test_persistence_of_filename_template(setup_postgresql_db): """Persistence of FilenameTemplate.""" ref_type = Type.query.filter_by(name="Reference").first() # create a FilenameTemplate object for movie files kwargs = { "name": "Movie Files Template", "target_entity_type": "File", "type": ref_type, "description": "This is a template to be used for movie files.", "path": "REFS/{{file.type.name}}", "filename": "{{file.file_name}}", "output_path": "OUTPUT", "output_file_code": "{{file.file_name}}", } new_type_template = FilenameTemplate(**kwargs) # persist it DBSession.add(new_type_template) DBSession.commit() created_by = new_type_template.created_by date_created = new_type_template.date_created date_updated = new_type_template.date_updated description = new_type_template.description filename = new_type_template.filename name = new_type_template.name nice_name = new_type_template.nice_name notes = new_type_template.notes path = new_type_template.path tags = new_type_template.tags target_entity_type = new_type_template.target_entity_type updated_by = new_type_template.updated_by type_ = new_type_template.type del new_type_template # get it back new_type_template_db = FilenameTemplate.query.filter_by(name=kwargs["name"]).first() assert isinstance(new_type_template_db, FilenameTemplate) assert new_type_template_db.created_by == created_by assert new_type_template_db.date_created == date_created assert new_type_template_db.date_updated == date_updated assert new_type_template_db.description == description assert new_type_template_db.filename == filename assert new_type_template_db.name == name assert new_type_template_db.nice_name == nice_name assert new_type_template_db.notes == notes assert new_type_template_db.path == path assert new_type_template_db.tags == tags assert new_type_template_db.target_entity_type == target_entity_type assert new_type_template_db.updated_by == updated_by assert new_type_template_db.type == type_ def test_persistence_of_image_format(setup_postgresql_db): """Persistence of ImageFormat.""" # create a new ImageFormat object and try to read it back kwargs = { "name": "HD", "description": "test image format", "width": 1920, "height": 1080, "pixel_aspect": 1.0, "print_resolution": 300.0, } # create the ImageFormat object im_format = ImageFormat(**kwargs) # persist it DBSession.add(im_format) DBSession.commit() # store attributes created_by = im_format.created_by date_created = im_format.date_created date_updated = im_format.date_updated description = im_format.description device_aspect = im_format.device_aspect height = im_format.height name = im_format.name nice_name = im_format.nice_name notes = im_format.notes pixel_aspect = im_format.pixel_aspect print_resolution = im_format.print_resolution tags = im_format.tags updated_by = im_format.updated_by width = im_format.width # delete the previous im_format del im_format # get it back im_format_db = ImageFormat.query.filter_by(name=kwargs["name"]).first() assert isinstance(im_format_db, ImageFormat) # just test the repository part of the attributes assert im_format_db.created_by == created_by assert im_format_db.date_created == date_created assert im_format_db.date_updated == date_updated assert im_format_db.description == description assert im_format_db.device_aspect == device_aspect assert im_format_db.height == height assert im_format_db.name == name assert im_format_db.nice_name == nice_name assert im_format_db.notes == notes assert im_format_db.pixel_aspect == pixel_aspect assert im_format_db.print_resolution == print_resolution assert im_format_db.tags == tags assert im_format_db.updated_by == updated_by assert im_format_db.width == width def test_persistence_of_file(setup_postgresql_db): """Persistence of File.""" # user user1 = User( name="Test User 1", login="tu1", email="test@users.com", password="secret" ) DBSession.add(user1) DBSession.commit() # create a file Type sound_file_type = Type(name="Sound", code="sound", target_entity_type="File") image_seq_type = Type( name="JPEG Sequence", code="JPEGSeq", target_entity_type="File" ) video_type = Type(name="Video", code="Video", target_entity_type="File") # create some reference Files ref1 = File( name="My Image Sequence #1", full_path="M:/PROJECTS/my_image_sequence.#.jpg", type=image_seq_type, created_by=user1, created_with="Maya", ) ref2 = File( name="My Movie #1", full_path="M:/PROJECTS/my_movie.mp4", type=video_type, created_by=user1, created_with="Blender", ) DBSession.save([ref1, ref2]) # create the main File kwargs = { "name": "My Sound", "full_path": "M:/PROJECTS/my_movie_sound.wav", "references": [ref1, ref2], "type": sound_file_type, "created_by": user1, "created_with": "Houdini", } file1 = File(**kwargs) # persist it DBSession.add_all([sound_file_type, file1]) DBSession.commit() # use it as a task reference repo1 = Repository(name="test repo", code="TR") project1 = Project(name="Test Project 1", code="TP1", repository=repo1) task1 = Task(name="Test Task", project=project1, responsible=[user1]) task1.references.append(file1) DBSession.add(task1) DBSession.commit() # store attributes created_by = file1.created_by created_with = file1.created_with date_created = file1.date_created date_updated = file1.date_updated description = file1.description full_path = file1.full_path name = file1.name nice_name = file1.nice_name notes = file1.notes references = file1.references assert isinstance(references, list) assert len(references) > 0 tags = file1.tags type_ = file1.type updated_by = file1.updated_by # delete the File del file1 # retrieve it back file1_db = File.query.filter_by(name=kwargs["name"]).first() assert isinstance(file1_db, File) assert file1_db.created_by == created_by assert file1_db.created_with == created_with assert file1_db.date_created == date_created assert file1_db.date_updated == date_updated assert file1_db.description == description assert file1_db.full_path == full_path assert file1_db.name == name assert file1_db.nice_name == nice_name assert file1_db.notes == notes assert file1_db.references == references assert file1_db.tags == tags assert file1_db.type == type_ assert file1_db.updated_by == updated_by assert file1_db == task1.references[0] # delete tests task1.references.remove(file1_db) # Deleting a File should not delete anything else DBSession.delete(file1_db) DBSession.commit() # We still should have the user and the type intact assert DBSession.get(User, user1.id) is not None assert user1 == DBSession.get(User, user1.id) assert DBSession.get(Type, type_.id) is not None assert DBSession.get(Type, type_.id) == type_ # The task should stay assert DBSession.get(Task, task1.id) is not None assert DBSession.get(Task, task1.id) == task1 def test_persistence_of_note(setup_postgresql_db): """Persistence of Note.""" # create a Note and attach it to an entity # create a Note object note_kwargs = { "name": "Note1", "description": "This Note is created for the purpose of testing \ the Note object", "content": "Please be carefull about this asset, I will fix the \ rig later on", } test_note = Note(**note_kwargs) # create an entity entity_kwargs = { "name": "Entity with Note", "description": "This Entity is created for testing purposes", "notes": [test_note], } test_entity = Entity(**entity_kwargs) DBSession.add_all([test_entity, test_note]) DBSession.commit() # store the attributes content = test_note.content created_by = test_note.created_by date_created = test_note.date_created date_updated = test_note.date_updated description = test_note.description name = test_note.name nice_name = test_note.nice_name updated_by = test_note.updated_by # delete the note del test_note # try to get the note directly test_note_db = Note.query.filter(Note.name == note_kwargs["name"]).first() assert isinstance(test_note_db, Note) assert test_note_db.content == content assert test_note_db.created_by == created_by assert test_note_db.date_created == date_created assert test_note_db.date_updated == date_updated assert test_note_db.description == description assert test_note_db.name == name assert test_note_db.nice_name == nice_name assert test_note_db.updated_by == updated_by def test_persistence_of_good(setup_postgresql_db): """hte persistence of Good.""" g1 = Good(name="Test Good 1", cost=10, msrp=100, unit="TRY") DBSession.add(g1) DBSession.commit() name = g1.name cost = g1.cost msrp = g1.msrp unit = g1.unit del g1 g1_db = Good.query.first() assert g1_db.name == name assert g1_db.cost == cost assert g1_db.msrp == msrp assert g1_db.unit == unit # attach a client client = Client(name="Test Client") DBSession.add(client) g1_db.client = client DBSession.commit() del g1_db g1_db2 = Good.query.first() assert g1_db2.client == client # Delete the good DBSession.delete(g1_db2) DBSession.commit() # except the client still exist client_db = Client.query.filter(Client.name == "Test Client").first() assert client_db is not None def test_persistence_of_group(setup_postgresql_db): """Persistence of Group.""" group1 = Group(name="Test Group") user1 = User(name="User1", login="user1", email="user1@test.com", password="12") user2 = User(name="User2", login="user2", email="user2@test.com", password="34") group1.users = [user1, user2] DBSession.add(group1) DBSession.commit() name = group1.name users = group1.users del group1 group_db = Group.query.filter_by(name=name).first() assert group_db.name == name assert group_db.users == users def test_persistence_of_price_list(setup_postgresql_db): """Persistence of PriceList.""" g1 = Good(name="Test Good 1") g2 = Good(name="Test Good 2") g3 = Good(name="Test Good 3") p = PriceList(name="Test Price List", goods=[g1, g2, g3]) DBSession.add_all([p, g1, g2, g3]) DBSession.commit() del p p_db = PriceList.query.first() assert p_db.name == "Test Price List" assert sorted(p_db.goods, key=lambda x: x.id) == sorted( [g1, g2, g3], key=lambda x: x.id ) DBSession.delete(p_db) DBSession.commit() # we should still have goods assert g1 is not None assert g2 is not None assert g3 is not None g1_db = Good.query.filter_by(name="Test Good 1").first() assert g1_db is not None assert g1_db.name == "Test Good 1" g2_db = Good.query.filter_by(name="Test Good 2").first() assert g2_db is not None assert g2_db.name == "Test Good 2" g3_db = Good.query.filter_by(name="Test Good 3").first() assert g3_db is not None assert g3_db.name == "Test Good 3" def test_persistence_of_project(setup_postgresql_db): """Persistence of Project.""" # create mock objects start = datetime.datetime(2016, 11, 17, tzinfo=pytz.utc) + datetime.timedelta(10) end = start + datetime.timedelta(days=20) lead = User(name="lead", login="lead", email="lead@lead.com", password="password") user1 = User( name="user1", login="user1", email="user1@user1.com", password="password" ) user2 = User( name="user2", login="user2", email="user1@user2.com", password="password" ) user3 = User( name="user3", login="user3", email="user3@user3.com", password="password" ) image_format = ImageFormat(name="HD", width=1920, height=1080) project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project" ) structure_type = Type( name="Commercial Structure", code="commstr", target_entity_type="Project" ) project_structure = Structure( name="Commercial Structure", custom_templates="{{project.code}}\n" "{{project.code}}/ASSETS\n" "{{project.code}}/SEQUENCES\n", type=structure_type, ) repo = Repository( name="Commercials Repository", code="CR", linux_path="/mnt/M/Projects", windows_path="M:/Projects", macos_path="/mnt/M/Projects", ) # create data for mixins # Reference Mixin file_type = Type(name="Image", code="image", target_entity_type="File") ref1 = File( name="Ref1", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="1.jpg", type=file_type, ) ref2 = File( name="Ref2", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="1.jpg", type=file_type, ) DBSession.save([lead, ref1, ref2]) working_hours = WorkingHours( working_hours={ "mon": [[570, 720], [780, 1170]], "tue": [[570, 720], [780, 1170]], "wed": [[570, 720], [780, 1170]], "thu": [[570, 720], [780, 1170]], "fri": [[570, 720], [780, 1170]], "sat": [[570, 720], [780, 1170]], "sun": [], } ) # create a project object kwargs = { "name": "Test Project", "code": "TP", "description": "This is a project object for testing purposes", "image_format": image_format, "fps": 25, "type": project_type, "structure": project_structure, "repositories": [repo], "is_stereoscopic": False, "display_width": 1.0, "start": start, "end": end, "status": 0, "references": [ref1, ref2], "working_hours": working_hours, } new_project = Project(**kwargs) # persist it in the database DBSession.add(new_project) DBSession.commit() task1 = Task( name="task1", status=0, project=new_project, resources=[user1, user2], responsible=[user1], ) task2 = Task( name="task2", status=0, project=new_project, resources=[user3], responsible=[user1], ) dt = datetime.datetime td = datetime.timedelta new_project._computed_start = dt.now(pytz.utc) new_project._computed_end = dt.now(pytz.utc) + td(10) DBSession.add_all([task1, task2]) DBSession.commit() # add tickets ticket1 = Ticket(project=new_project) DBSession.add(ticket1) DBSession.commit() # create dailies d1 = Daily(name="Daily1", project=new_project) d2 = Daily(name="Daily2", project=new_project) d3 = Daily(name="Daily3", project=new_project) DBSession.add_all([d1, d2, d3]) DBSession.commit() # store the attributes assets = new_project.assets code = new_project.code created_by = new_project.created_by date_created = new_project.date_created date_updated = new_project.date_updated description = new_project.description end = new_project.end duration = new_project.duration fps = new_project.fps image_format = new_project.image_format is_stereoscopic = new_project.is_stereoscopic name = new_project.name nice_name = new_project.nice_name notes = new_project.notes references = new_project.references repositories = [repo] sequences = new_project.sequences start = new_project.start status = new_project.status status_list = new_project.status_list structure = new_project.structure tags = new_project.tags tasks = new_project.tasks type_ = new_project.type updated_by = new_project.updated_by users = [user for user in new_project.users] computed_start = new_project.computed_start computed_end = new_project.computed_end # delete the project del new_project # now get it new_project_db = DBSession.query(Project).filter_by(name=kwargs["name"]).first() assert isinstance(new_project_db, Project) assert new_project_db.assets == assets assert new_project_db.code == code assert new_project_db.computed_start == computed_start assert new_project_db.computed_end == computed_end assert new_project_db.created_by == created_by assert new_project_db.date_created == date_created assert new_project_db.date_updated == date_updated assert new_project_db.description == description assert new_project_db.end == end assert new_project_db.duration == duration assert new_project_db.fps == fps assert new_project_db.image_format == image_format assert new_project_db.is_stereoscopic == is_stereoscopic assert new_project_db.name == name assert new_project_db.nice_name == nice_name assert new_project_db.notes == notes assert new_project_db.references == references assert new_project_db.repositories == repositories assert new_project_db.sequences == sequences assert new_project_db.start == start assert new_project_db.status == status assert new_project_db.status_list == status_list assert new_project_db.structure == structure assert new_project_db.tags == tags assert new_project_db.tasks == tasks assert new_project_db.type == type_ assert new_project_db.updated_by == updated_by assert new_project_db.users == users # delete tests # now delete the project and expect the following also to be deleted # # Tasks # Tickets DBSession.delete(new_project_db) DBSession.commit() # Tasks assert Task.query.all() == [] # Tickets assert Ticket.query.all() == [] # Dailies assert Daily.query.all() == [] def test_persistence_of_repository(setup_postgresql_db): """Persistence of Repository.""" # create a new Repository object and try to read it back kwargs = { "name": "Movie-Repo", "code": "MR", "description": "test repository", "linux_path": "/mnt/M", "macos_path": "/Volumes/M", "windows_path": "M:/", } # create the repository object repo = Repository(**kwargs) # save it to database DBSession.add(repo) DBSession.commit() # store attributes created_by = repo.created_by code = repo.code date_created = repo.date_created date_updated = repo.date_updated description = repo.description linux_path = repo.linux_path name = repo.name nice_name = repo.nice_name notes = repo.notes macos_path = repo.macos_path path = repo.path tags = repo.tags updated_by = repo.updated_by windows_path = repo.windows_path # delete the repo del repo # get it back repo_db = Repository.query.filter_by(name=kwargs["name"]).first() assert isinstance(repo_db, Repository) assert repo_db.created_by == created_by assert repo_db.code == code assert repo_db.date_created == date_created assert repo_db.date_updated == date_updated assert repo_db.description == description assert repo_db.linux_path == linux_path assert repo_db.name == name assert repo_db.nice_name == nice_name assert repo_db.notes == notes assert repo_db.macos_path == macos_path assert repo_db.path == path assert repo_db.tags == tags assert repo_db.updated_by == updated_by assert repo_db.windows_path == windows_path def test_persistence_of_scene(setup_postgresql_db): """Persistence of Scene.""" repo1 = Repository( name="Commercial Repository", code="CR", ) user1 = User( name="User1", login="user1", email="user1@user.com", password="1234", ) commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project" ) test_project1 = Project( name="Test Project", code="TP", type=commercial_project_type, repository=repo1, ) DBSession.add(test_project1) DBSession.commit() kwargs = { "name": "Test Scene", "code": "TSce", "description": "this is a test scene", "project": test_project1, } test_scene = Scene(**kwargs) # now add the shots shot1 = Shot( code="SH001", project=test_project1, scene=test_scene, responsible=[user1], ) shot2 = Shot( code="SH002", project=test_project1, scene=test_scene, responsible=[user1], ) shot3 = Shot( code="SH003", project=test_project1, scene=test_scene, responsible=[user1], ) DBSession.add_all([shot1, shot2, shot3]) DBSession.add(test_scene) DBSession.commit() # store the attributes code = test_scene.code created_by = test_scene.created_by date_created = test_scene.date_created date_updated = test_scene.date_updated description = test_scene.description name = test_scene.name nice_name = test_scene.nice_name notes = test_scene.notes project = test_scene.project shots = test_scene.shots tags = test_scene.tags updated_by = test_scene.updated_by # delete the test_sequence del test_scene test_scene_db = Scene.query.filter_by(name=kwargs["name"]).first() assert test_scene_db.code == code assert test_scene_db.created_by == created_by assert test_scene_db.date_created == date_created assert test_scene_db.date_updated == date_updated assert test_scene_db.description == description assert test_scene_db.name == name assert test_scene_db.nice_name == nice_name assert test_scene_db.notes == notes assert test_scene_db.project == project assert test_scene_db.shots == shots assert test_scene_db.tags == tags assert test_scene_db.updated_by == updated_by def test_persistence_of_sequence(setup_postgresql_db): """Persistence of Sequence.""" repo1 = Repository(name="Commercial Repository", code="CR") commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project" ) lead = User(name="lead", login="lead", email="lead@lead.com", password="password") test_project1 = Project( name="Test Project", code="TP", type=commercial_project_type, repository=repo1, ) DBSession.add(test_project1) DBSession.commit() kwargs = { "name": "Test Sequence", "code": "TS", "description": "this is a test sequence", "project": test_project1, "schedule_model": ScheduleModel.Effort, "schedule_timing": 50, "schedule_unit": TimeUnit.Day, "responsible": [lead], } test_sequence = Sequence(**kwargs) # now add the shots shot1 = Shot( code="SH001", project=test_project1, sequence=test_sequence, responsible=[lead], ) shot2 = Shot( code="SH002", project=test_project1, sequence=test_sequence, responsible=[lead], ) shot3 = Shot( code="SH003", project=test_project1, sequence=test_sequence, responsible=[lead], ) DBSession.add_all([shot1, shot2, shot3]) DBSession.add(test_sequence) DBSession.commit() # store the attributes code = test_sequence.code created_by = test_sequence.created_by date_created = test_sequence.date_created date_updated = test_sequence.date_updated description = test_sequence.description end = test_sequence.end name = test_sequence.name nice_name = test_sequence.nice_name notes = test_sequence.notes project = test_sequence.project references = test_sequence.references shots = test_sequence.shots start = test_sequence.start status = test_sequence.status status_list = test_sequence.status_list tags = test_sequence.tags children = test_sequence.children tasks = test_sequence.tasks updated_by = test_sequence.updated_by schedule_model = test_sequence.schedule_model schedule_timing = test_sequence.schedule_timing schedule_unit = test_sequence.schedule_unit # delete the test_sequence del test_sequence test_sequence_db = Sequence.query.filter_by(name=kwargs["name"]).first() assert test_sequence_db.code == code assert test_sequence_db.created_by == created_by assert test_sequence_db.date_created == date_created assert test_sequence_db.date_updated == date_updated assert test_sequence_db.description == description assert test_sequence_db.end == end assert test_sequence_db.name == name assert test_sequence_db.nice_name == nice_name assert test_sequence_db.notes == notes assert test_sequence_db.project == project assert test_sequence_db.references == references assert test_sequence_db.shots == shots assert test_sequence_db.start == start assert test_sequence_db.status == status assert test_sequence_db.status_list == status_list assert test_sequence_db.tags == tags assert test_sequence_db.children == children assert test_sequence_db.tasks == tasks assert test_sequence_db.updated_by == updated_by assert test_sequence_db.schedule_model == schedule_model assert test_sequence_db.schedule_timing == schedule_timing assert test_sequence_db.schedule_unit == schedule_unit def test_persistence_of_shot(setup_postgresql_db): """Persistence of Shot.""" commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) repo1 = Repository(name="Commercial Repository", code="CR") lead = User(name="lead", login="lead", email="lead@lead.com", password="password") test_project1 = Project( name="Test project", code="tp", type=commercial_project_type, repository=repo1, ) DBSession.add(test_project1) DBSession.commit() kwargs = { "name": "Test Sequence 1", "code": "tseq1", "description": "this is a test sequence", "project": test_project1, "responsible": [lead], } test_seq1 = Sequence(**kwargs) kwargs["name"] = "Test Sequence 2" kwargs["code"] = "tseq2" test_seq2 = Sequence(**kwargs) test_sce1 = Scene(name="Test Scene 1", code="tsce1", project=test_project1) test_sce2 = Scene(name="Test Scene 2", code="tsce2", project=test_project1) # now add the shots shot_kwargs = { "code": "SH001", "project": test_project1, "sequence": test_seq1, "scene": test_sce1, "status": 0, "responsible": [lead], } test_shot = Shot(**shot_kwargs) DBSession.save([test_shot, test_seq1]) # store the attributes code = test_shot.code children = test_shot.children cut_duration = test_shot.cut_duration cut_in = test_shot.cut_in cut_out = test_shot.cut_out date_created = test_shot.date_created date_updated = test_shot.date_updated description = test_shot.description name = test_shot.name nice_name = test_shot.nice_name notes = test_shot.notes references = test_shot.references sequence = test_shot.sequence scene = test_shot.scene status = test_shot.status status_list = test_shot.status_list tags = test_shot.tags tasks = test_shot.tasks updated_by = test_shot.updated_by fps = test_shot.fps # delete the shot del test_shot test_shot_db = Shot.query.filter_by(code=shot_kwargs["code"]).first() assert test_shot_db.code == code assert test_shot_db.children == children assert test_shot_db.cut_duration == cut_duration assert test_shot_db.cut_in == cut_in assert test_shot_db.cut_out == cut_out assert test_shot_db.date_created == date_created assert test_shot_db.date_updated == date_updated assert test_shot_db.description == description assert test_shot_db.name == name assert test_shot_db.nice_name == nice_name assert test_shot_db.notes == notes assert test_shot_db.references == references assert test_shot_db.scene == scene assert test_shot_db.sequence == sequence assert test_shot_db.status == status assert test_shot_db.status_list == status_list assert test_shot_db.tags == tags assert test_shot_db.tasks == tasks assert test_shot_db.updated_by == updated_by assert test_shot_db.fps == fps def test_persistence_of_simple_entity(setup_postgresql_db): """Persistence of SimpleEntity.""" thumbnail = File() DBSession.add(thumbnail) kwargs = { "name": "Simple Entity 1", "description": "this is for testing purposes", "thumbnail": thumbnail, "html_style": "width: 100px; color: purple", "html_class": "purple", "generic_text": json.dumps({"some_string": "hello world"}, sort_keys=True), } test_simple_entity = SimpleEntity(**kwargs) # persist it to the database DBSession.add(test_simple_entity) DBSession.commit() created_by = test_simple_entity.created_by date_created = test_simple_entity.date_created date_updated = test_simple_entity.date_updated description = test_simple_entity.description name = test_simple_entity.name nice_name = test_simple_entity.nice_name updated_by = test_simple_entity.updated_by html_style = test_simple_entity.html_style html_class = test_simple_entity.html_class generic_text = test_simple_entity.generic_text stalker_version = test_simple_entity.stalker_version del test_simple_entity # now try to retrieve it test_simple_entity_db = SimpleEntity.query.filter( SimpleEntity.name == kwargs["name"] ).first() assert isinstance(test_simple_entity_db, SimpleEntity) assert test_simple_entity_db.created_by == created_by assert test_simple_entity_db.date_created == date_created assert test_simple_entity_db.date_updated == date_updated assert test_simple_entity_db.description == description assert test_simple_entity_db.name == name assert test_simple_entity_db.nice_name == nice_name assert test_simple_entity_db.updated_by == updated_by assert test_simple_entity_db.html_style == html_style assert test_simple_entity_db.html_class == html_class print(test_simple_entity_db.stalker_version) assert test_simple_entity_db.stalker_version == stalker_version assert thumbnail is not None assert test_simple_entity_db.thumbnail == thumbnail assert generic_text is not None assert test_simple_entity_db.generic_text == generic_text def test_persistence_of_status(setup_postgresql_db): """Persistence of Status.""" # the status kwargs = { "name": "TestStatus_test_creating_Status", "description": "this is for testing purposes", "code": "TSTST", } test_status = Status(**kwargs) # persist it to the database DBSession.add(test_status) DBSession.commit() # store the attributes code = test_status.code created_by = test_status.created_by date_created = test_status.date_created date_updated = test_status.date_updated description = test_status.description name = test_status.name nice_name = test_status.nice_name notes = test_status.notes tags = test_status.tags updated_by = test_status.updated_by # delete the test_status del test_status # now try to retrieve it test_status_db = Status.query.filter(Status.name == kwargs["name"]).first() assert isinstance(test_status_db, Status) # just test the status part of the object assert test_status_db.code == code assert test_status_db.created_by == created_by assert test_status_db.date_created == date_created assert test_status_db.date_updated == date_updated assert test_status_db.description == description assert test_status_db.name == name assert test_status_db.nice_name == nice_name assert test_status_db.notes == notes assert test_status_db.tags == tags assert test_status_db.updated_by == updated_by def test_persistence_of_status_list(setup_postgresql_db): """Persistence of StatusList.""" # create a couple of statuses statuses = [ Status(name="Waiting To Start", code="WTS"), Status(name="On Hold A", code="OHA"), Status(name="Work In Progress A", code="WIPA"), Status(name="Complete A", code="CMPLA"), ] kwargs = dict( name="Hede Hodo Status List", statuses=statuses, target_entity_type="Hede Hodo", ) sequence_status_list = StatusList(**kwargs) DBSession.add(sequence_status_list) DBSession.commit() # store the attributes created_by = sequence_status_list.created_by date_created = sequence_status_list.date_created date_updated = sequence_status_list.date_updated description = sequence_status_list.description name = sequence_status_list.name nice_name = sequence_status_list.nice_name notes = sequence_status_list.notes statuses = sequence_status_list.statuses tags = sequence_status_list.tags target_entity_type = sequence_status_list.target_entity_type updated_by = sequence_status_list.updated_by # delete the sequence_status_list del sequence_status_list # now get it back sequence_status_list_db = StatusList.query.filter_by(name=kwargs["name"]).first() assert isinstance(sequence_status_list_db, StatusList) assert sequence_status_list_db.created_by == created_by assert sequence_status_list_db.date_created == date_created assert sequence_status_list_db.date_updated == date_updated assert sequence_status_list_db.description == description assert sequence_status_list_db.name == name assert sequence_status_list_db.nice_name == nice_name assert sequence_status_list_db.notes == notes assert sequence_status_list_db.statuses == statuses assert sequence_status_list_db.tags == tags assert sequence_status_list_db.target_entity_type == target_entity_type assert sequence_status_list_db.updated_by == updated_by # try to create another StatusList for the same target_entity_type # and do not expect an IntegrityError unless it is committed. kwargs["name"] = "new Sequence Status List" new_sequence_list = StatusList(**kwargs) DBSession.add(new_sequence_list) assert new_sequence_list in DBSession with pytest.raises(IntegrityError) as cm: DBSession.commit() assert ( "(psycopg2.errors.UniqueViolation) duplicate key value " "violates unique constraint " '"StatusLists_target_entity_type_key"' in str(cm.value) ) # roll it back DBSession.rollback() def test_persistence_of_structure(setup_postgresql_db): """Persistence of Structure.""" # create pipeline steps for character modeling_task_type = Type( name="Modeling", code="model", description="This is the step where all the modeling job is done", target_entity_type="Task", ) animation_task_type = Type( name="Animation", description="This is the step where all the animation job is " "done it is not limited with characters, other " "things can also be animated", code="Anim", target_entity_type="Task", ) # create a new asset Type char_asset_type = Type( name="Character", code="char", description="This is the asset type which covers animated " "characters", target_entity_type="Asset", ) # get the Version Type for FilenameTemplates v_type = ( Type.query.filter_by(target_entity_type="FilenameTemplate") .filter_by(name="Version") .first() ) # create a new type template for character assets asset_template = FilenameTemplate( name="Character Asset Template", description="This is the template for character assets", path="Assets/{{asset_type.name}}/{{pipeline_step.code}}", filename="{{asset.name}}_{{asset_type.name}}" "_r{{version.revision_number}}" "_v{{version.version_number}}", target_entity_type="Asset", type=v_type, ) # create a new file type image_file_type = Type( name="Image", code="image", description="It is used for image files.", target_entity_type="File", ) # get reference Type of FilenameTemplates r_type = ( Type.query.filter_by(target_entity_type="FilenameTemplate") .filter_by(name="Reference") .first() ) # create a new template for references image_reference_template = FilenameTemplate( name="Image Reference Template", description="this is the template for image references, it " "shows where to place the image files", path="REFS/{{reference.type.name}}", filename="{{reference.file_name}}", target_entity_type="File", type=r_type, ) commercial_structure_type = Type( name="Commercial", code="commercial", target_entity_type="Structure" ) # create a new structure kwargs = { "name": "Commercial Structure", "description": "The structure for commercials", "custom_template": """ Assets Sequences Sequences/{% for sequence in project.sequences %} {{sequence.code}}""", "templates": [asset_template, image_reference_template], "type": commercial_structure_type, } new_structure = Structure(**kwargs) DBSession.add_all( [ new_structure, modeling_task_type, animation_task_type, char_asset_type, image_file_type, ] ) DBSession.commit() # store the attributes templates = new_structure.templates created_by = new_structure.created_by date_created = new_structure.date_created date_updated = new_structure.date_updated description = new_structure.description name = new_structure.name nice_name = new_structure.nice_name notes = new_structure.notes custom_template = new_structure.custom_template tags = new_structure.tags updated_by = new_structure.updated_by # delete the new_structure del new_structure new_structure_db = Structure.query.filter_by(name=kwargs["name"]).first() assert isinstance(new_structure_db, Structure) assert new_structure_db.templates == templates assert new_structure_db.created_by == created_by assert new_structure_db.date_created == date_created assert new_structure_db.date_updated == date_updated assert new_structure_db.description == description assert new_structure_db.name == name assert new_structure_db.nice_name == nice_name assert new_structure_db.notes == notes assert new_structure_db.custom_template == custom_template assert new_structure_db.tags == tags assert new_structure_db.updated_by == updated_by def test_persistence_of_studio(setup_postgresql_db): """Persistence of Studio.""" test_studio = Studio(name="Test Studio") DBSession.add(test_studio) DBSession.commit() # customize attributes test_studio.daily_working_hours = 11 test_studio.working_hours = WorkingHours( working_hours={"mon": [], "sat": [[100, 1300]]} ) test_studio.timing_resolution = datetime.timedelta(hours=1, minutes=30) name = test_studio.name daily_working_hours = test_studio.daily_working_hours timing_resolution = test_studio._timing_resolution working_hours = test_studio.working_hours # now = test_studio.now del test_studio # get it back test_studio_db = Studio.query.first() assert test_studio_db.name == name assert test_studio_db.daily_working_hours == daily_working_hours assert test_studio_db.timing_resolution == timing_resolution assert test_studio_db.working_hours == working_hours def test_persistence_of_tag(setup_postgresql_db): """Persistence of Tag.""" name = "Tag_test_creating_a_Tag" description = "this is for testing purposes" created_by = None updated_by = None date_created = date_updated = datetime.datetime.now(pytz.utc) tag = Tag( name=name, description=description, created_by=created_by, updated_by=updated_by, date_created=date_created, date_updated=date_updated, ) # persist it to the database DBSession.add(tag) DBSession.commit() # store the attributes description = tag.description created_by = tag.created_by updated_by = tag.updated_by date_created = tag.date_created date_updated = tag.date_updated # delete the aTag del tag # now try to retrieve it tag_db = DBSession.query(Tag).filter_by(name=name).first() assert isinstance(tag_db, Tag) assert tag_db.name == name assert tag_db.description == description assert tag_db.created_by == created_by assert tag_db.updated_by == updated_by assert tag_db.date_created == date_created assert tag_db.date_updated == date_updated def test_persistence_of_task(setup_postgresql_db): """Persistence of Task.""" # create a task user1 = User( name="User1", login="user1", email="user1@user.com", password="1234", ) user2 = User( name="User2", login="user2", email="user2@user.com", password="1234", ) user3 = User( name="User3", login="user3", email="user3@user.com", password="1234", ) repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/M/JOBs", windows_path="M:/JOBs", macos_path="/Users/Shared/Servers/M", ) project1 = Project( name="Tests Project", code="tp", repository=repo, ) DBSession.add(project1) DBSession.commit() char_asset_type = Type( name="Character Asset", code="char", target_entity_type="Asset" ) asset1 = Asset( name="Char1", code="char1", type=char_asset_type, project=project1, responsible=[user1], ) task1 = Task( name="Test Task", watchers=[user3], parent=asset1, schedule_timing=10, schedule_unit=TimeUnit.Hour, schedule_model=ScheduleModel.Effort, schedule_constraint=ScheduleConstraint.Start, ) child_task1 = Task( name="Child Task 1", resources=[user1, user2], parent=task1, ) child_task2 = Task( name="Child Task 2", resources=[user1, user2], parent=task1, ) task2 = Task( name="Another Task", project=project1, resources=[user1], responsible=[user2], ) DBSession.add_all([asset1, task1, child_task1, child_task2, task2]) DBSession.commit() # time logs time_log1 = TimeLog( task=child_task1, resource=user1, start=datetime.datetime.now(pytz.utc), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(1), ) task1.computed_start = datetime.datetime.now(pytz.utc) task1.computed_end = datetime.datetime.now(pytz.utc) + datetime.timedelta(10) time_log2 = TimeLog( task=child_task2, resource=user1, start=datetime.datetime.now(pytz.utc) + datetime.timedelta(1), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(2), ) # time log for another task time_log3 = TimeLog( task=task2, resource=user1, start=datetime.datetime.now(pytz.utc) + datetime.timedelta(2), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(3), ) # Versions repr_type = Type(name="Representation", code="Repr", target_entity_type="File") DBSession.save(repr_type) file1 = File(name="Version1 Base Repr", type=repr_type) file2 = File(name="Version2 Base Repr", type=repr_type) file3 = File(name="Version3 Base Repr", type=repr_type) file4 = File(name="Version4 Base Repr", type=repr_type) DBSession.save([file1, file2, file3, file4]) file3.references = [file2] file2.references = [file1, file4] DBSession.commit() version1 = Version(task=task1) DBSession.add(version1) DBSession.commit() version2 = Version(task=task1) DBSession.add(version2) DBSession.commit() version3 = Version(task=task2) DBSession.add(version3) DBSession.commit() version4 = Version(task=task2) DBSession.add(version4) DBSession.commit() DBSession.add(version1) DBSession.commit() # references ref1 = File(full_path="some_path", original_filename="original_filename") ref2 = File(full_path="some_path", original_filename="original_filename") task1.references.append(ref1) task1.references.append(ref2) DBSession.add_all( [ task1, child_task1, child_task2, task2, time_log1, time_log2, time_log3, user1, user2, version1, version2, version3, version4, ref1, ref2, ] ) DBSession.commit() computed_start = task1.computed_start computed_end = task1.computed_end created_by = task1.created_by date_created = task1.date_created date_updated = task1.date_updated duration = task1.duration end = task1.end is_milestone = task1.is_milestone name = task1.name parent = task1.parent priority = task1.priority resources = task1.resources schedule_unit = task1.schedule_unit schedule_constraint = task1.schedule_constraint schedule_model = task1.schedule_model schedule_timing = task1.schedule_timing schedule_unit = task1.schedule_unit start = task1.start status = task1.status status_list = task1.status_list tasks = task1.tasks tags = task1.tags time_logs = task1.time_logs type_ = task1.type updated_by = task1.updated_by versions = [version1, version2] watchers = task1.watchers del task1 # now query it back task1_db = Task.query.filter_by(name=name).first() assert isinstance(task1_db, Task) assert task1_db.time_logs == time_logs assert task1_db.created_by == created_by assert task1_db.computed_start == computed_start assert task1_db.computed_end == computed_end assert task1_db.date_created == date_created assert task1_db.date_updated == date_updated assert task1_db.duration == duration assert task1_db.end == end assert task1_db.is_milestone == is_milestone assert task1_db.name == name assert task1_db.parent == parent assert task1_db.priority == priority assert resources == [] # it is a parent task, no child assert task1_db.resources == resources assert task1_db.start == start assert task1_db.status == status assert task1_db.status_list == status_list assert task1_db.tags == tags assert sorted(tasks, key=lambda x: x.name) == sorted( task1_db.tasks, key=lambda x: x.name ) assert len([child_task1, child_task2]) == len(tasks) assert sorted([child_task1, child_task2], key=lambda x: x.name) == sorted( tasks, key=lambda x: x.name ) assert task1_db.type == type_ assert task1_db.updated_by == updated_by assert task1_db.versions == versions assert task1_db.watchers == watchers assert task1_db.schedule_unit == schedule_unit assert isinstance(task1_db.schedule_unit, TimeUnit) assert task1_db.schedule_constraint == schedule_constraint assert isinstance(task1_db.schedule_constraint, ScheduleConstraint) assert task1_db.schedule_model == schedule_model assert isinstance(task1_db.schedule_model, ScheduleModel) assert task1_db.schedule_timing == schedule_timing assert task1_db.schedule_unit == schedule_unit DBSession.delete(task1_db) DBSession.commit() # we still should have the versions that are in the inputs (version3 # and version4) of the original versions (version1, version2) assert DBSession.get(Version, version3.id) is not None assert DBSession.get(Version, version4.id) is not None # Expect to have all child tasks also to be deleted assert sorted([asset1, task2], key=lambda x: x.name) == sorted( Task.query.all(), key=lambda x: x.name ) # Expect to have time logs related to this task are deleted assert TimeLog.query.all() == [time_log3] # We still should have the users intact admin = User.query.filter_by(name="admin").first() assert sorted([user1, user2, user3, admin], key=lambda x: x.name) == sorted( User.query.all(), key=lambda x: x.name ) # When updating the test to include deletion, the test task became a # parent task, so all the resources are removed, thus the resource # attribute should be tested separately. resources = task2.resources id_ = task2.id del task2 another_task_db = DBSession.get(Task, id_) assert resources == [user1] assert another_task_db.resources == resources def test_persistence_of_review(setup_postgresql_db): """Persistence of Review.""" # create a task repo = Repository( name="Test Repo", code="TR", linux_path="/some/random/path", windows_path="/some/random/path", macos_path="/some/random/path", ) user1 = User( name="User1", login="user1", email="user1@user.com", password="1234", ) user2 = User( name="User2", login="user2", email="user2@user.com", password="1234", ) user3 = User( name="User3", login="user3", email="user3@user.com", password="1234", ) project1 = Project( name="Tests Project", code="tp", repository=repo, ) char_asset_type = Type( name="Character Asset", code="char", target_entity_type="Asset" ) asset1 = Asset( name="Char1", code="char1", type=char_asset_type, project=project1, responsible=[user1], ) task1 = Task( name="Test Task", watchers=[user3], parent=asset1, schedule_timing=5, schedule_unit=TimeUnit.Hour, ) child_task1 = Task( name="Child Task 1", resources=[user1, user2], parent=task1, ) child_task2 = Task( name="Child Task 2", resources=[user1, user2], parent=task1, ) task2 = Task( name="Another Task", project=project1, resources=[user1], responsible=[user1], ) # time logs time_log1 = TimeLog( task=child_task1, resource=user1, start=datetime.datetime.now(pytz.utc), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(1), ) task1.computed_start = datetime.datetime.now(pytz.utc) task1.computed_end = datetime.datetime.now(pytz.utc) + datetime.timedelta(10) time_log2 = TimeLog( task=child_task2, resource=user1, start=datetime.datetime.now(pytz.utc) + datetime.timedelta(1), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(2), ) # time log for another task time_log3 = TimeLog( task=task2, resource=user1, start=datetime.datetime.now(pytz.utc) + datetime.timedelta(2), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(3), ) DBSession.save( [ task1, child_task1, child_task2, task2, time_log1, time_log2, time_log3, user1, user2, ] ) version1 = Version(task=task2) DBSession.save(version1) rev1 = Review( task=task2, reviewer=user1, version=version1, schedule_timing=1, schedule_unit=TimeUnit.Hour, ) DBSession.save(rev1) created_by = rev1.created_by date_created = rev1.date_created date_updated = rev1.date_updated name = rev1.name schedule_timing = rev1.schedule_timing schedule_unit = rev1.schedule_unit task = rev1.task updated_by = rev1.updated_by version = rev1.version del rev1 # now query it back rev1_db = Review.query.filter_by(name=name).first() assert isinstance(rev1_db, Review) assert rev1_db.created_by == created_by assert rev1_db.date_created == date_created assert rev1_db.date_updated == date_updated assert rev1_db.name == name assert rev1_db.task == task assert rev1_db.updated_by == updated_by assert rev1_db.schedule_timing == schedule_timing assert rev1_db.schedule_unit == schedule_unit assert rev1_db.version == version # delete tests # deleting a Review should be fairly simple: DBSession.delete(rev1_db) DBSession.commit() # Expect to have no task is deleted assert sorted( [asset1, task1, task2, child_task1, child_task2], key=lambda x: x.name ) == sorted(Task.query.all(), key=lambda x: x.name) def test_persistence_of_ticket(setup_postgresql_db): """Persistence of Ticket.""" repo = Repository(name="Test Repository", code="TR") proj_structure = Structure(name="Commercials Structure") proj1 = Project( name="Test Project 1", code="TP1", repository=repo, structure=proj_structure, ) simple_entity = SimpleEntity(name="Test Simple Entity") entity = Entity(name="Test Entity") user1 = User(name="user 1", login="user1", email="user1@users.com", password="pass") user2 = User(name="user 2", login="user2", email="user2@users.com", password="pass") note1 = Note(content="This is the content of the note 1") note2 = Note(content="This is the content of the note 2") related_ticket1 = Ticket(project=proj1) DBSession.add(related_ticket1) DBSession.commit() related_ticket2 = Ticket(project=proj1) DBSession.add(related_ticket2) DBSession.commit() # create Tickets test_ticket = Ticket( project=proj1, links=[simple_entity, entity], notes=[note1, note2], reported_by=user1, related_tickets=[related_ticket1, related_ticket2], ) test_ticket.reassign(user1, user2) test_ticket.priority = "MAJOR" DBSession.add(test_ticket) DBSession.commit() comments = test_ticket.comments created_by = test_ticket.created_by date_created = test_ticket.date_created date_updated = test_ticket.date_updated description = test_ticket.description logs = test_ticket.logs links = test_ticket.links name = test_ticket.name notes = test_ticket.notes number = test_ticket.number owner = test_ticket.owner priority = test_ticket.priority project = test_ticket.project related_tickets = test_ticket.related_tickets reported_by = test_ticket.reported_by resolution = test_ticket.resolution status = test_ticket.status type_ = test_ticket.type updated_by = test_ticket.updated_by del test_ticket # now query it back test_ticket_db = Ticket.query.filter_by(name=name).first() assert comments == test_ticket_db.comments assert created_by == test_ticket_db.created_by assert date_created == test_ticket_db.date_created assert date_updated == test_ticket_db.date_updated assert description == test_ticket_db.description assert logs != [] assert logs == test_ticket_db.logs assert links == test_ticket_db.links assert name == test_ticket_db.name assert notes == test_ticket_db.notes assert number == test_ticket_db.number assert owner == test_ticket_db.owner assert priority == test_ticket_db.priority assert project == test_ticket_db.project assert related_tickets == test_ticket_db.related_tickets assert reported_by == test_ticket_db.reported_by assert resolution == test_ticket_db.resolution assert status == test_ticket_db.status assert type_ == test_ticket_db.type assert updated_by == test_ticket_db.updated_by # delete tests # Deleting a Ticket should also delete all the logs related to the # ticket assert sorted(test_ticket_db.logs, key=lambda x: x.name) == sorted( logs, key=lambda x: x.name ) DBSession.delete(test_ticket_db) DBSession.commit() assert TicketLog.query.all() == [] def test_persistence_of_user(setup_postgresql_db): """Persistence of User.""" # create a new user save and retrieve it back # create a Department for the user dep_kwargs = { "name": "Test Department", "description": "This department has been created for testing \ purposes", } new_department = Department(**dep_kwargs) # create the user user_kwargs = { "name": "Test", "login": "testuser", "email": "testuser@test.com", "password": "12345", "description": "This user has been created for testing purposes", "departments": [new_department], "efficiency": 2.5, } user1 = User(**user_kwargs) DBSession.add_all([user1, new_department]) DBSession.commit() vacation1 = Vacation( user=user1, start=datetime.datetime.now(pytz.utc), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(1), ) vacation2 = Vacation( user=user1, start=datetime.datetime.now(pytz.utc) + datetime.timedelta(2), end=datetime.datetime.now(pytz.utc) + datetime.timedelta(3), ) user1.vacations.append(vacation1) user1.vacations.append(vacation2) DBSession.add(user1) DBSession.commit() # create a test project repo1 = Repository(name="Test Repo", code="TR") project1 = Project( name="Test Project", code="TP", repository=repo1, ) task1 = Task( name="Test Task 1", project=project1, resources=[user1], responsible=[user1] ) dt = datetime.datetime td = datetime.timedelta time_log1 = TimeLog( task=task1, resource=user1, start=dt.now(pytz.utc), end=dt.now(pytz.utc) + td(1), ) DBSession.add(time_log1) DBSession.add(task1) DBSession.commit() # store attributes created_by = user1.created_by date_created = user1.date_created date_updated = user1.date_updated departments = [dep for dep in user1.departments] description = user1.description efficiency = user1.efficiency email = user1.email authentication_logs = user1.authentication_logs login = user1.login name = user1.name nice_name = user1.nice_name notes = user1.notes password = user1.password groups = user1.groups projects = [project for project in user1.projects] tags = user1.tags tasks = user1.tasks watching = user1.watching updated_by = user1.updated_by vacations = [vacation1, vacation2] # delete new_user del user1 user1_db = User.query.filter(User.name == user_kwargs["name"]).first() assert isinstance(user1_db, User) # the user itself # assert new_user in new_user_DB assert user1_db.created_by == created_by assert user1_db.date_created == date_created assert user1_db.date_updated == date_updated assert user1_db.departments == departments assert user1_db.description == description assert user1_db.efficiency == efficiency assert user1_db.email == email assert user1_db.authentication_logs == authentication_logs assert user1_db.login == login assert user1_db.name == name assert user1_db.nice_name == nice_name assert user1_db.notes == notes assert user1_db.password == password assert user1_db.groups == groups assert user1_db.projects == projects assert user1_db.tags == tags assert user1_db.tasks == tasks assert sorted(vacations, key=lambda x: x.name) == sorted( user1_db.vacations, key=lambda x: x.name ) assert user1_db.watching == watching assert user1_db.updated_by == updated_by # as the member of a department department_db = Department.query.filter( Department.name == dep_kwargs["name"] ).first() assert user1_db == department_db.users[0] # delete tests assert sorted([vacation1, vacation2], key=lambda x: x.name) == sorted( Vacation.query.all(), key=lambda x: x.name ) # deleting a user should also delete its vacations DBSession.delete(user1_db) DBSession.commit() assert Vacation.query.all() == [] # deleting a user should also delete the time logs assert TimeLog.query.all() == [] def test_persistence_of_authentication_log(setup_postgresql_db): """Persistence of AuthenticationLog.""" user1 = User( name="Test User 1", login="tuser1", email="tuser1@users.com", password="sosecret", ) DBSession.add(user1) DBSession.commit() al1 = AuthenticationLog( user=user1, action=LOGIN, date=datetime.datetime.now(pytz.utc) ) al2 = AuthenticationLog( user=user1, action=LOGOUT, date=datetime.datetime.now(pytz.utc) + datetime.timedelta(minutes=10), ) DBSession.add_all([al1, al2]) DBSession.commit() al1_id = al1.id action = al1.action date = al1.date del al1 al1_from_db = DBSession.get(AuthenticationLog, al1_id) assert al1_from_db.user == user1 assert al1_from_db.date == date assert al1_from_db.action == action # check if users data is also updated assert sorted(user1.authentication_logs) == sorted([al1_from_db, al2]) # delete tests DBSession.delete(al1_from_db) DBSession.commit() # check the user still exists user1_from_db = DBSession.get(User, user1.id) assert user1_from_db is not None # check if the other log is still there al2_from_db = DBSession.get(AuthenticationLog, al2.id) assert al2_from_db is not None # delete the other AuthenticationLog DBSession.delete(al2_from_db) DBSession.commit() # check if the user is still there user1_from_db = DBSession.get(User, user1.id) assert user1_from_db is not None def test_persistence_of_vacation(setup_postgresql_db): """Persistence of Vacation instances.""" # create a User new_user = User( name="Test User", login="testuser", email="test@user.com", password="secret" ) # personal vacation type personal_vacation = Type( name="Personal", code="PERS", target_entity_type="Vacation" ) start = datetime.datetime(2013, 6, 7, 15, 0, tzinfo=pytz.utc) end = datetime.datetime(2013, 6, 21, 0, 0, tzinfo=pytz.utc) vacation = Vacation(user=new_user, type=personal_vacation, start=start, end=end) DBSession.add(vacation) DBSession.commit() name = vacation.name del vacation # get it back vacation_db = Vacation.query.filter_by(name=name).first() assert isinstance(vacation_db, Vacation) assert vacation_db.user == new_user assert vacation_db.start == start assert vacation_db.end == end assert vacation_db.type == personal_vacation def test_persistence_of_version(setup_postgresql_db): """Persistence of Version instances.""" # create a FilenameTemplate for Tasks test_filename_template = FilenameTemplate( name="Task Filename Template", target_entity_type="Task", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", filename="{{version.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) DBSession.add(test_filename_template) DBSession.commit() # create a Structure test_structure = Structure( name="Project Structure", templates=[test_filename_template], ) DBSession.add(test_structure) DBSession.commit() # create a project test_project = Project( name="Test Project", code="tp", repository=Repository( name="Film Projects", code="FP", windows_path="M:/", linux_path="/mnt/M/", macos_path="/Users/Volumes/M/", ), structure=test_structure, ) DBSession.add(test_project) DBSession.commit() # create a task test_task = Task( name="Modeling", project=test_project, responsible=[User(name="user1", login="user1", email="u@u", password="12")], ) DBSession.add(test_task) DBSession.commit() # create a new version test_version = Version( name="version for task modeling", task=test_task, revision_number=12, full_path="M:/Shows/Proj1/Seq1/Shots/SH001/Lighting" "/Proj1_Seq1_Sh001_MAIN_Lighting_v001.ma", outputs=[ File( name="Renders", full_path="M:/Shows/Proj1/Seq1/Shots/SH001/Lighting/" "Output/test1.###.jpg", ), ], ) # now save it to the database DBSession.add(test_version) DBSession.commit() assert test_version.revision_number == 12 assert test_version.version_number == 1 # create a new version test_version_2 = Version( name="version for task modeling", task=test_task, revision_number=12, full_path="M:/Shows/Proj1/Seq1/Shots/SH001/Lighting" "/Proj1_Seq1_Sh001_MAIN_Lighting_v002.ma", ) DBSession.add(test_version_2) DBSession.commit() assert test_version_2.revision_number == 12 assert test_version_2.version_number == 2 created_by = test_version.created_by date_created = test_version.date_created date_updated = test_version.date_updated name = test_version.name nice_name = test_version.nice_name notes = test_version.notes files = test_version.files is_published = test_version.is_published full_path = test_version.generate_path() tags = test_version.tags type_ = test_version.type updated_by = test_version.updated_by revision_number = test_version.revision_number assert revision_number == 12 version_number = test_version.version_number task = test_version.task del test_version # get it back from the db test_version_db = Version.query.filter_by(version_number=1).first() assert isinstance(test_version_db, Version) assert test_version_db.created_by == created_by assert test_version_db.date_created == date_created assert test_version_db.date_updated == date_updated assert test_version_db.name == name assert test_version_db.nice_name == nice_name assert test_version_db.notes == notes assert test_version_db.files == files assert test_version_db.is_published == is_published assert test_version_db.generate_path() == full_path assert test_version_db.tags == tags assert test_version_db.type == type_ assert test_version_db.updated_by == updated_by assert test_version_db.version_number == version_number assert test_version_db.task == task assert test_version_db.revision_number == revision_number # try to delete version and expect the task, user and other versions # to be intact DBSession.delete(test_version_db) DBSession.commit() # version_2 test_version_3 = Version( name="version for task modeling", task=test_task, full_path="M:/Shows/Proj1/Seq1/Shots/SH001/Lighting" "/Proj1_Seq1_Sh001_MAIN_Lighting_v003.ma", ) DBSession.add(test_version_3) DBSession.commit() # now delete test_version_2 DBSession.delete(test_version_2) DBSession.commit() # and check if test_version_3 is still present in the database test_version_3_db = ( Version.query.filter(Version.name == test_version_3.name) .filter(Version.task == test_version_3.task) .filter(Version.version_number == test_version_3.version_number) .first() ) assert test_version_3_db is not None assert test_version_3_db.task == test_version_3.task assert test_version_3_db.version_number == test_version_3.version_number # create a new version append it to version_3.children and then delete # version_3 test_version_4 = Version(name="version for task modeling", task=test_task) test_version_3.children.append(test_version_4) DBSession.save(test_version_4) assert test_version_3.children == [test_version_4] assert test_version_4.parent == test_version_3 # and check if test_version_4 is still present in the database test_version_4_db = ( Version.query.filter(Version.name == test_version_4.name) .filter(Version.task == test_version_4.task) .filter(Version.version_number == test_version_4.version_number) .first() ) assert test_version_4_db is not None assert test_version_4_db.task == test_version_4.task assert test_version_4_db.version_number == test_version_4.version_number assert test_version_4_db.parent == test_version_3 # now delete test_version_3 DBSession.delete(test_version_3) DBSession.commit() # and check if test_version_4 is still present in the database test_version_4_db = ( Version.query.filter(Version.name == test_version_4.name) .filter(Version.task == test_version_4.task) .filter(Version.version_number == test_version_4.version_number) .first() ) assert test_version_4_db is not None assert test_version_4_db.task == test_version_4.task assert test_version_4_db.version_number == test_version_4.version_number assert test_version_4_db.parent is None # create a new version and assign it as a child of version_5 test_version_5 = Version(task=test_task) DBSession.save(test_version_5) test_version_4.children = [test_version_5] DBSession.commit() # now delete test_version_5 test_version_5_id = test_version_5.id DBSession.delete(test_version_5) DBSession.commit() # query it from db assert DBSession.get(Version, test_version_5_id) is None assert test_version_4.children == [] def test_persistence_of_working_hours(setup_postgresql_db): """Persistence of WorkingHours instances.""" wh = WorkingHours( name="Default Working Hours", working_hours={ "mon": [[9, 12], [13, 18]], "tue": [[9, 12], [13, 18]], "wed": [[9, 12], [13, 18]], "thu": [[9, 12], [13, 18]], "fri": [[9, 12], [13, 18]], "sat": [], "sun": [], }, daily_working_hours=8, ) DBSession.add(wh) DBSession.commit() name = wh.name hours = wh.working_hours daily_working_hours = 8 del wh wh_db = WorkingHours.query.filter_by(name=name).first() assert wh_db.name == name assert wh_db.working_hours == hours assert wh_db.daily_working_hours == daily_working_hours def test_timezones_with_sqlite3(setup_sqlite3): """Timezones is correctly handled in SQLite3.""" stalker.db.setup.setup() stalker.db.setup.init() # check if we're really using SQLite3 assert str(DBSession.connection().engine.url) == "sqlite://" # create a simple entity test_se_1 = SimpleEntity(name="Test Entry 1") # check if it has UTC as timezone assert test_se_1.date_created.tzinfo == pytz.utc # commit to database DBSession.save(test_se_1) # now delete the local copy and retrieve it back del test_se_1 test_se_1_db = SimpleEntity.query.filter_by(name="Test Entry 1").first() # now check if the test_se_1_db has the local time zone in its # date_created field local_tz = tzlocal.get_localzone() now = datetime.datetime.now(local_tz) assert test_se_1_db.date_created.tzinfo == now.tzinfo ================================================ FILE: tests/db/test_dbsession.py ================================================ from stalker import User from stalker.db.session import DBSession, ExtendedScopedSession def test_dbsession_save_method_is_correctly_created(setup_postgresql_db): """DBSession is correctly created from ExtendedScopedSession class.""" assert isinstance(DBSession, ExtendedScopedSession) def test_dbsession_save_method_is_working_as_expected_for_single_entity( setup_postgresql_db, ): """DBSession.save() method is working as expected for single entity.""" test_user = User( name="Test User", login="tuser", email="tuser@gmail.com", password="12345" ) DBSession.save(test_user) del test_user test_user_db = User.query.filter(User.name == "Test User").first() assert test_user_db is not None def test_dbsession_save_method_is_working_as_expected_for_multiple_entity( setup_postgresql_db, ): """DBSession.save() method is working as expected for single entity.""" test_user1 = User( name="Test User 1", login="tuser1", email="tuser1@gmail.com", password="12345", ) test_user2 = User( name="Test User 2", login="tuser2", email="tuser2@gmail.com", password="12345", ) DBSession.save([test_user1, test_user2]) del test_user1 del test_user2 test_user1_db = User.query.filter(User.name == "Test User 1").first() test_user2_db = User.query.filter(User.name == "Test User 2").first() assert test_user1_db is not None assert test_user2_db is not None def test_dbsession_save_method_is_working_as_expected_for_no_entry(setup_postgresql_db): """DBSession.save() method is working as expected with no parameters.""" test_user = User( name="Test User", login="tuser", email="tuser@gmail.com", password="12345" ) DBSession.add(test_user) DBSession.save() del test_user test_user_db = User.query.filter(User.name == "Test User").first() assert test_user_db is not None ================================================ FILE: tests/db/test_types.py ================================================ # -*- coding: utf-8 -*- import pytest from sqlalchemy import Column, ForeignKey, Integer from stalker.db.setup import init, setup from stalker.db.session import DBSession from stalker.db.types import GenericJSON from stalker.models.entity import Entity @pytest.fixture(scope="function") def setup_db(setup_sqlite3): """setup test db.""" class MyEntityClass(Entity): __tablename__ = "MyEntityClasses" __table_args__ = { "extend_existing": True, } __mapper_args__ = { "polymorphic_identity": "MyEntityClass", } my_entity_id = Column( "id", Integer, ForeignKey("Entities.id"), primary_key=True ) data = Column(GenericJSON) # setup and initialize db setup() init() yield MyEntityClass def test_json_encoded_dict_with_generic_data_stored(setup_db): """JSONEncodedDict with generic data.""" MyEntityClass = setup_db my_entity = MyEntityClass() my_entity.data = { "some key": "and this is the value", } DBSession.add(my_entity) DBSession.commit() def test_json_encoded_dict_with_generic_data_none_data_stored(setup_db): """JSONEncodedDict with generic data.""" MyEntityClass = setup_db my_entity = MyEntityClass() my_entity.data = None DBSession.add(my_entity) DBSession.commit() def test_json_encoded_dict_with_generic_data_retrieved(setup_db): """JSONEncodedDict with generic data.""" MyEntityClass = setup_db test_data = { "some key": "and this is the value", } my_entity = MyEntityClass() my_entity.data = test_data DBSession.add(my_entity) DBSession.commit() del my_entity retrieved_data = MyEntityClass.query.first() assert retrieved_data.data == test_data ================================================ FILE: tests/mixins/__init__.py ================================================ ================================================ FILE: tests/mixins/test_acl_mixin.py ================================================ # -*- coding: utf-8 -*- """ACLMixin related tests.""" import pytest from sqlalchemy import Column, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import ACLMixin, Permission from stalker.db.declarative import Base class TestClassForACL(Base, ACLMixin): """A class for testing ACLMixing.""" __tablename__ = "TestClassForACLs" id: Mapped[int] = mapped_column(primary_key=True) def __init__(self): super(TestClassForACL, self).__init__() self.name = None @pytest.fixture(scope="function") def acl_mixin_test_setup(): """stalker.models.mixins.ACLMixin class. Returns: dict: Test data. """ data = dict() # create permissions data["test_perm1"] = Permission( access="Allow", action="Create", class_name="Something" ) data["test_instance"] = TestClassForACL() data["test_instance"].name = "Test" data["test_instance"].permissions.append(data["test_perm1"]) return data def test_permission_attribute_accept_permission_instances_only(acl_mixin_test_setup): """permissions attribute accepts only Permission instances.""" data = acl_mixin_test_setup with pytest.raises(TypeError) as cm: data["test_instance"].permissions = [234] assert str(cm.value) == ( "TestClassForACL.permissions should be all instances of " "stalker.models.auth.Permission, not int: '234'" ) def test_permission_attribute_is_working_as_expected(acl_mixin_test_setup): """permissions attribute is working as expected.""" data = acl_mixin_test_setup assert data["test_instance"].permissions == [data["test_perm1"]] def test_acl_property_returns_a_list(acl_mixin_test_setup): """__acl__ property returns a list.""" data = acl_mixin_test_setup assert isinstance(data["test_instance"].__acl__, list) def test_acl_property_returns_a_proper_ACL_list(acl_mixin_test_setup): """__acl__ property is a list of ACLs according to the given permissions.""" data = acl_mixin_test_setup assert data["test_instance"].__acl__ == [ ("Allow", "TestClassForACL:Test", "Create_Something") ] ================================================ FILE: tests/mixins/test_amount_mixin.py ================================================ # -*- coding: utf-8 -*- """AmountMixin related tests.""" import pytest from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import AmountMixin, SimpleEntity class AmountMixinFooMixedInClass(SimpleEntity, AmountMixin): """A class which derives from another which has and __init__ already.""" __tablename__ = "AmountMixinFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "AmountMixinFooMixedInClass"} amountMixinFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) __id_column__ = "amountMixinFooMixedInClass_id" def __init__(self, **kwargs): super(AmountMixinFooMixedInClass, self).__init__(**kwargs) AmountMixin.__init__(self, **kwargs) def test_mixed_in_class_initialization(): """init() is working as expected.""" a = AmountMixinFooMixedInClass(amount=1500) assert isinstance(a, AmountMixinFooMixedInClass) assert a.amount == 1500 def test_amount_argument_is_skipped(): """amount attribute will be 0 if the amount argument is skipped.""" entry = AmountMixinFooMixedInClass() assert entry.amount == 0.0 def test_amount_argument_is_set_to_none(): """amount attribute will be 0 if the amount argument is None.""" entry = AmountMixinFooMixedInClass(amount=None) assert entry.amount == 0.0 def test_amount_attribute_is_set_to_none(): """amount attribute will be set to 0 if it is set to None.""" entry = AmountMixinFooMixedInClass(amount=10.0) assert entry.amount == 10.0 entry.amount = None assert entry.amount == 0.0 def test_amount_argument_is_not_a_number(): """TypeError will be raised if the amount argument is not a number.""" with pytest.raises(TypeError) as cm: AmountMixinFooMixedInClass(amount="some string") assert str(cm.value) == ( "AmountMixinFooMixedInClass.amount should be a number, not str: 'some string'" ) def test_amount_attribute_is_not_a_number(): """TypeError will be raised if amount attribute is not a number.""" entry = AmountMixinFooMixedInClass(amount=10) with pytest.raises(TypeError) as cm: entry.amount = "some string" assert str(cm.value) == ( "AmountMixinFooMixedInClass.amount should be a number, not str: 'some string'" ) def test_amount_argument_is_working_as_expected(): """amount argument value is correctly passed to the amount attribute.""" entry = AmountMixinFooMixedInClass(amount=10) assert entry.amount == 10.0 def test_amount_attribute_is_working_as_expected(): """amount attribute is working as expected.""" entry = AmountMixinFooMixedInClass(amount=10) test_value = 5.0 assert entry.amount != test_value entry.amount = test_value assert entry.amount == test_value ================================================ FILE: tests/mixins/test_code_mixin.py ================================================ # -*- coding: utf-8 -*- """CodeMixin related tests.""" import pytest from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import CodeMixin, SimpleEntity class CodeMixFooMixedInClass(SimpleEntity, CodeMixin): """A class which derives from another which has and __init__ already.""" __tablename__ = "CodeMixFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "CodeMixFooMixedInClass"} codeMixFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(CodeMixFooMixedInClass, self).__init__(**kwargs) CodeMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def code_mixin_tester_setup(): """Set up the test. Returns: dict: Test data. """ data = { "kwargs": { "name": "Test Code Mixin", "code": "this_is_a_test_code", "description": "This is a simple entity object for testing " "DateRangeMixin", }, } data["test_foo_obj"] = CodeMixFooMixedInClass(**data["kwargs"]) return data def test_code_argument_is_skipped(code_mixin_tester_setup): """TypeError is raised if the code argument is skipped.""" data = code_mixin_tester_setup data["kwargs"].pop("code") with pytest.raises(TypeError) as cm: CodeMixFooMixedInClass(**data["kwargs"]) assert str(cm.value) == "CodeMixFooMixedInClass.code cannot be None" def test_code_argument_is_none(code_mixin_tester_setup): """TypeError is raised if the code argument is None.""" data = code_mixin_tester_setup data["kwargs"]["code"] = None with pytest.raises(TypeError) as cm: CodeMixFooMixedInClass(**data["kwargs"]) assert str(cm.value) == "CodeMixFooMixedInClass.code cannot be None" def test_code_attribute_is_none(code_mixin_tester_setup): """TypeError is raised if teh code attribute is set to None.""" data = code_mixin_tester_setup with pytest.raises(TypeError) as cm: data["test_foo_obj"].code = None assert str(cm.value) == "CodeMixFooMixedInClass.code cannot be None" def test_code_argument_is_not_a_string(code_mixin_tester_setup): """TypeError is raised if the code argument is not a string.""" data = code_mixin_tester_setup data["kwargs"]["code"] = 123 with pytest.raises(TypeError) as cm: CodeMixFooMixedInClass(**data["kwargs"]) assert str(cm.value) == ( "CodeMixFooMixedInClass.code should be a string, not int: '123'" ) def test_code_attribute_is_not_a_string(code_mixin_tester_setup): """TypeError is raised if the code attribute is set to None.""" data = code_mixin_tester_setup with pytest.raises(TypeError) as cm: data["test_foo_obj"].code = 2342 assert str(cm.value) == ( "CodeMixFooMixedInClass.code should be a string, not int: '2342'" ) def test_code_argument_is_an_empty_string(code_mixin_tester_setup): """ValueError is raised if the code attribute is an empty string.""" data = code_mixin_tester_setup data["kwargs"]["code"] = "" with pytest.raises(ValueError) as cm: CodeMixFooMixedInClass(**data["kwargs"]) assert str(cm.value) == "CodeMixFooMixedInClass.code cannot be an empty string" def test_code_attribute_is_set_to_an_empty_string(code_mixin_tester_setup): """ValueError is raised if the code attribute is set to an empty string.""" data = code_mixin_tester_setup with pytest.raises(ValueError) as cm: data["test_foo_obj"].code = "" assert str(cm.value) == "CodeMixFooMixedInClass.code cannot be an empty string" def test_code_argument_is_working_as_expected(code_mixin_tester_setup): """code argument value is passed to the code attribute.""" data = code_mixin_tester_setup assert data["test_foo_obj"].code == data["kwargs"]["code"] def test_code_attribute_is_working_as_expected(code_mixin_tester_setup): """code attribute is working as expected.""" data = code_mixin_tester_setup test_value = "new code" data["test_foo_obj"].code = test_value assert data["test_foo_obj"].code == test_value ================================================ FILE: tests/mixins/test_create_secondary_table.py ================================================ # -*- coding: utf-8 -*- import pytest from sqlalchemy import Column, ForeignKey, Integer, Table from stalker import SimpleEntity from stalker.db.declarative import Base from stalker.models.mixins import create_secondary_table @pytest.fixture(scope="function") def setup_test_class(): """Create a test class.""" class TestEntity(SimpleEntity): """Test class.""" __tablename__ = "TestEntities" __table_args__ = {"extend_existing": True} __mapper_args__ = {"polymorphic_identity": "TestEntity"} test_entity_id = Column( "id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ) yield TestEntity def test_primary_cls_name_is_none(setup_test_class): """primary_cls_name is None raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( None, # "TestEntity", "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_name should be a str containing the primary class name, " "not NoneType: 'None'" ) def test_primary_cls_name_is_not_a_string(setup_test_class): """primary_cls_name is not str raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( 1234, # "TestEntity", "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_name should be a str containing the primary class name, " "not int: '1234'" ) def test_primary_cls_name_is_empty_string(setup_test_class): """primary_cls_name is an empty str raises ValueError.""" _ = setup_test_class with pytest.raises(ValueError) as cm: create_secondary_table( "", # "TestEntity", "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_name should be a str containing the primary class name, not: ''" ) def test_secondary_cls_name_is_none(setup_test_class): """secondary_cls_name is None raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", None, # "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_name should be a str containing the secondary class name, " "not NoneType: 'None'" ) def test_secondary_cls_name_is_not_a_string(setup_test_class): """secondary_cls_name is not str raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", 1234, # "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_name should be a str containing the secondary class name, " "not int: '1234'" ) def test_secondary_cls_name_is_empty_string(setup_test_class): """secondary_cls_name is an empty str raises ValueError.""" _ = setup_test_class with pytest.raises(ValueError) as cm: create_secondary_table( "TestEntity", "", # "File", "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_name should be a str containing the secondary class name, " "not: ''" ) def test_secondary_cls_name_is_converted_to_plural(setup_test_class): """secondary_cls_name is converted to plural.""" return_value = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", None, # "TestEntity_References" ) assert return_value.name == "TestEntity_Files" def test_primary_cls_table_name_is_none(setup_test_class): """primary_cls_table_name is None raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", "File", None, # "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_table_name should be a str containing the primary class table " "name, not NoneType: 'None'" ) def test_primary_cls_table_name_is_not_a_string(setup_test_class): """primary_cls_table_name is not str raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", "File", 1234, # "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_table_name should be a str containing the primary class table " "name, not int: '1234'" ) def test_primary_cls_table_name_is_empty_string(setup_test_class): """primary_cls_table_name is an empty str raises ValueError.""" _ = setup_test_class with pytest.raises(ValueError) as cm: create_secondary_table( "TestEntity", "File", "", # "TestEntities", "Files", "TestEntity_References", ) assert str(cm.value) == ( "primary_cls_table_name should be a str containing the primary class table " "name, not: ''" ) def test_secondary_cls_table_name_is_none(setup_test_class): """secondary_cls_table_name is None raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", "File", "TestEntities", None, # "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_table_name should be a str containing the secondary class table " "name, not NoneType: 'None'" ) def test_secondary_cls_table_name_is_not_a_string(setup_test_class): """secondary_cls_table_name is not str raises TypeError.""" _ = setup_test_class with pytest.raises(TypeError) as cm: create_secondary_table( "TestEntity", "File", "TestEntities", 1234, # "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_table_name should be a str containing the secondary class table " "name, not int: '1234'" ) def test_secondary_cls_table_name_is_empty_string(setup_test_class): """secondary_cls_table_name is an empty str raises ValueError.""" _ = setup_test_class with pytest.raises(ValueError) as cm: create_secondary_table( "TestEntity", "File", "TestEntities", "", # "Files", "TestEntity_References", ) assert str(cm.value) == ( "secondary_cls_table_name should be a str containing the secondary class table " "name, not: ''" ) def test_secondary_table_name_can_be_none(setup_test_class): """secondary_table_name can be None.""" return_value = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", None, # "TestEntity_References" ) assert return_value.name == "TestEntity_Files" def test_secondary_table_name_is_not_a_str(setup_test_class): """secondary_table_name is not str raises TypeError.""" with pytest.raises(TypeError) as cm: _ = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", 1234, # "TestEntity_References" ) assert str(cm.value) == ( "secondary_table_name should be a str containing the secondary table name, " "or it can be None or an empty string to let Stalker to auto generate one, " "not int: '1234'" ) def test_secondary_table_name_is_an_empty_str(setup_test_class): """secondary_table_name is an empty string generates new name from class names.""" return_value = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", "", # "TestEntity_References" ) assert return_value.name == "TestEntity_Files" def test_secondary_table_name_already_exists_in_base_metadata(setup_test_class): """secondary_table_name already exists will use that table.""" assert "TestEntity_References" not in Base.metadata return_value_1 = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", "TestEntity_References" ) assert "TestEntity_References" in Base.metadata # should not generate any errors return_value_2 = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", "TestEntity_References" ) # and return the same table assert return_value_2.name == "TestEntity_References" assert return_value_1 == return_value_2 def test_returns_a_table(setup_test_class): """create_secondary_table returns a table.""" return_value = create_secondary_table( "TestEntity", "File", "TestEntities", "Files", "TestEntity_References" ) assert isinstance(return_value, Table) ================================================ FILE: tests/mixins/test_dag_mixin.py ================================================ # -*- coding: utf-8 -*- """DAGMixin related tests.""" import copy import sys import pytest from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import log from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError from stalker.models.entity import SimpleEntity from stalker.models.mixins import DAGMixin log.get_logger("stalker.models.studio") class DAGMixinFooMixedInClass(SimpleEntity, DAGMixin): """A class which derives from another which has and __init__ already.""" __tablename__ = "DAGMixinFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "DAGMixinFooMixedInClass"} dagMixinFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) __id_column__ = "dagMixinFooMixedInClass_id" def __init__(self, **kwargs): super(DAGMixinFooMixedInClass, self).__init__(**kwargs) DAGMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def dag_mixin_test_case(): """Set up the DAGMixin class tests. Returns: dict: Test data. """ data = {"kwargs": {"name": "Test DAG Mixin"}} return data @pytest.fixture(scope="function") def setup_dag_db(setup_postgresql_db): """Set up the test for DAGMixin. Returns: dict: Test data. """ data = setup_postgresql_db data["kwargs"] = {"name": "Test DAG Mixin"} return data def test_parent_argument_is_skipped(dag_mixin_test_case): """parent attribute is None if the parent argument is skipped.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d = DAGMixinFooMixedInClass(**kwargs) assert d.parent is None def test_parent_argument_is_none(dag_mixin_test_case): """parent attribute is None if the parent argument is None.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None d = DAGMixinFooMixedInClass(**kwargs) assert d.parent is None def test_parent_argument_is_not_a_correct_class_instance(dag_mixin_test_case): """TypeError is raised if the parent argument is not correct type.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = "not a correct type" with pytest.raises(TypeError) as cm: _ = DAGMixinFooMixedInClass(**kwargs) assert str(cm.value) == ( "DAGMixinFooMixedInClass.parent should be an instance of " "DAGMixinFooMixedInClass class or derivative, not str: 'not a correct type'" ) def test_parent_attribute_is_not_a_correct_class_instance(dag_mixin_test_case): """TypeError is raised if the parent attribute is set to a wrong class instance.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(TypeError) as cm: d.parent = "not a correct type" assert str(cm.value) == ( "DAGMixinFooMixedInClass.parent should be an instance of " "DAGMixinFooMixedInClass class or derivative, not str: 'not a correct type'" ) def test_parent_attribute_creates_a_cycle(dag_mixin_test_case): """CircularDependency is raised if a child is tried to be set as the parent.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = d1 d2 = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(CircularDependencyError) as cm: d1.parent = d2 assert ( str(cm.value) == " " "(DAGMixinFooMixedInClass) and " " " "(DAGMixinFooMixedInClass) are in a circular dependency in " 'their "children" attribute' ) def test_parent_argument_is_working_as_expected(dag_mixin_test_case): """parent argument is working as expected.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = d1 d2 = DAGMixinFooMixedInClass(**kwargs) assert d1 == d2.parent def test_parent_attribute_is_working_as_expected(dag_mixin_test_case): """parent attribute is working as expected.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) assert d2.parent != d1 d2.parent = d1 assert d2.parent == d1 def test_children_attribute_is_an_empty_list_by_default(dag_mixin_test_case): """children attribute is an empty list by default.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d = DAGMixinFooMixedInClass(**kwargs) assert d.children == [] def test_children_attribute_is_set_to_none(dag_mixin_test_case): """TypeError is raised if the children attribute is set to None.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(TypeError) as cm: d.children = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_children_attribute_accepts_correct_class_instances_only(dag_mixin_test_case): """children attribute accepts only correct class instances.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(TypeError) as cm: d.children = ["not", 1, "", "of", "correct", "instances"] assert str(cm.value) == ( "DAGMixinFooMixedInClass.children should only contain instances of " "DAGMixinFooMixedInClass (or derivative), not str: 'not'" ) def test_children_attribute_is_working_as_expected(dag_mixin_test_case): """children attribute is working as expected.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) kwargs["name"] = "Test DAG Mixin 1" d1 = DAGMixinFooMixedInClass(**kwargs) kwargs["name"] = "Test DAG Mixin 2" d2 = DAGMixinFooMixedInClass(**kwargs) kwargs["name"] = "Test DAG Mixin 3" d3 = DAGMixinFooMixedInClass(**kwargs) assert d1.children == [] d1.children.append(d2) assert d1.children == [d2] d1.children = [d3] assert d1.children == [d3] def test_is_leaf_attribute_is_read_only(dag_mixin_test_case): """is_leaf attribute is a read only attribute.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(AttributeError) as cm: d1.is_leaf = "this will not work" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_leaf'", }.get( sys.version_info.minor, "property 'is_leaf' of 'DAGMixinFooMixedInClass' object has no setter", ) assert str(cm.value) == error_message def test_is_leaf_attribute_is_working_as_expected(dag_mixin_test_case): """is_leaf attribute is True for an instance without a child and False for another one with at least one child.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] assert d1.is_leaf is False assert d2.is_leaf is True assert d3.is_leaf is True def test_is_root_attribute_is_read_only(dag_mixin_test_case): """is_root attribute is a read only attribute.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(AttributeError) as cm: d1.is_root = "this will not work" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_root'", }.get( sys.version_info.minor, "property 'is_root' of 'DAGMixinFooMixedInClass' object has no setter", ) assert str(cm.value) == error_message def test_is_root_attribute_is_working_as_expected(dag_mixin_test_case): """is_root is True for an instance without a parent and False otherwise.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] assert d1.is_root is True assert d2.is_root is False assert d3.is_root is False def test_is_container_attribute_is_read_only(dag_mixin_test_case): """is_container attribute is a read only attribute.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(AttributeError) as cm: d1.is_container = "this will not work" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_container'", }.get( sys.version_info.minor, "property 'is_container' of 'DAGMixinFooMixedInClass' object has no setter", ) assert str(cm.value) == error_message def test_is_container_attribute_working_as_expected(dag_mixin_test_case): """is_container is True if at least one child exist and False otherwise.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d4 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] d2.children = [d4] assert d1.is_container is True assert d2.is_container is True assert d3.is_container is False assert d4.is_container is False def test_parents_property_is_read_only(dag_mixin_test_case): """parents property is read-only.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) with pytest.raises(AttributeError) as cm: d1.parents = "this will not work" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'parents'", }.get( sys.version_info.minor, "property 'parents' of 'DAGMixinFooMixedInClass' object has no setter", ) assert str(cm.value) == error_message def test_parents_property_is_working_as_expected(dag_mixin_test_case): """parents property is read-only.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d4 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] d2.children = [d4] assert d1.parents == [] assert d2.parents == [d1] assert d3.parents == [d1] assert d4.parents == [d1, d2] def test_walk_hierarchy_is_working_as_expected(dag_mixin_test_case): """walk_hierarchy method is working as expected.""" data = dag_mixin_test_case kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d4 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] d2.children = [d4] entities_walked = [] for e in d1.walk_hierarchy(): entities_walked.append(e) assert entities_walked == [d1, d2, d4, d3] entities_walked = [] for e in d1.walk_hierarchy(method=1): entities_walked.append(e) assert entities_walked == [d1, d2, d3, d4] entities_walked = [] for e in d2.walk_hierarchy(): entities_walked.append(e) assert entities_walked == [d2, d4] entities_walked = [] for e in d3.walk_hierarchy(): entities_walked.append(e) assert entities_walked == [d3] entities_walked = [] for e in d4.walk_hierarchy(): entities_walked.append(e) assert entities_walked == [d4] def test_committing_data(setup_dag_db): """Committing and retrieving data back.""" data = setup_dag_db kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d4 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] d2.children = [d4] DBSession.add_all([d1, d2, d3, d4]) DBSession.commit() del d1, d2, d3, d4 all_data = DAGMixinFooMixedInClass.query.all() assert len(all_data) == 4 assert isinstance(all_data[0], DAGMixinFooMixedInClass) assert isinstance(all_data[1], DAGMixinFooMixedInClass) assert isinstance(all_data[2], DAGMixinFooMixedInClass) assert isinstance(all_data[3], DAGMixinFooMixedInClass) def test_deleting_data(setup_dag_db): """Deleting data.""" data = setup_dag_db kwargs = copy.copy(data["kwargs"]) d1 = DAGMixinFooMixedInClass(**kwargs) d2 = DAGMixinFooMixedInClass(**kwargs) d3 = DAGMixinFooMixedInClass(**kwargs) d4 = DAGMixinFooMixedInClass(**kwargs) d1.children = [d2, d3] d2.children = [d4] DBSession.add_all([d1, d2, d3, d4]) DBSession.commit() DBSession.delete(d1) DBSession.commit() all_data = DAGMixinFooMixedInClass.query.all() assert len(all_data) == 0 ================================================ FILE: tests/mixins/test_date_range_mixin.py ================================================ # -*- coding: utf-8 -*- """DateRangeMixin class related tests.""" import datetime import logging import sys import pytest import pytz from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column import stalker from stalker import DateRangeMixin, SimpleEntity, defaults, log from stalker.db.session import DBSession from stalker.models.studio import Studio logger = log.get_logger(__name__) class DateRangeMixFooMixedInClass(SimpleEntity, DateRangeMixin): """A class which derives from another which has and __init__ already.""" __tablename__ = "DateRangeMixFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "DateRangeMixFooMixedInClass"} schedMixFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DateRangeMixFooMixedInClass, self).__init__(**kwargs) DateRangeMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def date_range_mixin_tester(): """Fixture for the DateRangeMixin tests. Returns: dict: Test data. """ # create mock objects stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) data = dict() data["start"] = datetime.datetime(2013, 3, 22, 15, 15, tzinfo=pytz.utc) data["end"] = data["start"] + datetime.timedelta(days=20) data["duration"] = datetime.timedelta(days=10) data["kwargs"] = { "name": "Test Daterange Mixin", "description": "This is a simple entity object for testing " "DateRangeMixin", "start": data["start"], "end": data["end"], "duration": data["duration"], } data["test_foo_obj"] = DateRangeMixFooMixedInClass(**data["kwargs"]) yield data stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) @pytest.mark.parametrize("test_value", [1, 1.2, "str", ["a", "date"]]) def test_start_argument_is_not_a_date_object(test_value, date_range_mixin_tester): """Default values are used if the start attribute is not a datetime object.""" data = date_range_mixin_tester data["kwargs"]["start"] = test_value new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.start == new_foo_obj.end - new_foo_obj.duration @pytest.mark.parametrize("test_value", [1, 1.2, "str", ["a", "date"]]) def test_start_attribute_is_not_a_date_object(test_value, date_range_mixin_tester): """Default values are used if start attribute is set not datetime object.""" data = date_range_mixin_tester end = data["test_foo_obj"].end duration = data["test_foo_obj"].duration data["test_foo_obj"].start = test_value assert ( data["test_foo_obj"].start == data["test_foo_obj"].end - data["test_foo_obj"].duration ) # check if we still have the same end assert data["test_foo_obj"].end == end # check if we still have the same duration assert data["test_foo_obj"].duration == duration def test_start_attribute_is_set_to_none_use_the_default_value(date_range_mixin_tester): """Setting the start attribute to None will update the start to today.""" data = date_range_mixin_tester data["test_foo_obj"].start = None assert data["test_foo_obj"].start == datetime.datetime( 2013, 3, 22, 15, 00, tzinfo=pytz.utc ) assert isinstance(data["test_foo_obj"].start, datetime.datetime) def test_start_attribute_works_as_expected(date_range_mixin_tester): """start attribute is working as expected.""" data = date_range_mixin_tester test_value = datetime.datetime(2011, 1, 1, tzinfo=pytz.utc) data["test_foo_obj"].start = test_value assert data["test_foo_obj"].start == test_value @pytest.mark.parametrize( "test_value", [1, 1.2, "str", ["a", "date"], datetime.timedelta(days=100)] ) def test_end_argument_is_not_a_date_object(test_value, date_range_mixin_tester): """Defaults used for the end attribute if due date not a datetime instance.""" data = date_range_mixin_tester data["kwargs"]["end"] = test_value new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.end == new_foo_obj.start + new_foo_obj.duration @pytest.mark.parametrize( "test_value", [1, 1.2, "str", ["a", "date"], datetime.timedelta(days=100)] ) def test_end_attribute_is_not_a_date_object(test_value, date_range_mixin_tester): """Defaults used for the end attribute if it is not a datetime object.""" data = date_range_mixin_tester data["test_foo_obj"].end = test_value assert ( data["test_foo_obj"].end == data["test_foo_obj"].start + data["test_foo_obj"].duration ) def test_end_argument_is_tried_to_set_to_a_time_before_start(date_range_mixin_tester): """end attribute is updated to start+duration if end arg is a date before start.""" data = date_range_mixin_tester data["kwargs"]["end"] = data["kwargs"]["start"] - datetime.timedelta(days=10) new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.end == new_foo_obj.start + new_foo_obj.duration def test_end_attribute_is_tried_to_set_to_a_time_before_start(date_range_mixin_tester): """end attribute is updated to start+duration if end is a date before start.""" data = date_range_mixin_tester new_end = data["test_foo_obj"].start - datetime.timedelta(days=10) data["test_foo_obj"].end = new_end assert ( data["test_foo_obj"].end == data["test_foo_obj"].start + data["test_foo_obj"].duration ) def test_end_attribute_is_shifted_if_start_passes_it(date_range_mixin_tester): """end attribute is shifted if the start attribute passes it.""" data = date_range_mixin_tester time_delta = data["test_foo_obj"].end - data["test_foo_obj"].start data["test_foo_obj"].start += 2 * time_delta assert data["test_foo_obj"].end - data["test_foo_obj"].start == time_delta @pytest.mark.parametrize("test_value", [None, 1, 1.2, "10", "10 days"]) def test_duration_argument_is_not_an_instance_of_timedelta_no_problem_if_start_and_end_is_present( test_value, date_range_mixin_tester ): """No error raised if duration arg is not a datetime instance if start and end args are present.""" data = date_range_mixin_tester data["kwargs"]["duration"] = test_value new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.duration == new_foo_obj.end - new_foo_obj.start @pytest.mark.parametrize("test_value", [1, 1.2, "10", "10 days"]) def test_duration_argument_is_not_an_instance_of_date_if_start_argument_is_missing( test_value, date_range_mixin_tester ): """defaults.timing_resolution is used if duration arg is not a datetime if start arg is also missing.""" data = date_range_mixin_tester data["kwargs"].pop("start") data["kwargs"]["duration"] = test_value new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.duration == defaults.timing_resolution @pytest.mark.parametrize("test_value", [1, 1.2, "10", "10 days"]) def test_duration_argument_is_not_an_instance_of_date_if_end_argument_is_missing( test_value, date_range_mixin_tester ): """defaults.timing_resolution is used if the duration arg is not a datetime and if end arg is also missing.""" data = date_range_mixin_tester defaults["timing_resolution"] = datetime.timedelta(hours=1) # some wrong values for the duration data["kwargs"].pop("end") data["kwargs"]["duration"] = test_value new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.duration == defaults.timing_resolution def test_duration_argument_is_smaller_than_timing_resolution(date_range_mixin_tester): """defaults.timing_resolution is used for duration if duration arg is smaller than defaults.timing_resolution""" data = date_range_mixin_tester data["kwargs"].pop("end") data["kwargs"]["duration"] = datetime.timedelta(minutes=1) obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert obj.start == datetime.datetime(2013, 3, 22, 15, 0, tzinfo=pytz.utc) assert obj.end == datetime.datetime(2013, 3, 22, 16, 0, tzinfo=pytz.utc) def test_duration_attribute_is_calculated_correctly(date_range_mixin_tester): """duration attribute is calculated correctly.""" data = date_range_mixin_tester new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_entity.start = datetime.datetime(2013, 3, 22, 15, 0, tzinfo=pytz.utc) new_foo_entity.end = new_foo_entity.start + datetime.timedelta(201) assert new_foo_entity.duration == datetime.timedelta(201) @pytest.mark.parametrize("test_value", [None, 1, 1.2, "10", "10 days"]) def test_duration_attribute_is_set_to_not_an_instance_of_timedelta( test_value, date_range_mixin_tester, ): """duration attribute reset to a calculated value if it is not a timedelta.""" data = date_range_mixin_tester # no problem if there are start and end arguments data["test_foo_obj"].duration = test_value # check the value assert ( data["test_foo_obj"].duration == data["test_foo_obj"].end - data["test_foo_obj"].start ) def test_duration_attribute_expands_then_end_shifts(date_range_mixin_tester): """duration attribute is expanded then the end attribute is shifted.""" data = date_range_mixin_tester _ = data["test_foo_obj"].end start = data["test_foo_obj"].start duration = data["test_foo_obj"].duration # change the duration new_duration = duration * 10 data["test_foo_obj"].duration = new_duration # duration expanded assert data["test_foo_obj"].duration == new_duration # start is not changed assert data["test_foo_obj"].start == start # end is postponed assert data["test_foo_obj"].end == start + new_duration def test_duration_attribute_contracts_then_end_shifts_back(date_range_mixin_tester): """duration attribute is contracted then the end attribute is shifted back.""" data = date_range_mixin_tester _ = data["test_foo_obj"].end start = data["test_foo_obj"].start duration = data["test_foo_obj"].duration # change the duration new_duration = duration / 2 data["test_foo_obj"].duration = new_duration # duration expanded assert data["test_foo_obj"].duration == new_duration # start is not changed assert data["test_foo_obj"].start == start # end is postponed assert data["test_foo_obj"].end == start + new_duration def test_duration_attribute_is_smaller_then_timing_resolution(date_range_mixin_tester): """defaults.timing_resolution is used for the duration if it is smaller than it.""" data = date_range_mixin_tester data["test_foo_obj"].duration = datetime.timedelta(minutes=10) assert data["test_foo_obj"].duration == defaults.timing_resolution def test_duration_is_a_negative_timedelta(date_range_mixin_tester): """duration is a negative timedelta will set the duration to 1 days.""" data = date_range_mixin_tester start = data["test_foo_obj"].start data["test_foo_obj"].duration = datetime.timedelta(-10) assert data["test_foo_obj"].duration == datetime.timedelta(1) assert data["test_foo_obj"].start == start def test_init_all_parameters_skipped(date_range_mixin_tester): """Attributes are initialized to the following values. start = datetime.datetime.now(pytz.utc) duration = stalker.config.Config.timing_resolution end = start + duration """ data = date_range_mixin_tester # self.fail("test is not implemented yet") data["kwargs"].pop("start") data["kwargs"].pop("end") data["kwargs"].pop("duration") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert isinstance(new_foo_entity.start, datetime.datetime) # cannot check for start, just don't want to struggle with the round # thing # assert \ # new_foo_entity.start == \ # datetime.datetime(2013, 3, 22, 15, 30, tzinfo=pytz.utc) assert new_foo_entity.duration == defaults.timing_resolution assert new_foo_entity.end == new_foo_entity.start + new_foo_entity.duration def test_init_only_start_argument_is_given(date_range_mixin_tester): """Attributes are initialized to the following values. duration = defaults.timing_resolution end = start + duration """ data = date_range_mixin_tester data["kwargs"].pop("end") data["kwargs"].pop("duration") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.duration == defaults.timing_resolution assert new_foo_entity.end == new_foo_entity.start + new_foo_entity.duration def test_init_start_and_end_argument_is_given(date_range_mixin_tester): """Attributes are initialized to the following values. duration = end - start """ data = date_range_mixin_tester data["kwargs"].pop("duration") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.duration == new_foo_entity.end - new_foo_entity.start def test_init_start_and_end_argument_is_given_but_duration_is_smaller_than_timing_resolution( date_range_mixin_tester, ): """Start is anchored and the end is updated if duration is smaller than timing_resolution. duration = end - start """ data = date_range_mixin_tester data["kwargs"].pop("duration") data["kwargs"]["start"] = datetime.datetime(2013, 12, 22, 23, 8, tzinfo=pytz.utc) data["kwargs"]["end"] = datetime.datetime(2013, 12, 22, 23, 15, tzinfo=pytz.utc) obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert obj.start == datetime.datetime(2013, 12, 22, 23, 0, tzinfo=pytz.utc) assert obj.end == datetime.datetime(2013, 12, 23, 0, 0, tzinfo=pytz.utc) def test_init_start_and_duration_argument_is_given(date_range_mixin_tester): """Attributes are initialized to: end = start + duration """ data = date_range_mixin_tester data["kwargs"].pop("end") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.end == new_foo_entity.start + new_foo_entity.duration def test_init_all_arguments_are_given(date_range_mixin_tester): """Attributes are initialized to: duration = end - start """ data = date_range_mixin_tester new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.duration == new_foo_entity.end - new_foo_entity.start def test_init_end_and_duration_argument_is_given(date_range_mixin_tester): """Attributes are initialized to: start = end - duration """ data = date_range_mixin_tester data["kwargs"].pop("start") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.start == new_foo_entity.end - new_foo_entity.duration def test_init_only_end_argument_is_given(date_range_mixin_tester): """Attributes are initialized to: duration = defaults.timing_resolution start = end - duration """ data = date_range_mixin_tester data["kwargs"].pop("duration") data["kwargs"].pop("start") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_entity.duration == defaults.timing_resolution assert new_foo_entity.start == new_foo_entity.end - new_foo_entity.duration def test_init_only_duration_argument_is_given(date_range_mixin_tester): """Attributes are initialized to: start = datetime.datetime.now(pytz.utc) end = start + duration """ data = date_range_mixin_tester data["kwargs"].pop("end") data["kwargs"].pop("start") new_foo_entity = DateRangeMixFooMixedInClass(**data["kwargs"]) # just check if it is an instance of datetime.datetime assert isinstance(new_foo_entity.start, datetime.datetime) # cannot check for start # assert new_foo_entity.start == \ # datetime.datetime(2013, 3, 22, 15, 30, tzinfo=pytz.utc assert new_foo_entity.end == new_foo_entity.start + new_foo_entity.duration def test_start_end_and_duration_values_are_rounded_to_the_default_timing_resolution( date_range_mixin_tester, ): """start and end dates are rounded to the default timing_resolution (no Studio).""" data = date_range_mixin_tester data["kwargs"]["start"] = datetime.datetime( 2013, 3, 22, 2, 38, 55, 531, tzinfo=pytz.utc ) data["kwargs"]["end"] = datetime.datetime( 2013, 3, 24, 16, 46, 32, 102, tzinfo=pytz.utc ) defaults["timing_resolution"] = datetime.timedelta(minutes=5) new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) # check the start expected_start = datetime.datetime(2013, 3, 22, 2, 40, tzinfo=pytz.utc) assert new_foo_obj.start == expected_start # check the end expected_end = datetime.datetime(2013, 3, 24, 16, 45, tzinfo=pytz.utc) assert new_foo_obj.end == expected_end # check the duration assert new_foo_obj.duration == expected_end - expected_start def test_computed_start_is_none_for_a_non_scheduled_class(date_range_mixin_tester): """computed_start attribute is None for a non-scheduled class.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.computed_start is None def test_computed_end_is_none_for_a_non_scheduled_class(date_range_mixin_tester): """computed_end attribute is None for a non-scheduled class.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) assert new_foo_obj.computed_end is None def test_computed_duration_attribute_is_none_if_there_is_no_computed_start_and_no_computed_end( date_range_mixin_tester, ): """computed_start attr is None if there is no computed_start and no computed_end.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.computed_start = None new_foo_obj.computed_end = None assert new_foo_obj.computed_duration is None def test_computed_duration_attribute_is_none_if_there_is_computed_start_but_no_computed_end( date_range_mixin_tester, ): """computed_start attr is None if there is computed_start but no computed_end.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.computed_start = datetime.datetime.now(pytz.utc) new_foo_obj.computed_end = None assert new_foo_obj.computed_duration is None def test_computed_duration_attribute_is_none_if_there_is_no_computed_start_but_computed_end( date_range_mixin_tester, ): """computed_start attr is None if there is no computed_start but computed_end.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.computed_start = None new_foo_obj.computed_end = datetime.datetime.now(pytz.utc) assert new_foo_obj.computed_duration is None def test_computed_duration_attribute_is_calculated_correctly_if_there_are_both_computed_start_and_computed_end( date_range_mixin_tester, ): """computed_duration is calculated correctly if both computed_start and computed_end given.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.computed_start = datetime.datetime.now(pytz.utc) new_foo_obj.computed_end = new_foo_obj.computed_start + datetime.timedelta(12) assert new_foo_obj.computed_duration == datetime.timedelta(12) def test_computed_duration_is_read_only(date_range_mixin_tester): """computed_duration attribute is read-only.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_foo_obj.computed_duration = datetime.timedelta(10) error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'computed_duration'", }.get( sys.version_info.minor, "property 'computed_duration' of 'DateRangeMixFooMixedInClass' " "object has no setter", ) assert str(cm.value) == error_message def test_total_seconds_attribute_is_read_only(date_range_mixin_tester): """total_seconds is read only.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_foo_obj.total_seconds = 234234 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'total_seconds'", }.get( sys.version_info.minor, "property 'total_seconds' of 'DateRangeMixFooMixedInClass' " "object has no setter", ) assert str(cm.value) == error_message def test_total_seconds_attribute_is_working_as_expected(date_range_mixin_tester): """total_seconds is read only.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.start = datetime.datetime(2013, 5, 31, 10, 0, tzinfo=pytz.utc) new_foo_obj.end = datetime.datetime(2013, 5, 31, 18, 0, tzinfo=pytz.utc) assert new_foo_obj.total_seconds == 8 * 60 * 60 def test_computed_total_seconds_attribute_is_read_only(date_range_mixin_tester): """computed_total_seconds is read only.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_foo_obj.computed_total_seconds = 234234 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'computed_total_seconds'", }.get( sys.version_info.minor, "property 'computed_total_seconds' of 'DateRangeMixFooMixedInClass' " "object has no setter", ) assert str(cm.value) == error_message def test_computed_total_seconds_attribute_is_working_as_expected( date_range_mixin_tester, ): """computed_total_seconds is read only.""" data = date_range_mixin_tester new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) new_foo_obj.computed_start = datetime.datetime(2013, 5, 31, 10, 0, tzinfo=pytz.utc) new_foo_obj.computed_end = datetime.datetime(2013, 5, 31, 18, 0, tzinfo=pytz.utc) assert new_foo_obj.computed_total_seconds == 8 * 60 * 60 @pytest.fixture(scope="function") def setup_date_range_mixin_db(setup_postgresql_db): """Set up the tests that needs a database. Returns: dict: Test data. """ data = setup_postgresql_db # create mock objects data["start"] = datetime.datetime(2013, 3, 22, 15, 15, tzinfo=pytz.utc) data["end"] = data["start"] + datetime.timedelta(days=20) data["duration"] = datetime.timedelta(days=10) data["kwargs"] = { "name": "Test Daterange Mixin", "description": "This is a simple entity object for testing " "DateRangeMixin", "start": data["start"], "end": data["end"], "duration": data["duration"], } data["test_foo_obj"] = DateRangeMixFooMixedInClass(**data["kwargs"]) return data def test_start_end_and_duration_values_are_rounded_to_the_studio_timing_resolution( setup_date_range_mixin_db, ): """start and end dates are rounded to the Studio timing_resolution.""" data = setup_date_range_mixin_db log.set_level(logging.DEBUG) studio = Studio.query.first() if not studio: logger.debug("No studio found! Creating one!") studio = Studio( name="Test Studio", timing_resolution=datetime.timedelta(minutes=5) ) DBSession.add(studio) DBSession.commit() else: logger.debug("A studio found! Updating timing resolution!") studio.timing_resolution = datetime.timedelta(minutes=5) studio.update_defaults() data["kwargs"]["start"] = datetime.datetime( 2013, 3, 22, 2, 38, 55, 531, tzinfo=pytz.utc ) data["kwargs"]["end"] = datetime.datetime( 2013, 3, 24, 16, 46, 32, 102, tzinfo=pytz.utc ) new_foo_obj = DateRangeMixFooMixedInClass(**data["kwargs"]) # check the start expected_start = datetime.datetime(2013, 3, 22, 2, 40, tzinfo=pytz.utc) assert new_foo_obj.start == expected_start # check the end expected_end = datetime.datetime(2013, 3, 24, 16, 45, tzinfo=pytz.utc) assert new_foo_obj.end == expected_end # check the duration assert new_foo_obj.duration == expected_end - expected_start ================================================ FILE: tests/mixins/test_declarative_project_mixin.py ================================================ # -*- coding: utf-8 -*- """ProjectMixin related tests.""" import pytest from sqlalchemy import Column, ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import ( Project, ProjectMixin, Repository, SimpleEntity, Status, StatusList, Type, ) class DeclProjMixA(SimpleEntity, ProjectMixin): """A class for testing ProjectMixin.""" __tablename__ = "DeclProjMixAs" __mapper_args__ = {"polymorphic_identity": "DeclProjMixA"} a_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclProjMixA, self).__init__(**kwargs) ProjectMixin.__init__(self, **kwargs) class DeclProjMixB(SimpleEntity, ProjectMixin): """A class for testing ProjectMixin.""" __tablename__ = "DeclProjMixBs" __mapper_args__ = {"polymorphic_identity": "DeclProjMixB"} b_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclProjMixB, self).__init__(**kwargs) ProjectMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_project_mixin_tester(): """Set up the tests for ProjectMixin. Returns: dict: Test data. """ data = dict() data["test_stat1"] = Status(name="On Hold", code="OH") data["test_stat2"] = Status(name="Work In Progress", code="WIP") data["test_stat3"] = Status(name="Approved", code="APP") data["test_status_list_1"] = StatusList( name="A Statuses", statuses=[data["test_stat1"], data["test_stat3"]], target_entity_type=DeclProjMixA, ) data["test_status_list_2"] = StatusList( name="B Statuses", statuses=[data["test_stat2"], data["test_stat3"]], target_entity_type=DeclProjMixB, ) data["test_project_statuses"] = StatusList( name="Project Statuses", statuses=[data["test_stat2"], data["test_stat3"]], target_entity_type="Project", ) data["test_project_type"] = Type( name="Test Project Type", code="testproj", target_entity_type="Project", ) data["test_repository"] = Repository( name="Test Repo", code="TR", ) data["test_project"] = Project( name="Test Project", code="tp", type=data["test_project_type"], status_list=data["test_project_statuses"], repository=data["test_repository"], ) data["kwargs"] = { "name": "ozgur", "status_list": data["test_status_list_1"], "project": data["test_project"], } data["test_a_obj"] = DeclProjMixA(**data["kwargs"]) return data def test_project_attribute_is_working_as_expected(setup_project_mixin_tester): """project attribute is working as expected.""" data = setup_project_mixin_tester assert data["test_a_obj"].project == data["test_project"] ================================================ FILE: tests/mixins/test_declarative_reference_mixin.py ================================================ # -*- coding: utf-8 -*- """ReferenceMixin related tests.""" from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import File, SimpleEntity from stalker.models.mixins import ReferenceMixin class DeclRefMixA(SimpleEntity, ReferenceMixin): """A test class for testing ReferenceMixin.""" __tablename__ = "DeclRefMixAs" __mapper_args__ = {"polymorphic_identity": "DeclRefMixA"} a_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclRefMixA, self).__init__(**kwargs) ReferenceMixin.__init__(self, **kwargs) class DeclRefMixB(SimpleEntity, ReferenceMixin): """A test class for testing ReferenceMixin.""" __tablename__ = "RefMixBs" __mapper_args__ = {"polymorphic_identity": "DeclRefMixB"} b_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclRefMixB, self).__init__(**kwargs) ReferenceMixin.__init__(self, **kwargs) def test_reference_mixin_setup(): """ReferenceMixin setup.""" a_ins = DeclRefMixA(name="ozgur") b_ins = DeclRefMixB(name="bozgur") new_file1 = File(name="test file 1", full_path="none") new_file2 = File(name="test file 2", full_path="no path") a_ins.references.append(new_file1) b_ins.references.append(new_file2) assert new_file1 in a_ins.references assert new_file2 in b_ins.references ================================================ FILE: tests/mixins/test_declarative_schedule_mixin.py ================================================ # -*- coding: utf-8 -*- """DateRangeMixin related tests.""" import datetime import logging import pytest import pytz from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import log from stalker.models.entity import SimpleEntity from stalker.models.mixins import DateRangeMixin logger = log.get_logger(__name__) log.set_level(logging.DEBUG) class DeclSchedMixA(SimpleEntity, DateRangeMixin): """A class for testing DateRangeMixin.""" __tablename__ = "DeclSchedMixAs" __mapper_args__ = {"polymorphic_identity": "DeclSchedMixA"} a_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclSchedMixA, self).__init__(**kwargs) DateRangeMixin.__init__(self, **kwargs) class DeclSchedMixB(SimpleEntity, DateRangeMixin): """A class for testing DateRangeMixin.""" __tablename__ = "DeclSchedMixBs" __mapper_args__ = {"polymorphic_identity": "DeclSchedMixB"} b_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclSchedMixB, self).__init__(**kwargs) DateRangeMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_schedule_mixin_tester(): """Set up the tests for DateRangeMixin setup. Returns: dict: Test data. """ data = dict() data["kwargs"] = { "name": "ozgur", "start": datetime.datetime(2013, 3, 20, 4, 0, tzinfo=pytz.utc), "end": datetime.datetime(2013, 3, 30, 4, 0, tzinfo=pytz.utc), "duration": datetime.timedelta(10), } return data def test_mixin_setup_is_working_as_expected(setup_schedule_mixin_tester): """Mixin setup is working as expected.""" data = setup_schedule_mixin_tester new_a = DeclSchedMixA(**data["kwargs"]) # should not create any problem assert new_a.start == data["kwargs"]["start"] assert new_a.end == data["kwargs"]["end"] assert new_a.duration == data["kwargs"]["duration"] logger.debug("----------------------------") logger.debug(new_a.start) logger.debug(new_a.end) logger.debug(new_a.duration) # try to change the start and check if the duration is also updated new_a.start = datetime.datetime(2013, 3, 30, 10, 0, tzinfo=pytz.utc) assert new_a.start == datetime.datetime(2013, 3, 30, 10, 0, tzinfo=pytz.utc) assert new_a.end == datetime.datetime(2013, 4, 9, 10, 0, tzinfo=pytz.utc) assert new_a.duration == datetime.timedelta(10) a_start = new_a.start a_end = new_a.end a_duration = new_a.duration # now check the start, end and duration logger.debug("----------------------------") logger.debug(new_a.start) logger.debug(new_a.end) logger.debug(new_a.duration) # create a new class new_b = DeclSchedMixB(**data["kwargs"]) # now check the start, end and duration assert new_b.start == data["kwargs"]["start"] assert new_b.end == data["kwargs"]["end"] assert new_b.duration == data["kwargs"]["duration"] logger.debug("----------------------------") logger.debug(new_b.start) logger.debug(new_b.end) logger.debug(new_b.duration) # now check the start, end and duration of A logger.debug("----------------------------") logger.debug(new_a.start) logger.debug(new_a.end) logger.debug(new_a.duration) assert new_a.start == a_start assert new_a.end == a_end assert new_a.duration == a_duration ================================================ FILE: tests/mixins/test_declarative_status_mixin.py ================================================ # -*- coding: utf-8 -*- """StatusMixin class related tests.""" import pytest from sqlalchemy import Column, ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import SimpleEntity, Status, StatusList, StatusMixin class DeclStatMixA(SimpleEntity, StatusMixin): """A class for testing StatusMixin.""" __tablename__ = "DeclStatMixAs" __mapper_args__ = {"polymorphic_identity": "DeclStatMixA"} declStatMixAs_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclStatMixA, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) class DeclStatMixB(SimpleEntity, StatusMixin): """A class for testing StatusMixin.""" __tablename__ = "DeclStatMixBs" __mapper_args__ = {"polymorphic_identity": "DeclStatMixB"} b_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(DeclStatMixB, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_status_mixin_tester(): """Set up the tests for StatusMixin. Returns: dict: Test data. """ data = dict() data["test_stat1"] = Status(name="On Hold", code="OH") data["test_stat2"] = Status(name="Work In Progress", code="WIP") data["test_stat3"] = Status(name="Approved", code="APP") data["test_a_statusList"] = StatusList( name="A Statuses", statuses=[data["test_stat1"], data["test_stat3"]], target_entity_type="DeclStatMixA", ) data["test_b_statusList"] = StatusList( name="B Statuses", statuses=[data["test_stat2"], data["test_stat3"]], target_entity_type="DeclStatMixB", ) data["kwargs"] = {"name": "ozgur", "status_list": data["test_a_statusList"]} return data def test_status_list_argument_not_set(setup_status_mixin_tester): """TypeError will be raised if the status_list argument is not set.""" data = setup_status_mixin_tester data["kwargs"].pop("status_list") with pytest.raises(TypeError) as cm: DeclStatMixA(**data["kwargs"]) assert ( str(cm.value) == "DeclStatMixA instances cannot be initialized without a " "stalker.models.status.StatusList instance, please pass a suitable StatusList " "(StatusList.target_entity_type=DeclStatMixA) with the 'status_list' argument" ) def test_status_list_argument_is_not_correct(setup_status_mixin_tester): """TypeError is raised if status_list argument is not a StatusList.""" data = setup_status_mixin_tester data["kwargs"]["status_list"] = data["test_b_statusList"] with pytest.raises(TypeError) as cm: DeclStatMixA(**data["kwargs"]) assert ( str(cm.value) == "The given StatusLists' target_entity_type is DeclStatMixB, " "whereas the entity_type of this object is DeclStatMixA" ) def test_status_list_working_as_expected(setup_status_mixin_tester): """status_list attribute is working as expected.""" data = setup_status_mixin_tester new_a_ins = DeclStatMixA(name="Ozgur", status_list=data["test_a_statusList"]) assert data["test_stat1"] in new_a_ins.status_list assert data["test_stat2"] not in new_a_ins.status_list assert data["test_stat3"] in new_a_ins.status_list ================================================ FILE: tests/mixins/test_project_mixin.py ================================================ # -*- coding: utf-8 -*- """ProjectMixin related tests.""" import pytest from sqlalchemy import Column, ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import Project, ProjectMixin, Repository, SimpleEntity, Type class ProjMixClass(SimpleEntity, ProjectMixin): """A class for testing ProjectMixin.""" __tablename__ = "ProjMixClasses" __mapper_args__ = {"polymorphic_identity": "ProjMixClass"} projMixClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(ProjMixClass, self).__init__(**kwargs) ProjectMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_project_mixin_tester(): """Set up the tests for the ProjectMixin. Returns: dict: Test data. """ # create a repository data = dict() data["repository_type"] = Type( name="Test Repository Type", code="testproj", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["repository_type"], ) # statuses from stalker import Status data["status1"] = Status(name="Status1", code="STS1") data["status2"] = Status(name="Status2", code="STS2") data["status3"] = Status(name="Status3", code="STS3") # project status list from stalker import StatusList data["project_status_list"] = StatusList( name="Project Status List", statuses=[ data["status1"], data["status2"], data["status3"], ], target_entity_type="Project", ) # project type data["test_project_type"] = Type( name="Test Project Type", code="testproj", target_entity_type="Project", ) # create projects data["test_project1"] = Project( name="Test Project 1", code="tp1", type=data["test_project_type"], status_list=data["project_status_list"], repository=data["test_repository"], ) data["test_project2"] = Project( name="Test Project 2", code="tp2", type=data["test_project_type"], status_list=data["project_status_list"], repository=data["test_repository"], ) data["kwargs"] = { "name": "Test Class", "project": data["test_project1"], } data["test_foo_obj"] = ProjMixClass(**data["kwargs"]) return data def test_project_argument_is_skipped(setup_project_mixin_tester): """TypeError will be raised if the project argument is skipped.""" data = setup_project_mixin_tester data["kwargs"].pop("project") with pytest.raises(TypeError) as cm: ProjMixClass(**data["kwargs"]) assert ( str(cm.value) == "ProjMixClass.project cannot be None it must be an instance of " "stalker.models.project.Project" ) def test_project_argument_is_none(setup_project_mixin_tester): """TypeError will be raised if the project argument is None.""" data = setup_project_mixin_tester data["kwargs"]["project"] = None with pytest.raises(TypeError) as cm: ProjMixClass(**data["kwargs"]) assert ( str(cm.value) == "ProjMixClass.project cannot be None it must be an instance of " "stalker.models.project.Project" ) def test_project_attribute_is_none(setup_project_mixin_tester): """TypeError is raised if the project attribute is set to None.""" data = setup_project_mixin_tester with pytest.raises(TypeError) as cm: data["test_foo_obj"].project = None assert ( str(cm.value) == "ProjMixClass.project cannot be None it must be an instance of " "stalker.models.project.Project" ) def test_project_argument_is_not_a_project_instance(setup_project_mixin_tester): """TypeError is raised if the project argument is not a Project.""" data = setup_project_mixin_tester data["kwargs"]["project"] = "a project" with pytest.raises(TypeError) as cm: ProjMixClass(**data["kwargs"]) assert str(cm.value) == ( "ProjMixClass.project should be an instance of stalker.models.project.Project " "instance, not str: 'a project'" ) def test_project_attribute_is_not_a_project_instance(setup_project_mixin_tester): """TypeError is raised if the project attribute is not a Project.""" data = setup_project_mixin_tester with pytest.raises(TypeError) as cm: data["test_foo_obj"].project = "a project" assert str(cm.value) == ( "ProjMixClass.project should be an instance of stalker.models.project.Project " "instance, not str: 'a project'" ) def test_project_attribute_is_working_as_expected(setup_project_mixin_tester): """project attribute is working as expected.""" data = setup_project_mixin_tester data["test_foo_obj"].project = data["test_project2"] assert data["test_foo_obj"].project == data["test_project2"] ================================================ FILE: tests/mixins/test_reference_mixin.py ================================================ # -*- coding: utf-8 -*- """ReferenceMixin related tests.""" import pytest from sqlalchemy import Column, ForeignKey, Integer from sqlalchemy.orm import Mapped, mapped_column from stalker import Entity, File, ReferenceMixin, SimpleEntity, Type class RefMixFooClass(SimpleEntity, ReferenceMixin): """class for ReferenceMixin tests.""" __tablename__ = "RefMixFooClasses" __mapper_args__ = {"polymorphic_identity": "RefMixFooClass"} refMixFooClass_id: Mapped[int] = mapped_column( "id", Integer, ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(RefMixFooClass, self).__init__(**kwargs) @pytest.fixture(scope="function") def setup_reference_mixin_tester(): """Set up the tests for the ReferenceMixin. Returns: dict: test data. """ # file type data = dict() data["test_file_type"] = Type( name="Test File Type", code="testfile", target_entity_type=File, ) # create a couple of File objects data["test_file1"] = File( name="Test File 1", type=data["test_file_type"], full_path="test_path", filename="test_filename", ) data["test_file2"] = File( name="Test File 2", type=data["test_file_type"], full_path="test_path", filename="test_filename", ) data["test_file3"] = File( name="Test File 3", type=data["test_file_type"], full_path="test_path", filename="test_filename", ) data["test_file4"] = File( name="Test File 4", type=data["test_file_type"], full_path="test_path", filename="test_filename", ) data["test_entity1"] = Entity( name="Test Entity 1", ) data["test_entity2"] = Entity( name="Test Entity 2", ) data["test_files"] = [ data["test_file1"], data["test_file2"], data["test_file3"], data["test_file4"], ] data["test_foo_obj"] = RefMixFooClass(name="Ref Mixin Test") return data def test_references_attribute_accepting_empty_list(setup_reference_mixin_tester): """references attribute accepting empty lists.""" data = setup_reference_mixin_tester data["test_foo_obj"].references = [] def test_references_attribute_only_accepts_list_like_objects( setup_reference_mixin_tester, ): """references attribute accepts only list-like objects.""" data = setup_reference_mixin_tester with pytest.raises(TypeError) as cm: data["test_foo_obj"].references = "a string" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_references_attribute_accepting_only_lists_of_file_instances( setup_reference_mixin_tester, ): """references attribute accepting only lists of Files.""" data = setup_reference_mixin_tester test_value = [1, 2.2, "some references"] with pytest.raises(TypeError) as cm: data["test_foo_obj"].references = test_value assert str(cm.value) == ( "RefMixFooClass.references should only contain instances of " "stalker.models.file.File, not int: '1'" ) def test_references_attribute_elements_accepts_files_only(setup_reference_mixin_tester): """TypeError is raised if non File assigned to references attribute.""" data = setup_reference_mixin_tester with pytest.raises(TypeError) as cm: data["test_foo_obj"].references = [data["test_entity1"], data["test_entity2"]] assert str(cm.value) == ( "RefMixFooClass.references should only contain instances of " "stalker.models.file.File, not Entity: ''" ) def test_references_attribute_is_working_as_expected(setup_reference_mixin_tester): """references attribute working as expected.""" data = setup_reference_mixin_tester data["test_foo_obj"].references = data["test_files"] assert data["test_foo_obj"].references == data["test_files"] test_value = [data["test_file1"], data["test_file2"]] data["test_foo_obj"].references = test_value assert sorted(data["test_foo_obj"].references, key=lambda x: x.name) == sorted( test_value, key=lambda x: x.name ) def test_references_application_test(setup_reference_mixin_tester): """example of ReferenceMixin usage.""" class GreatEntity(SimpleEntity, ReferenceMixin): __tablename__ = "GreatEntities" __mapper_args__ = {"polymorphic_identity": "GreatEntity"} ge_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) my_ge = GreatEntity(name="Test") # we should have a references attribute right now _ = my_ge.references image_file_type = Type(name="Image", code="image", target_entity_type="File") new_file = File( name="NewTestFile", full_path="nopath", filename="nofilename", type=image_file_type, ) test_value = [new_file] my_ge.references = test_value assert my_ge.references == test_value ================================================ FILE: tests/mixins/test_schedule_mixin.py ================================================ # -*- coding: utf-8 -*- """ScheduleMixin related tests.""" import datetime import pytest from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column import stalker from stalker import ScheduleMixin, SimpleEntity, defaults from stalker.models.enum import TimeUnit from stalker.models.enum import ScheduleModel class MixedInClass(SimpleEntity, ScheduleMixin): """class derived from SimpleEntity and mixed in with ScheduleMixin for testing.""" __tablename__ = "ScheduleMixFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "ScheduleMixFooMixedInClass"} schedMixFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): SimpleEntity.__init__(self, **kwargs) ScheduleMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_schedule_mixin_tests(): """Set up the tests for the ScheduleMixin. Yields: dict: Test data. """ stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) data = dict() data["kwargs"] = { "name": "Test Object", "schedule_timing": 1, "schedule_unit": TimeUnit.Hour, "schedule_model": ScheduleModel.Effort, "schedule_constraint": 0, } data["test_obj"] = MixedInClass(**data["kwargs"]) yield data stalker.defaults.config_values = stalker.defaults.default_config_values.copy() stalker.defaults["timing_resolution"] = datetime.timedelta(hours=1) def test_schedule_model_attribute_is_effort_by_default(setup_schedule_mixin_tests): """schedule_model is effort by default.""" data = setup_schedule_mixin_tests assert data["test_obj"].schedule_model == ScheduleModel.Effort def test_schedule_model_argument_is_none(setup_schedule_mixin_tests): """schedule model is 'effort' if the schedule_model argument is set to None.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_model"] = None new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_model == ScheduleModel.Effort def test_schedule_model_attribute_is_set_to_none(setup_schedule_mixin_tests): """schedule_model will be 'effort' if it is set to None.""" data = setup_schedule_mixin_tests data["test_obj"].schedule_model = None assert data["test_obj"].schedule_model == ScheduleModel.Effort def test_schedule_model_argument_is_not_a_string(setup_schedule_mixin_tests): """TypeError is raised if the schedule_model argument is not a string.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_model"] = 234 with pytest.raises(TypeError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], not int: '234'" ) def test_schedule_model_attribute_is_not_a_string(setup_schedule_mixin_tests): """TypeError is raised if the schedule_model attribute is not a string.""" data = setup_schedule_mixin_tests with pytest.raises(TypeError) as cm: data["test_obj"].schedule_model = 2343 assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not int: '2343'" ) def test_schedule_model_argument_is_not_in_correct_value(setup_schedule_mixin_tests): """ValueError is raised if the schedule_model argument is not valid.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_model"] = "not in the list" with pytest.raises(ValueError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], not " "'not in the list'" ) def test_schedule_model_attribute_is_not_in_correct_value(setup_schedule_mixin_tests): """ValueError is raised if the schedule_model attribute is not valid.""" data = setup_schedule_mixin_tests with pytest.raises(ValueError) as cm: data["test_obj"].schedule_model = "not in the list" assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not 'not in the list'" ) @pytest.mark.parametrize("schedule_model", ["duration", ScheduleModel.Duration]) def test_schedule_model_argument_is_working_as_expected( setup_schedule_mixin_tests, schedule_model ): """schedule_model arg is passed to the schedule_model attribute.""" data = setup_schedule_mixin_tests test_value = schedule_model data["kwargs"]["schedule_model"] = test_value new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_model == ScheduleModel.to_model(test_value) @pytest.mark.parametrize("schedule_model", ["duration", ScheduleModel.Duration]) def test_schedule_model_attribute_is_working_as_expected( setup_schedule_mixin_tests, schedule_model ): """schedule_model attribute is working as expected.""" data = setup_schedule_mixin_tests test_value = schedule_model assert data["test_obj"].schedule_model != ScheduleModel.to_model(test_value) data["test_obj"].schedule_model = test_value assert data["test_obj"].schedule_model == ScheduleModel.to_model(test_value) def test_schedule_constraint_is_0_by_default(setup_schedule_mixin_tests): """schedule_constraint attribute is None by default.""" data = setup_schedule_mixin_tests assert data["test_obj"].schedule_constraint == 0 def test_schedule_constraint_argument_is_skipped(setup_schedule_mixin_tests): """schedule_constraint attribute is 0 if schedule_constraint is skipped.""" data = setup_schedule_mixin_tests try: data["kwargs"].pop("schedule_constraint") except KeyError: pass new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_constraint == 0 def test_schedule_constraint_argument_is_none(setup_schedule_mixin_tests): """schedule_constraint attribute will be 0 if schedule_constraint is None.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_constraint"] = None new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_constraint == 0 def test_schedule_constraint_attribute_is_set_to_none(setup_schedule_mixin_tests): """schedule_constraint attribute will be 0 if it is set to None.""" data = setup_schedule_mixin_tests data["test_obj"].schedule_constraint = None assert data["test_obj"].schedule_constraint == 0 def test_schedule_constraint_argument_is_not_an_integer(setup_schedule_mixin_tests): """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(ValueError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "constraint should be a ScheduleConstraint enum value or 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(ValueError) as cm: data["test_obj"].schedule_constraint = "not an int" assert str(cm.value) == ( "constraint should be a ScheduleConstraint enum value or one of " "['None', 'Start', 'End', 'Both'], not 'not an int'" ) def test_schedule_constraint_argument_is_working_as_expected( setup_schedule_mixin_tests, ): """schedule_constraint arg value is passed to schedule_constraint attribute.""" data = setup_schedule_mixin_tests test_value = 2 data["kwargs"]["schedule_constraint"] = test_value new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_constraint == test_value def test_schedule_constraint_attribute_is_working_as_expected( setup_schedule_mixin_tests, ): """schedule_constraint attribute value is correctly changed.""" data = setup_schedule_mixin_tests test_value = 3 data["test_obj"].schedule_constraint = test_value assert data["test_obj"].schedule_constraint == test_value @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"] = 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, test_value, ): """schedule_constraint is clamped to the [0-3] range if it is out of range.""" data = setup_schedule_mixin_tests 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.""" data = setup_schedule_mixin_tests data["kwargs"].pop("schedule_timing") new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_timing == MixedInClass.__default_schedule_timing__ def test_schedule_timing_argument_is_none(setup_schedule_mixin_tests): """schedule_timing==Config.timing_resolution.seconds/60 if the schedule_timing arg is None.""" data = setup_schedule_mixin_tests defaults["timing_resolution"] = datetime.timedelta(hours=1) data["kwargs"]["schedule_timing"] = None new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_timing == defaults.timing_resolution.seconds / 60.0 def test_schedule_timing_attribute_is_set_to_none(setup_schedule_mixin_tests): """schedule_timing==Config.timing_resolution.seconds/60 if it is set to None.""" data = setup_schedule_mixin_tests defaults["timing_resolution"] = datetime.timedelta(hours=1) data["test_obj"].schedule_timing = None assert data["test_obj"].schedule_timing == defaults.timing_resolution.seconds / 60.0 def test_schedule_timing_argument_is_not_an_integer_or_float( setup_schedule_mixin_tests, ): """TypeError is raised if the schedule_timing is not an int or float.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_timing"] = "10d" with pytest.raises(TypeError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "MixedInClass.schedule_timing should be an integer or float number showing the " "value of the schedule timing of this MixedInClass, not str: '10d'" ) def test_schedule_timing_attribute_is_not_an_int_or_float( setup_schedule_mixin_tests, ): """TypeError is raised if the schedule_timing attribute is not int or float.""" data = setup_schedule_mixin_tests with pytest.raises(TypeError) as cm: data["test_obj"].schedule_timing = "10d" assert str(cm.value) == ( "MixedInClass.schedule_timing should be an integer or float number showing the " "value of the schedule timing of this MixedInClass, not str: '10d'" ) def test_schedule_timing_attribute_is_working_as_expected(setup_schedule_mixin_tests): """schedule_timing attribute is working as expected.""" data = setup_schedule_mixin_tests test_value = 18 data["test_obj"].schedule_timing = test_value assert data["test_obj"].schedule_timing == test_value def test_schedule_unit_argument_skipped(setup_schedule_mixin_tests): """schedule_unit attribute defaults if schedule_unit argument is skipped.""" data = setup_schedule_mixin_tests data["kwargs"].pop("schedule_unit") new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_unit == MixedInClass.__default_schedule_unit__ def test_schedule_unit_argument_is_none(setup_schedule_mixin_tests): """schedule_unit attribute defaults if the schedule_unit argument is None.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_unit"] = None new_task = MixedInClass(**data["kwargs"]) assert new_task.schedule_unit == MixedInClass.__default_schedule_unit__ def test_schedule_unit_attribute_is_set_to_none(setup_schedule_mixin_tests): """schedule_unit attribute will use the default value if it is set to None.""" data = setup_schedule_mixin_tests data["test_obj"].schedule_unit = None assert data["test_obj"].schedule_unit == MixedInClass.__default_schedule_unit__ def test_schedule_unit_argument_is_not_a_string(setup_schedule_mixin_tests): """TypeError will be raised if the schedule_unit is not an int.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_unit"] = 10 with pytest.raises(TypeError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '10'" ) def test_schedule_unit_attribute_is_not_a_string(setup_schedule_mixin_tests): """TypeError is raised if schedule_unit attribute is not set to a string.""" data = setup_schedule_mixin_tests with pytest.raises(TypeError) as cm: data["test_obj"].schedule_unit = 23 assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '23'" ) def test_schedule_unit_attribute_is_working_as_expected(setup_schedule_mixin_tests): """schedule_unit attribute is working as expected.""" data = setup_schedule_mixin_tests test_value = TimeUnit.Week data["test_obj"].schedule_unit = test_value assert data["test_obj"].schedule_unit == test_value def test_schedule_unit_argument_value_is_not_in_defaults_datetime_units( setup_schedule_mixin_tests, ): """ValueError is raised if the schedule_unit is not in datetime_units list.""" data = setup_schedule_mixin_tests data["kwargs"]["schedule_unit"] = "os" with pytest.raises(ValueError) as cm: MixedInClass(**data["kwargs"]) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'os'" ) def test_schedule_unit_attribute_value_is_not_in_defaults_datetime_units( setup_schedule_mixin_tests, ): """ValueError is raised if schedule_unit not in datetime_units.""" data = setup_schedule_mixin_tests with pytest.raises(ValueError) as cm: data["test_obj"].schedule_unit = "so" assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'so'" ) @pytest.mark.parametrize( "input_value,expected_result", [ [[60, True], (1, TimeUnit.Minute)], [[125, True], (2, TimeUnit.Minute)], [[1800, True], (30, TimeUnit.Minute)], [[3600, True], (1, TimeUnit.Hour)], [[5400, True], (90, TimeUnit.Minute)], [[6000, True], (100, TimeUnit.Minute)], [[7200, True], (2, TimeUnit.Hour)], [[9600, True], (160, TimeUnit.Minute)], [[10000, True], (166, TimeUnit.Minute)], [[12000, True], (200, TimeUnit.Minute)], [[14400, True], (4, TimeUnit.Hour)], [[15000, True], (250, TimeUnit.Minute)], [[18000, True], (5, TimeUnit.Hour)], [[32400, True], (1, TimeUnit.Day)], [[32400, False], (9, TimeUnit.Hour)], [[64800, True], (2, TimeUnit.Day)], [[64800, False], (18, TimeUnit.Hour)], [[86400, True], (24, TimeUnit.Hour)], [[86400, False], (1, TimeUnit.Day)], [[162000, True], (1, TimeUnit.Week)], [[162000, False], (45, TimeUnit.Hour)], [[604800, False], (1, TimeUnit.Week)], [[648000, True], (1, TimeUnit.Month)], [[648000, False], (180, TimeUnit.Hour)], [[8424000, True], (1, TimeUnit.Year)], [[8424000, False], (2340, TimeUnit.Hour)], [[2419200, False], (1, TimeUnit.Month)], [[31536000, False], (1, TimeUnit.Year)], ], ) def test_least_meaningful_time_unit_is_working_as_expected( input_value, expected_result, setup_schedule_mixin_tests ): """least_meaningful_time_unit is working as expected.""" data = setup_schedule_mixin_tests defaults["daily_working_hours"] = 9 defaults["weekly_working_days"] = 5 defaults["weekly_working_hours"] = 45 defaults["yearly_working_days"] = 52.1428 * 5 assert expected_result == data["test_obj"].least_meaningful_time_unit(*input_value) @pytest.mark.parametrize( "schedule_model,schedule_timing,schedule_unit,expected_value", [ # effort values ["effort", 1, "min", 60], ["effort", 1, "h", 3600], ["effort", 1, "d", 32400], ["effort", 1, "w", 162000], ["effort", 1, "m", 648000], ["effort", 1, "y", 8424000], ["effort", 1, TimeUnit.Minute, 60], ["effort", 1, TimeUnit.Hour, 3600], ["effort", 1, TimeUnit.Day, 32400], ["effort", 1, TimeUnit.Week, 162000], ["effort", 1, TimeUnit.Month, 648000], ["effort", 1, TimeUnit.Year, 8424000], [ScheduleModel.Effort, 1, "min", 60], [ScheduleModel.Effort, 1, "h", 3600], [ScheduleModel.Effort, 1, "d", 32400], [ScheduleModel.Effort, 1, "w", 162000], [ScheduleModel.Effort, 1, "m", 648000], [ScheduleModel.Effort, 1, "y", 8424000], [ScheduleModel.Effort, 1, TimeUnit.Minute, 60], [ScheduleModel.Effort, 1, TimeUnit.Hour, 3600], [ScheduleModel.Effort, 1, TimeUnit.Day, 32400], [ScheduleModel.Effort, 1, TimeUnit.Week, 162000], [ScheduleModel.Effort, 1, TimeUnit.Month, 648000], [ScheduleModel.Effort, 1, TimeUnit.Year, 8424000], # length values ["length", 1, "min", 60], ["length", 1, "h", 3600], ["length", 1, "d", 32400], ["length", 1, "w", 162000], ["length", 1, "m", 648000], ["length", 1, "y", 8424000], ["length", 1, TimeUnit.Minute, 60], ["length", 1, TimeUnit.Hour, 3600], ["length", 1, TimeUnit.Day, 32400], ["length", 1, TimeUnit.Week, 162000], ["length", 1, TimeUnit.Month, 648000], ["length", 1, TimeUnit.Year, 8424000], [ScheduleModel.Length, 1, "min", 60], [ScheduleModel.Length, 1, "h", 3600], [ScheduleModel.Length, 1, "d", 32400], [ScheduleModel.Length, 1, "w", 162000], [ScheduleModel.Length, 1, "m", 648000], [ScheduleModel.Length, 1, "y", 8424000], [ScheduleModel.Length, 1, TimeUnit.Minute, 60], [ScheduleModel.Length, 1, TimeUnit.Hour, 3600], [ScheduleModel.Length, 1, TimeUnit.Day, 32400], [ScheduleModel.Length, 1, TimeUnit.Week, 162000], [ScheduleModel.Length, 1, TimeUnit.Month, 648000], [ScheduleModel.Length, 1, TimeUnit.Year, 8424000], # duration values ["duration", 1, "min", 60], ["duration", 1, "h", 3600], ["duration", 1, "d", 86400], ["duration", 1, "w", 604800], ["duration", 1, "m", 2419200], ["duration", 1, "y", 31536000], ["duration", 1, TimeUnit.Minute, 60], ["duration", 1, TimeUnit.Hour, 3600], ["duration", 1, TimeUnit.Day, 86400], ["duration", 1, TimeUnit.Week, 604800], ["duration", 1, TimeUnit.Month, 2419200], ["duration", 1, TimeUnit.Year, 31536000], [ScheduleModel.Duration, 1, "min", 60], [ScheduleModel.Duration, 1, "h", 3600], [ScheduleModel.Duration, 1, "d", 86400], [ScheduleModel.Duration, 1, "w", 604800], [ScheduleModel.Duration, 1, "m", 2419200], [ScheduleModel.Duration, 1, "y", 31536000], [ScheduleModel.Duration, 1, TimeUnit.Minute, 60], [ScheduleModel.Duration, 1, TimeUnit.Hour, 3600], [ScheduleModel.Duration, 1, TimeUnit.Day, 86400], [ScheduleModel.Duration, 1, TimeUnit.Week, 604800], [ScheduleModel.Duration, 1, TimeUnit.Month, 2419200], [ScheduleModel.Duration, 1, TimeUnit.Year, 31536000], ], ) def test_to_seconds_is_working_as_expected( schedule_model, schedule_timing, schedule_unit, expected_value, setup_schedule_mixin_tests, ): """to_seconds method is working as expected.""" data = setup_schedule_mixin_tests defaults["daily_working_hours"] = 9 defaults["weekly_working_days"] = 5 defaults["weekly_working_hours"] = 45 defaults["yearly_working_days"] = 52.1428 * 5 data["test_obj"].schedule_model = schedule_model data["test_obj"].schedule_timing = schedule_timing data["test_obj"].schedule_unit = schedule_unit assert expected_value == data["test_obj"].to_seconds( data["test_obj"].schedule_timing, data["test_obj"].schedule_unit, data["test_obj"].schedule_model, ) def test_to_unit_unit_is_none(setup_schedule_mixin_tests): """to_unit method is working as expected.""" data = setup_schedule_mixin_tests defaults["daily_working_hours"] = 9 defaults["weekly_working_days"] = 5 defaults["weekly_working_hours"] = 45 defaults["yearly_working_days"] = 52.1428 * 5 assert data["test_obj"].to_unit(10, None, "effort") is None @pytest.mark.parametrize( "schedule_model,schedule_timing,schedule_unit,seconds", [ # effort values ["effort", 1, "min", 60], ["effort", 10, "min", 600], ["effort", 20, "min", 1200], ["effort", 1, "h", 3600], ["effort", 1.01, "h", 3636], ["effort", 2, "h", 7200], ["effort", 1, "d", 32400], ["effort", 1, "w", 162000], ["effort", 1, "m", 648000], ["effort", 1, "y", 8424000], # length values ["length", 1, "min", 60], ["length", 540, "min", 32400], ["length", 1, "h", 3600], ["length", 1, "d", 32400], ["length", 1, "w", 162000], ["length", 1, "m", 648000], ["length", 1, "y", 8424000], # duration values ["duration", 1, "min", 60], ["duration", 60, "min", 3600], ["duration", 1440, "min", 86400], ["duration", 1, "h", 3600], ["duration", 1.5, "h", 5400], ["duration", 2, "h", 7200], ["duration", 1, "d", 86400], ["duration", 1, "w", 604800], ["duration", 1, "m", 2419200], ["duration", 1, "y", 31536000], ], ) def test_to_unit_is_working_as_expected( schedule_model, schedule_timing, schedule_unit, seconds, setup_schedule_mixin_tests ): """to_unit method is working as expected.""" data = setup_schedule_mixin_tests defaults["daily_working_hours"] = 9 defaults["weekly_working_days"] = 5 defaults["weekly_working_hours"] = 45 defaults["yearly_working_days"] = 52.1428 * 5 assert schedule_timing == data["test_obj"].to_unit( seconds, schedule_unit, schedule_model ) @pytest.mark.parametrize( "schedule_model,schedule_timing,schedule_unit,expected_value", [ # effort values ["effort", 1, "min", 60], ["effort", 1, "h", 3600], ["effort", 1, "d", 32400], ["effort", 1, "w", 162000], ["effort", 1, "m", 648000], ["effort", 1, "y", 8424000], # length values ["length", 1, "min", 60], ["length", 1, "h", 3600], ["length", 1, "d", 32400], ["length", 1, "w", 162000], ["length", 1, "m", 648000], ["length", 1, "y", 8424000], # duration values ["duration", 1, "min", 60], ["duration", 1, "h", 3600], ["duration", 1, "d", 86400], ["duration", 1, "w", 604800], ["duration", 1, "m", 2419200], ["duration", 1, "y", 31536000], ], ) def test_schedule_seconds_is_working_as_expected( schedule_model, schedule_timing, schedule_unit, expected_value, setup_schedule_mixin_tests, ): """schedule_seconds property is working as expected.""" data = setup_schedule_mixin_tests defaults["daily_working_hours"] = 9 defaults["weekly_working_days"] = 5 defaults["weekly_working_hours"] = 45 defaults["yearly_working_days"] = 52.1428 * 5 data["test_obj"].schedule_model = schedule_model data["test_obj"].schedule_timing = schedule_timing data["test_obj"].schedule_unit = schedule_unit assert expected_value == data["test_obj"].schedule_seconds ================================================ FILE: tests/mixins/test_status_mixin.py ================================================ # -*- coding: utf-8 -*- """StatusMixin class related tests.""" import pytest from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import SimpleEntity, Status, StatusList, StatusMixin from stalker.db.session import DBSession class StatMixClass(SimpleEntity, StatusMixin): """A class for testing StatusMixin.""" __tablename__ = "StatMixClasses" __mapper_args__ = {"polymorphic_identity": "StatMixClass"} StatMixClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(StatMixClass, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) class StatMixDerivedClass(StatMixClass): """A class deriving from StatMixClass. With the new approach it should be possible to use the StatusLists created for the StatMixClass. """ __tablename__ = "StatMixDerivedClasses" __mapper_args__ = {"polymorphic_identity": "StatMixDerivedClass"} StatMixDerivedClass_id: Mapped[int] = mapped_column( "id", ForeignKey("StatMixClasses.id"), primary_key=True ) @pytest.fixture(scope="function") def status_mixin_tests(): """Set up the tests for the StatusMixin class. Returns: dict: Test data. """ data = dict() data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") data["test_status4"] = Status(name="Status4", code="STS4") data["test_status5"] = Status(name="Status5", code="STS5") # statuses which are not going to be used data["test_status6"] = Status(name="Status6", code="STS6") data["test_status7"] = Status(name="Status7", code="STS7") data["test_status8"] = Status(name="Status8", code="STS8") # a test StatusList object data["test_status_list1"] = StatusList( name="Test Status List 1", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="StatMixClass", ) # another test StatusList object data["test_status_list2"] = StatusList( name="Test Status List 2", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="StatMixClass", ) data["kwargs"] = { "name": "Test Class", "status_list": data["test_status_list1"], "status": data["test_status_list1"].statuses[0], } data["test_mixed_obj"] = StatMixClass(**data["kwargs"]) data["test_mixed_obj"].status_list = data["test_status_list1"] # create another one without status_list set to something data["test_mixed_obj2"] = StatMixClass(**data["kwargs"]) return data def test_status_list_arg_is_not_a_status_list_instance(status_mixin_tests): """TypeError is raised if status_list arg is not a StatusList.""" data = status_mixin_tests data["kwargs"]["status_list"] = 100 with pytest.raises(TypeError) as cm: StatMixClass(**data["kwargs"]) assert str(cm.value) == ( "StatMixClass.status_list should be an instance of " "stalker.models.status.StatusList, not int: '100'" ) def test_status_list_attr_is_not_a_status_list(status_mixin_tests): """TypeError is raised if status_list is not a StatusList.""" data = status_mixin_tests with pytest.raises(TypeError) as cm: data["test_mixed_obj"].status_list = "a string" assert str(cm.value) == ( "StatMixClass.status_list should be an instance of " "stalker.models.status.StatusList, not str: 'a string'" ) def test_status_list_arg_is_not_suitable_for_the_current_class(status_mixin_tests): """TypeError is raised if the Status.target_entity_type is not compatible.""" data = status_mixin_tests # create a new status list suitable for another class with different # entity_type new_status_list = StatusList( name="Sequence Statuses", statuses=[ Status(name="On Hold", code="OH"), Status(name="Complete", code="CMPLT"), ], target_entity_type="Sequence", ) data["kwargs"]["status_list"] = new_status_list data["kwargs"].pop("status") with pytest.raises(TypeError) as cm: _ = StatMixClass(**data["kwargs"]) assert ( str(cm.value) == "The given StatusLists' target_entity_type is Sequence, whereas " "the entity_type of this object is StatMixClass" ) def test_status_list_attr_is_not_suitable_for_the_current_class(status_mixin_tests): """TypeError is raised if the Status.target_entity_type is not compatible.""" data = status_mixin_tests # create a new status list suitable for another class with different # entity_type new_status_list = StatusList( name="Sequence Statuses", statuses=[ Status(name="On Hold", code="OH"), Status(name="Complete", code="CMPLT"), ], target_entity_type="Sequence", ) with pytest.raises(TypeError) as cm: data["test_mixed_obj"].status_list = new_status_list assert ( str(cm.value) == "The given StatusLists' target_entity_type is Sequence, whereas " "the entity_type of this object is StatMixClass" ) def test_status_list_arg_is_suitable_for_the_super(status_mixin_tests): """It is possible to use a StatusList that is suitable for a super.""" data = status_mixin_tests # use the status list suitable for the super class # this should not raise a TypeError assert data["kwargs"]["status_list"].target_entity_type != "StatMixDerivedClass" obj = StatMixDerivedClass(**data["kwargs"]) assert obj.status_list == data["kwargs"]["status_list"] def test_status_list_attr_is_working_as_expected(status_mixin_tests): """status_list attribute is working as expected.""" data = status_mixin_tests new_suitable_list = StatusList( name="Suitable Statuses", statuses=[ Status(name="On Hold", code="OH"), Status(name="Complete", code="CMPLT"), ], target_entity_type="StatMixClass", ) # this shouldn't raise any errors data["test_mixed_obj"].status_list = new_suitable_list assert data["test_mixed_obj"].status_list == new_suitable_list def test_status_arg_set_to_none(status_mixin_tests): """first in the status_list attribute is used if the status arg is None.""" data = status_mixin_tests data["kwargs"]["status"] = None new_obj = StatMixClass(**data["kwargs"]) assert new_obj.status == new_obj.status_list[0] def test_status_attr_set_to_none(status_mixin_tests): """first in the status_list is used if status attribute is set to None.""" data = status_mixin_tests data["test_mixed_obj"].status = None assert data["test_mixed_obj"].status == data["test_mixed_obj"].status_list[0] def test_status_arg_is_not_a_status_instance_or_integer(status_mixin_tests): """TypeError is raised if status arg is not a Status or int.""" data = status_mixin_tests data["kwargs"]["status"] = "0" with pytest.raises(TypeError) as cm: StatMixClass(**data["kwargs"]) assert str(cm.value) == ( "StatMixClass.status must be an instance of stalker.models.status.Status or " "an integer showing the index of the Status object in the " "StatMixClass.status_list, not str: '0'" ) def test_status_attr_is_not_a_status_or_integer( status_mixin_tests, ): """TypeError is raised if status attribute is set to not Status nor int.""" data = status_mixin_tests with pytest.raises(TypeError) as cm: data["test_mixed_obj"].status = "a string" assert str(cm.value) == ( "StatMixClass.status must be an instance of stalker.models.status.Status " "or an integer showing the index of the Status object in the " "StatMixClass.status_list, not str: 'a string'" ) def test_status_attr_is_set_to_a_status_which_is_not_in_the_status_list( status_mixin_tests, ): """ValueError is raised if Status is not in the related StatusList.""" data = status_mixin_tests with pytest.raises(ValueError) as cm: data["test_mixed_obj"].status = data["test_status8"] assert ( str(cm.value) == "The given Status instance for StatMixClass.status is not in " "the StatMixClass.status_list, please supply a status from " "that list." ) def test_status_arg_is_working_as_expected_with_status_instances( status_mixin_tests, ): """status attribute value is set correctly with Status arg value.""" data = status_mixin_tests test_value = data["kwargs"]["status_list"][1] data["kwargs"]["status"] = test_value new_obj = StatMixClass(**data["kwargs"]) assert new_obj.status == test_value def test_status_attr_is_working_as_expected_with_status_instances( status_mixin_tests, ): """status attribute is working as expected with Status instances.""" data = status_mixin_tests test_value = data["test_mixed_obj"].status_list[1] data["test_mixed_obj"].status = test_value assert data["test_mixed_obj"].status == test_value def test_status_arg_is_working_as_expected_with_integers(status_mixin_tests): """status attribute value is set correctly with int arg value.""" data = status_mixin_tests data["kwargs"]["status"] = 1 test_value = data["kwargs"]["status_list"][1] new_obj = StatMixClass(**data["kwargs"]) assert new_obj.status == test_value def test_status_attr_is_working_as_expected_with_integers(status_mixin_tests): """status attribute is working as expected with integers.""" data = status_mixin_tests test_value = 1 data["test_mixed_obj"].status = test_value assert ( data["test_mixed_obj"].status == data["test_mixed_obj"].status_list[test_value] ) def test_status_arg_is_an_integer_but_out_of_range(status_mixin_tests): """ValueError is raised if the status argument is out of range.""" data = status_mixin_tests data["kwargs"]["status"] = 10 with pytest.raises(ValueError) as cm: StatMixClass(**data["kwargs"]) assert ( str(cm.value) == "StatMixClass.status cannot be bigger than the length of the " "status_list" ) def test_status_attr_set_to_an_integer_but_out_of_range(status_mixin_tests): """ValueError is raised if the status attribute is set to out of range int.""" data = status_mixin_tests with pytest.raises(ValueError) as cm: data["test_mixed_obj"].status = 10 assert ( str(cm.value) == "StatMixClass.status cannot be bigger than the length of the " "status_list" ) def test_status_arg_is_a_negative_integer(status_mixin_tests): """ValueError will be raised if the status argument is a negative int.""" data = status_mixin_tests data["kwargs"]["status"] = -10 with pytest.raises(ValueError) as cm: StatMixClass(**data["kwargs"]) assert str(cm.value) == "StatMixClass.status must be a non-negative integer" def test_status_attr_set_to_an_negative_integer(status_mixin_tests): """ValueError is raised if the status attribute is set to a negative int.""" data = status_mixin_tests with pytest.raises(ValueError) as cm: data["test_mixed_obj"].status = -10 assert str(cm.value) == "StatMixClass.status must be a non-negative integer" class StatusListAutoAddClass(SimpleEntity, StatusMixin): """A class derived from stalker.core.models.SimpleEntity for testing purposes.""" __tablename__ = "StatusListAutoAddClass" __mapper_args__ = {"polymorphic_identity": "StatusListAutoAddClass"} statusListAutoAddClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(SimpleEntity, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) class StatusListAutoAddDerivedClass(StatusListAutoAddClass): """A class derived from StatusListAutoAddClass for testing purposes.""" __tablename__ = "StatusListAutoAddDerivedClass" __mapper_args__ = {"polymorphic_identity": "StatusListAutoAddDerivedClass"} statusListAutoAddClass_id: Mapped[int] = mapped_column( "id", ForeignKey("StatusListAutoAddClass.id"), primary_key=True ) class StatusListNoAutoAddClass(SimpleEntity, StatusMixin): """A class derived from stalker.core.models.SimpleEntity for testing purposes.""" __tablename__ = "StatusListNoAutoAddClass" __mapper_args__ = {"polymorphic_identity": "StatusListNoAutoAddClass"} statusListNoAutoAddClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(SimpleEntity, self).__init__(**kwargs) StatusMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_status_mixin_db_tests(setup_postgresql_db): """Set up tests for the StatusMixin with a DB. Returns: dict: Test data. """ data = setup_postgresql_db data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") data["test_status4"] = Status(name="Status4", code="STS4") data["test_status5"] = Status(name="Status5", code="STS5") # statuses which are not going to be used data["test_status6"] = Status(name="Status6", code="STS6") data["test_status7"] = Status(name="Status7", code="STS7") data["test_status8"] = Status(name="Status8", code="STS8") # a test StatusList object data["test_status_list1"] = StatusList( name="Test Status List 1", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="StatMixClass", ) # another test StatusList object data["test_status_list2"] = StatusList( name="Test Status List 2", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="StatMixClass", ) data["kwargs"] = { "name": "Test Class", "status_list": data["test_status_list1"], "status": data["test_status_list1"].statuses[0], } data["test_mixed_obj"] = StatMixClass(**data["kwargs"]) data["test_mixed_obj"].status_list = data["test_status_list1"] # create another one without status_list set to something data["test_mixed_obj2"] = StatMixClass(**data["kwargs"]) return data def test_status_list_arg_is_skipped_and_there_is_a_db_setup( setup_status_mixin_db_tests, ): """no error raised, status_list is filled with StatusList instance, with db.""" # create a StatusList for StatusListAutoAddClass test_status_list = StatusList( name="StatusListAutoAddClass Statuses", statuses=[ Status(name="Status1", code="Sts1"), Status(name="Status2", code="Sts2"), Status(name="Status3", code="Sts3"), ], target_entity_type=StatusListAutoAddClass, ) # add it to the db DBSession.add(test_status_list) DBSession.commit() # now try to create a StatusListAutoAddClass without a status_list # argument test_status_list_auto_add_class = StatusListAutoAddClass( name="Test StatusListAutoAddClass", ) # now check if the status_list is equal to test_status_list assert test_status_list_auto_add_class.status_list == test_status_list def test_status_list_arg_is_skipped_and_there_is_a_db_setup_but_no_suitable_status_list( setup_status_mixin_db_tests, ): """TypeError is raised no suitable StatusList in the database.""" # create a StatusList for StatusListAutoAddClass test_status_list = StatusList( name="StatusListAutoAddClass Statuses", statuses=[ Status(name="Status1", code="Sts1"), Status(name="Status2", code="Sts2"), Status(name="Status3", code="Sts3"), ], target_entity_type=StatusListAutoAddClass, ) # add it to the db DBSession.add(test_status_list) DBSession.commit() # now try to create a StatusListAutoAddClass without a status_list # argument with pytest.raises(TypeError) as cm: StatusListNoAutoAddClass(name="Test StatusListNoAutoAddClass") assert ( str(cm.value) == "StatusListNoAutoAddClass instances cannot be initialized " "without a stalker.models.status.StatusList instance, please " "pass a suitable StatusList " "(StatusList.target_entity_type=StatusListNoAutoAddClass) with " "the 'status_list' argument" ) def test_status_list_arg_is_none(setup_status_mixin_db_tests): """TypeError is raised if trying to initialize status_list with None.""" data = setup_status_mixin_db_tests data["kwargs"]["status_list"] = None with pytest.raises(TypeError) as cm: StatMixClass(**data["kwargs"]) assert ( str(cm.value) == "StatMixClass instances cannot be initialized without a " "stalker.models.status.StatusList instance, please pass a " "suitable StatusList " "(StatusList.target_entity_type=StatMixClass) with the " "'status_list' argument" ) def test_status_list_arg_skipped(setup_status_mixin_db_tests): """TypeError is raised if status_list argument is skipped.""" data = setup_status_mixin_db_tests data["kwargs"].pop("status_list") with pytest.raises(TypeError) as cm: StatMixClass(**data["kwargs"]) assert ( str(cm.value) == "StatMixClass instances cannot be initialized without a " "stalker.models.status.StatusList instance, please pass a " "suitable StatusList " "(StatusList.target_entity_type=StatMixClass) with the " "'status_list' argument" ) def test_status_list_attr_set_to_none(setup_status_mixin_db_tests): """TypeError is raised if trying to set the status_list to None.""" data = setup_status_mixin_db_tests with pytest.raises(TypeError) as cm: data["test_mixed_obj"].status_list = None assert ( str(cm.value) == "StatMixClass instances cannot be initialized without a " "stalker.models.status.StatusList instance, please pass a " "suitable StatusList " "(StatusList.target_entity_type=StatMixClass) with the " "'status_list' argument" ) def test_status_list_is_found_automatically_for_derived_class( setup_status_mixin_db_tests, ): """StatusList can be found automatically for StatusListAutoAddDerivedClass.""" data = setup_status_mixin_db_tests status_list = StatusList( name="Test Status List", target_entity_type="StatusListAutoAddClass", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], ) DBSession.save(status_list) assert status_list.target_entity_type == "StatusListAutoAddClass" assert StatusListAutoAddClass in StatusListAutoAddDerivedClass.__mro__ test_obj = StatusListAutoAddDerivedClass() assert test_obj.status_list == status_list ================================================ FILE: tests/mixins/test_target_entity_type_mixin.py ================================================ # -*- coding: utf-8 -*- """TargetEntityTypeMixin related tests.""" import sys import pytest from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import Project, SimpleEntity from stalker.models.mixins import TargetEntityTypeMixin class TestClass(object): """A simple class for testing purposes.""" pass class TargetEntityTypeMixedClass(SimpleEntity, TargetEntityTypeMixin): """A simple class for TargetEntityTypeMixin tests.""" __tablename__ = "TarEntMixClasses" __mapper_args__ = {"polymorphic_identity": "TarEntMixClass"} tarMixClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) def __init__(self, **kwargs): super(TargetEntityTypeMixedClass, self).__init__(**kwargs) TargetEntityTypeMixin.__init__(self, **kwargs) @pytest.fixture(scope="function") def setup_target_entity_mixin_tests(): """Set up tests for the TargetEntityMixin. Returns: dict: Test data. """ data = dict() data["kwargs"] = {"name": "Test object", "target_entity_type": Project} data["test_object"] = TargetEntityTypeMixedClass(**data["kwargs"]) return data def test_target_entity_type_argument_is_skipped(setup_target_entity_mixin_tests): """TypeError is raised if target_entity_type argument is skipped.""" data = setup_target_entity_mixin_tests data["kwargs"].pop("target_entity_type") with pytest.raises(TypeError) as cm: TargetEntityTypeMixedClass(**data["kwargs"]) assert ( str(cm.value) == "TargetEntityTypeMixedClass.target_entity_type cannot be None" ) def test_target_entity_type_argument_being_empty_string( setup_target_entity_mixin_tests, ): """ValueError is raised if the target_entity_type argument is given as None.""" data = setup_target_entity_mixin_tests data["kwargs"]["target_entity_type"] = "" with pytest.raises(ValueError) as cm: TargetEntityTypeMixedClass(**data["kwargs"]) assert ( str(cm.value) == "TargetEntityTypeMixedClass.target_entity_type cannot be empty" ) def test_target_entity_type_argument_being_none(setup_target_entity_mixin_tests): """TypeError is raised if target_entity_type argument is given as None.""" data = setup_target_entity_mixin_tests data["kwargs"]["target_entity_type"] = None with pytest.raises(TypeError) as cm: TargetEntityTypeMixedClass(**data["kwargs"]) assert ( str(cm.value) == "TargetEntityTypeMixedClass.target_entity_type cannot be None" ) def test_target_entity_type_attribute_is_read_only(setup_target_entity_mixin_tests): """target_entity_type argument is read-only.""" data = setup_target_entity_mixin_tests # try to set the target_entity_type attribute and expect AttributeError with pytest.raises(AttributeError) as cm: data["test_object"].target_entity_type = "Project" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'TargetEntityTypeMixedClass' object has no setter", 12: "property of 'TargetEntityTypeMixedClass' object has no setter", }.get( sys.version_info.minor, "property '_target_entity_type_getter' of 'TargetEntityTypeMixedClass' " "object has no setter", ) assert str(cm.value) == error_message def test_target_entity_type_argument_accepts_classes(setup_target_entity_mixin_tests): """target_entity_type argument accepts classes.""" data = setup_target_entity_mixin_tests data["kwargs"]["target_entity_type"] = TestClass new_object = TargetEntityTypeMixedClass(**data["kwargs"]) assert new_object.target_entity_type == "TestClass" ================================================ FILE: tests/mixins/test_unit_mixin.py ================================================ # -*- coding: utf-8 -*- """UnitMixin class related tests.""" import pytest from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column from stalker import SimpleEntity, UnitMixin class UnitMixinFooMixedInClass(SimpleEntity, UnitMixin): """A class which derives from another which has and __init__ already.""" __tablename__ = "UnitMixinFooMixedInClasses" __mapper_args__ = {"polymorphic_identity": "UnitMixinFooMixedInClass"} unitMixinFooMixedInClass_id: Mapped[int] = mapped_column( "id", ForeignKey("SimpleEntities.id"), primary_key=True ) __id_column__ = "unitMixinFooMixedInClass_id" def __init__(self, **kwargs): super(UnitMixinFooMixedInClass, self).__init__(**kwargs) UnitMixin.__init__(self, **kwargs) def test_mixed_in_class_initialization(): """init is working as expected.""" a = UnitMixinFooMixedInClass(unit="TRY") assert isinstance(a, UnitMixinFooMixedInClass) assert a.unit == "TRY" def test_unit_argument_is_skipped(): """unit attribute is an empty string if the unit argument is skipped.""" g = UnitMixinFooMixedInClass() assert g.unit == "" def test_unit_argument_is_none(): """unit attribute will be an empty string if the unit argument is None.""" g = UnitMixinFooMixedInClass(unit=None) assert g.unit == "" def test_unit_attribute_is_set_to_none(): """unit attribute will be an empty string if it is set to None.""" g = UnitMixinFooMixedInClass(unit="TRY") assert g.unit != "" g.unit = None assert g.unit == "" def test_unit_argument_is_not_a_string(): """TypeError is raised if the unit argument is not a str.""" with pytest.raises(TypeError) as cm: UnitMixinFooMixedInClass(unit=1234) assert str(cm.value) == ( "UnitMixinFooMixedInClass.unit should be a string, not int: '1234'" ) def test_unit_attribute_is_not_a_string(): """TypeError is raised if the unit attribute is set to non-str.""" g = UnitMixinFooMixedInClass(unit="TRY") with pytest.raises(TypeError) as cm: g.unit = 2342 assert str(cm.value) == ( "UnitMixinFooMixedInClass.unit should be a string, not int: '2342'" ) def test_unit_argument_is_working_as_expected(): """unit arg value is passed to the unit attribute.""" test_value = "this is my unit" g = UnitMixinFooMixedInClass(unit=test_value) assert g.unit == test_value def test_unit_attribute_is_working_as_expected(): """unit attribute value can be changed.""" test_value = "this is my unit" g = UnitMixinFooMixedInClass(unit="TRY") assert g.unit != test_value g.unit = test_value assert g.unit == test_value ================================================ FILE: tests/models/__init__.py ================================================ ================================================ FILE: tests/models/test_asset.py ================================================ # -*- coding: utf-8 -*- """Asset class related tests.""" import pytest from stalker import ( Asset, Entity, File, Project, Repository, Sequence, Shot, Status, StatusList, Task, Type, User, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_asset_tests(setup_postgresql_db): """Set up tests for the Asset class. Args: setup_postgresql_db: pytest.fixture. Returns: dict: Test data. """ data = dict() # users data["test_user1"] = User( name="User1", login="user1", password="12345", email="user1@user1.com" ) DBSession.add(data["test_user1"]) data["test_user2"] = User( name="User2", login="user2", password="12345", email="user2@user2.com" ) DBSession.add(data["test_user2"]) DBSession.commit() # statuses data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() # types data["commercial_project_type"] = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) DBSession.add(data["commercial_project_type"]) data["asset_type1"] = Type( name="Character", code="char", target_entity_type="Asset" ) DBSession.add(data["asset_type1"]) data["asset_type2"] = Type( name="Environment", code="env", target_entity_type="Asset" ) DBSession.add(data["asset_type2"]) data["repository_type"] = Type( name="Test Repository Type", code="testrepo", target_entity_type="Repository", ) DBSession.add(data["repository_type"]) # repository data["repository"] = Repository( name="Test Repository", code="TR", type=data["repository_type"], ) DBSession.add(data["repository"]) # project data["project1"] = Project( name="Test Project1", code="tp1", type=data["commercial_project_type"], repositories=[data["repository"]], ) DBSession.add(data["project1"]) DBSession.commit() # sequence data["seq1"] = Sequence( name="Test Sequence", code="tseq", project=data["project1"], responsible=[data["test_user1"]], ) DBSession.add(data["seq1"]) # shots data["shot1"] = Shot( code="TestSH001", project=data["project1"], sequence=data["seq1"], responsible=[data["test_user1"]], ) DBSession.add(data["shot1"]) data["shot2"] = Shot( code="TestSH002", project=data["project1"], sequence=data["seq1"], responsible=[data["test_user1"]], ) DBSession.add(data["shot2"]) data["shot3"] = Shot( code="TestSH003", project=data["project1"], sequence=data["seq1"], responsible=[data["test_user1"]], ) DBSession.add(data["shot3"]) data["shot4"] = Shot( code="TestSH004", project=data["project1"], sequence=data["seq1"], responsible=[data["test_user1"]], ) DBSession.add(data["shot4"]) data["kwargs"] = { "name": "Test Asset", "code": "ta", "description": "This is a test Asset object", "project": data["project1"], "type": data["asset_type1"], "responsible": [data["test_user1"]], } data["asset1"] = Asset(**data["kwargs"]) DBSession.add(data["asset1"]) # tasks data["task1"] = Task( name="Task1", parent=data["asset1"], ) DBSession.add(data["task1"]) data["task2"] = Task( name="Task2", parent=data["asset1"], ) DBSession.add(data["task2"]) data["task3"] = Task( name="Task3", parent=data["asset1"], ) DBSession.add(data["task3"]) DBSession.commit() return data def test_auto_name_class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Asset class.""" assert Asset.__auto_name__ is False def test_name_cannot_be_set_to_none(setup_asset_tests): """name arg cannot be set to None.""" data = setup_asset_tests data["kwargs"]["name"] = None with pytest.raises(TypeError) as cm: _ = Asset(**data["kwargs"]) assert str(cm.value) == "Asset.name cannot be None" def test_name_cannot_be_set_to_empty_string(setup_asset_tests): """name arg cannot be set to None.""" data = setup_asset_tests data["kwargs"]["name"] = "" with pytest.raises(ValueError) as cm: _ = Asset(**data["kwargs"]) assert str(cm.value) == "Asset.name cannot be an empty string" def test_equality(setup_asset_tests): """Equality of two Asset objects.""" data = setup_asset_tests new_asset1 = Asset(**data["kwargs"]) new_asset2 = Asset(**data["kwargs"]) new_entity1 = Entity(**data["kwargs"]) data["kwargs"]["type"] = data["asset_type2"] new_asset3 = Asset(**data["kwargs"]) data["kwargs"]["name"] = "another name" new_asset4 = Asset(**data["kwargs"]) assert new_asset1 == new_asset2 assert not new_asset1 == new_asset3 assert not new_asset1 == new_asset4 assert not new_asset3 == new_asset4 assert not new_asset1 == new_entity1 def test_inequality(setup_asset_tests): """Inequality of two Asset objects.""" data = setup_asset_tests new_asset1 = Asset(**data["kwargs"]) new_asset2 = Asset(**data["kwargs"]) new_entity1 = Entity(**data["kwargs"]) data["kwargs"]["type"] = data["asset_type2"] new_asset3 = Asset(**data["kwargs"]) data["kwargs"]["name"] = "another name" new_asset4 = Asset(**data["kwargs"]) assert not new_asset1 != new_asset2 assert new_asset1 != new_asset3 assert new_asset1 != new_asset4 assert new_asset3 != new_asset4 assert new_asset1 != new_entity1 def test_reference_mixin_initialization(setup_asset_tests): """ReferenceMixin part is initialized correctly.""" data = setup_asset_tests file_type_1 = Type(name="Image", code="image", target_entity_type="File") file1 = File( name="Artwork 1", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="a.jpg", type=file_type_1, ) file2 = File( name="Artwork 2", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="b.jbg", type=file_type_1, ) references = [file1, file2] data["kwargs"]["code"] = "SH12314" data["kwargs"]["references"] = references new_asset = Asset(**data["kwargs"]) assert new_asset.references == references def test_status_mixin_initialization(setup_asset_tests): """StatusMixin part is initialized correctly.""" data = setup_asset_tests status_list = StatusList.query.filter_by(target_entity_type="Task").first() data["kwargs"]["code"] = "SH12314" data["kwargs"]["status"] = 0 data["kwargs"]["status_list"] = status_list new_asset = Asset(**data["kwargs"]) assert new_asset.status_list == status_list def test_task_mixin_initialization(setup_asset_tests): """TaskMixin part is initialized correctly.""" data = setup_asset_tests commercial_project_type = Type( name="Commercial", code="comm", target_entity_type="Project", ) new_project = Project( name="Commercial", code="COM", type=commercial_project_type, repository=data["repository"], ) character_asset_type = Type( name="Character", code="char", target_entity_type="Asset" ) new_asset = Asset( name="test asset", type=character_asset_type, code="tstasset", project=new_project, responsible=[data["test_user1"]], ) task1 = Task(name="Modeling", parent=new_asset) task2 = Task(name="Lighting", parent=new_asset) tasks = [task1, task2] assert sorted(new_asset.tasks, key=lambda x: x.name) == sorted( tasks, key=lambda x: x.name ) def test_plural_class_name(setup_asset_tests): """Default plural name of the Asset class.""" data = setup_asset_tests assert data["asset1"].plural_class_name == "Assets" def test_strictly_typed_is_true(): """__strictly_typed__ class attribute is True.""" assert Asset.__strictly_typed__ is True def test_hash_value(setup_asset_tests): """__hash__ returns the hash of the Asset instance.""" data = setup_asset_tests result = hash(data["asset1"]) assert isinstance(result, int) def test_template_variables_for_asset_related_task(setup_asset_tests): """_template_variables() for an asset related task returns correct data.""" data = setup_asset_tests assert data["task2"]._template_variables() == { "asset": data["asset1"], "parent_tasks": [data["asset1"], data["task2"]], "project": data["project1"], "scene": None, "sequence": None, "shot": None, "task": data["task2"], "type": None, } def test_template_variables_for_asset_itself(setup_asset_tests): """_template_variables() for an asset itself returns correct data.""" data = setup_asset_tests assert data["asset1"]._template_variables() == { "asset": data["asset1"], "parent_tasks": [data["asset1"]], "project": data["project1"], "scene": None, "sequence": None, "shot": None, "task": data["asset1"], "type": data["asset_type1"], } def test_assets_can_use_task_status_list(): """It is possible to use TaskStatus lists with Assets.""" # users test_user1 = User( name="User1", login="user1", password="12345", email="user1@user1.com" ) # statuses status_wip = Status(code="WIP", name="Work In Progress") status_cmpl = Status(code="CMPL", name="Complete") # Just create a StatusList for Tasks task_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Task" ) project_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Project" ) # types commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) asset_type1 = Type(name="Character", code="char", target_entity_type="Asset") # project project1 = Project( name="Test Project1", code="tp1", type=commercial_project_type, status_list=project_status_list, ) # this should now be possible test_asset = Asset( name="Test Asset", code="ta", description="This is a test Asset object", project=project1, type=asset_type1, status_list=task_status_list, status=status_wip, responsible=[test_user1], ) assert test_asset.status_list == task_status_list ================================================ FILE: tests/models/test_authentication_log.py ================================================ # -*- coding: utf-8 -*- """AuthenticationLog class related tests.""" import datetime import pytest import pytz from stalker import AuthenticationLog, User from stalker.models.auth import LOGIN, LOGOUT @pytest.fixture(scope="function") def setup_authentication_log_tests(): """Set up tests for the AuthenticationLog class. Returns: dict: Test data. """ data = dict() data["test_user1"] = User( name="Test User 1", login="tuser1", email="tuser1@users.com", password="secret", ) data["test_user2"] = User( name="Test User 2", login="tuser2", email="tuser2@users.com", password="secret", ) return data def test_user_argument_is_skipped(setup_authentication_log_tests): """TypeError is raised if the user arg is skipped.""" with pytest.raises(TypeError) as cm: AuthenticationLog(action=LOGIN, date=datetime.datetime.now(pytz.utc)) assert str(cm.value) == ( "AuthenticationLog.user should be a User instance, not NoneType: 'None'" ) def test_user_argument_is_none(setup_authentication_log_tests): """TypeError is raised if the user arg is None.""" with pytest.raises(TypeError) as cm: AuthenticationLog(user=None, action=LOGIN, date=datetime.datetime.now(pytz.utc)) assert str(cm.value) == ( "AuthenticationLog.user should be a User instance, not NoneType: 'None'" ) def test_user_argument_is_not_a_user_instance(setup_authentication_log_tests): """TypeError is raised if user arg is not User.""" with pytest.raises(TypeError) as cm: AuthenticationLog( user="not a user instance", action=LOGIN, date=datetime.datetime.now(pytz.utc), ) assert str(cm.value) == ( "AuthenticationLog.user should be a User instance, " "not str: 'not a user instance'" ) def test_user_attribute_is_not_a_user_instance(setup_authentication_log_tests): """TypeError is raised if user attr is not User.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) ) with pytest.raises(TypeError) as cm: uli.user = "not a user instance" assert str(cm.value) == ( "AuthenticationLog.user should be a User instance, " "not str: 'not a user instance'" ) def test_user_argument_is_working_as_expected(setup_authentication_log_tests): """user arg value is correctly passed to the user attribute.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGOUT, date=datetime.datetime.now(pytz.utc) ) assert uli.user == data["test_user1"] def test_user_attribute_is_working_as_expected(setup_authentication_log_tests): """user attr is working as expected.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGOUT, date=datetime.datetime.now(pytz.utc) ) assert uli.user != data["test_user2"] uli.user = data["test_user2"] assert uli.user == data["test_user2"] def test_action_argument_is_skipped(setup_authentication_log_tests): """action attr is "login" if the action argument is skipped.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], date=datetime.datetime.now(pytz.utc) ) assert uli.action == LOGIN def test_action_argument_is_none(setup_authentication_log_tests): """action attr is "login" when action arg is None.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=None, date=datetime.datetime.now(pytz.utc) ) assert uli.action == LOGIN def test_action_argument_value_is_not_login_or_logout(setup_authentication_log_tests): """ValueError is raised if the action attr is not one of "login" or "login".""" data = setup_authentication_log_tests with pytest.raises(ValueError) as cm: AuthenticationLog( user=data["test_user1"], action="not login", date=datetime.datetime.now(pytz.utc), ) assert ( str(cm.value) == 'AuthenticationLog.action should be one of "login" or "logout", ' 'not "not login"' ) def test_action_attribute_value_is_not_login_or_logout(setup_authentication_log_tests): """ValueError is raised if the action attr is not LOGIN/LOGOUT.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) ) with pytest.raises(ValueError) as cm: uli.action = "not login" assert ( str(cm.value) == 'AuthenticationLog.action should be one of "login" or "logout", ' 'not "not login"' ) def test_action_argument_is_working_as_expected(setup_authentication_log_tests): """action arg value is passed to the action attr.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) ) assert uli.action == LOGIN uli = AuthenticationLog( user=data["test_user1"], action=LOGOUT, date=datetime.datetime.now(pytz.utc) ) assert uli.action == LOGOUT def test_action_attribute_is_working_as_expected(setup_authentication_log_tests): """action attr is working as expected.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) ) assert uli.action != LOGOUT uli.action = LOGOUT assert uli.action == LOGOUT def test_date_argument_is_skipped(setup_authentication_log_tests): """date attr datetime.datetime.now(pytz.utc) if date arg is skipped.""" data = setup_authentication_log_tests uli = AuthenticationLog(user=data["test_user1"], action=LOGIN) diff = datetime.datetime.now(pytz.utc) - uli.date assert diff.microseconds < 5000 def test_date_argument_is_none(setup_authentication_log_tests): """date attr datetime.datetime.now(pytz.utc) if date argument is None.""" data = setup_authentication_log_tests uli = AuthenticationLog(user=data["test_user1"], action=LOGIN, date=None) diff = datetime.datetime.now(pytz.utc) - uli.date assert diff < datetime.timedelta(seconds=1) def test_date_attribute_is_none(setup_authentication_log_tests): """date attr is set to datetime.datetime.now(pytz.utc) if is set to None.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) - datetime.timedelta(days=10), ) diff = datetime.datetime.now(pytz.utc) - uli.date one_second = datetime.timedelta(seconds=1) assert diff > one_second uli.date = None diff = datetime.datetime.now(pytz.utc) - uli.date assert diff < one_second def test_date_argument_is_not_a_datetime_instance(setup_authentication_log_tests): """TypeError is raised if date argument is not a datetime.datetime instance.""" data = setup_authentication_log_tests with pytest.raises(TypeError) as cm: AuthenticationLog( user=data["test_user1"], action=LOGIN, date="not a datetime instance" ) assert str(cm.value) == ( "AuthenticationLog.date should be a datetime.datetime instance, " "not str: 'not a datetime instance'" ) def test_date_attribute_is_not_a_datetime_instance(setup_authentication_log_tests): """TypeError is raised if date attr is not datetime.datetime instance.""" data = setup_authentication_log_tests uli = AuthenticationLog( user=data["test_user1"], action=LOGIN, date=datetime.datetime.now(pytz.utc) ) with pytest.raises(TypeError) as cm: uli.date = "not a datetime instance" assert str(cm.value) == ( "AuthenticationLog.date should be a datetime.datetime instance, " "not str: 'not a datetime instance'" ) def test_date_argument_is_working_as_expected(setup_authentication_log_tests): """date argument value is passed to the date attribute.""" data = setup_authentication_log_tests date = datetime.datetime(2016, 11, 14, 16, 30, tzinfo=pytz.utc) uli = AuthenticationLog(user=data["test_user1"], action=LOGIN, date=date) assert uli.date == date def test_date_attribute_is_working_as_expected(setup_authentication_log_tests): """date attribute value can be changed.""" data = setup_authentication_log_tests date1 = datetime.datetime(2016, 11, 4, 6, 30, tzinfo=pytz.utc) date2 = datetime.datetime(2016, 11, 14, 16, 30, tzinfo=pytz.utc) uli = AuthenticationLog(user=data["test_user1"], action=LOGIN, date=date1) assert uli.date != date2 uli.date = date2 assert uli.date == date2 def test_date_argument_is_working_as_expected2(setup_authentication_log_tests): """date argument value is passed to the date attribute.""" data = setup_authentication_log_tests date1 = datetime.datetime(2016, 11, 4, 6, 30, tzinfo=pytz.utc) uli = AuthenticationLog(user=data["test_user1"], action=LOGIN, date=date1) assert uli.date == date1 def test_authentication_log_is_orderable_for_some_reason( setup_authentication_log_tests, ): """AuthenticationLog instances are orderable.""" data = setup_authentication_log_tests date1 = datetime.datetime(2024, 12, 10, 10, 0) date2 = datetime.datetime(2024, 12, 10, 17, 0) auth_log1 = AuthenticationLog(user=data["test_user1"], action=LOGIN, date=date1) auth_log2 = AuthenticationLog(user=data["test_user1"], action=LOGOUT, date=date2) assert (auth_log1 < auth_log2) is True assert (auth_log1 > auth_log2) is False ================================================ FILE: tests/models/test_budget.py ================================================ # -*- coding: utf-8 -*- """Budget class tests.""" import pytest from stalker import ( Budget, BudgetEntry, Good, Project, Repository, Status, StatusList, Type, User, ) @pytest.fixture(scope="function") def setup_budget_test_base(): """Set up the tests for the Budget class. Returns: dict: Test data. """ data = dict() data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["status_new"] = Status(name="New", code="NEW") data["status_app"] = Status(name="Approved", code="APP") data["budget_status_list"] = StatusList( name="Budget Statuses", target_entity_type="Budget", statuses=[data["status_new"], data["status_prev"], data["status_app"]], ) data["task_status_list"] = StatusList( statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_cmpl"], ], target_entity_type="Task", ) data["test_project_status_list"] = StatusList( name="Project Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type="Project", ) data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234" ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234" ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234" ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234" ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234" ) data["test_project"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], status_list=data["test_project_status_list"], repository=data["test_repository"], ) data["kwargs"] = { "project": data["test_project"], "name": "Test Budget 1", "status_list": data["budget_status_list"], } data["test_budget"] = Budget(**data["kwargs"]) data["test_good"] = Good(name="Some Good", cost=100, msrp=120, unit="$") return data def test_entries_attribute_is_set_to_a_list_of_other_instances_than_a_budget_entry( setup_budget_test_base, ): """TypeError is raised if the entries attribute is not a list of BudgetEntries.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: data["test_budget"].entries = ["some", "string", 1, 2] assert str(cm.value) == ( "Budget.entries should only contain instances of BudgetEntry, not str: 'some'" ) def test_entries_attribute_is_working_as_expected(setup_budget_test_base): """Entries attribute is working as expected.""" data = setup_budget_test_base some_other_budget = Budget( name="Test Budget", project=data["test_project"], status_list=data["budget_status_list"], ) entry1 = BudgetEntry( budget=some_other_budget, good=data["test_good"], ) entry2 = BudgetEntry( budget=some_other_budget, good=data["test_good"], ) data["test_budget"].entries = [entry1, entry2] assert data["test_budget"].entries == [entry1, entry2] def test_statuses_is_working_as_expected(setup_budget_test_base): """Budget accepts statuses.""" data = setup_budget_test_base data["test_budget"].status = data["status_new"] assert data["test_budget"].status == data["status_new"] data["test_budget"].status = data["status_prev"] assert data["test_budget"].status == data["status_prev"] data["test_budget"].status = data["status_app"] assert data["test_budget"].status == data["status_app"] def test_budget_argument_is_skipped(setup_budget_test_base): """TypeError is raised if the budget argument is skipped.""" with pytest.raises(TypeError) as cm: BudgetEntry(amount=10.0) assert str(cm.value) == ( "BudgetEntry.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_argument_is_none(setup_budget_test_base): """TypeError is raised if the budget argument is None.""" with pytest.raises(TypeError) as cm: BudgetEntry(budget=None, amount=10.0) assert str(cm.value) == ( "BudgetEntry.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_attribute_is_set_to_none(setup_budget_test_base): """TypeError is raised if the budget attribute is set to None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) with pytest.raises(TypeError) as cm: entry.budget = None assert str(cm.value) == ( "BudgetEntry.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_argument_is_not_a_budget_instance(setup_budget_test_base): """TypeError is raised if the budget argument is not a Budget instance.""" with pytest.raises(TypeError) as cm: BudgetEntry(budget="not a budget", amount=10.0) assert str(cm.value) == ( "BudgetEntry.budget should be a Budget instance, not str: 'not a budget'" ) def test_budget_attribute_is_not_a_budget_instance(setup_budget_test_base): """TypeError is raised if the budget attribute is not a Budget instance.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10.0) with pytest.raises(TypeError) as cm: entry.budget = "not a budget instance" assert str(cm.value) == ( "BudgetEntry.budget should be a Budget instance, " "not str: 'not a budget instance'" ) def test_budget_argument_is_working_as_expected(setup_budget_test_base): """If the budget argument value is correctly passed to the budget attribute.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10.0) assert entry.budget == data["test_budget"] def test_budget_attribute_is_working_as_expected(setup_budget_test_base): """If the budget attribute value can correctly be changed.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10.0) new_budget = Budget( name="Test Budget", project=data["test_project"], status_list=data["budget_status_list"], ) assert entry.budget != new_budget entry.budget = new_budget assert entry.budget == new_budget def test_cost_attribute_value_will_be_copied_from_the_supplied_good_argument( setup_budget_test_base, ): """Cost attribute is copied from the good argument.""" data = setup_budget_test_base good = Good(name="Some Good", cost=10, msrp=20, unit="$/hour") entry = BudgetEntry(budget=data["test_budget"], good=good) assert entry.cost == good.cost def test_cost_attribute_is_set_to_none(setup_budget_test_base): """If the cost attribute is set to 0 if it is set to None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) assert entry.cost == data["test_good"].cost entry.cost = None assert entry.cost == 0.0 def test_cost_attribute_is_not_a_number(setup_budget_test_base): """TypeError is raised if cost attribute is not a number.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) with pytest.raises(TypeError) as cm: entry.cost = "some string" assert str(cm.value) == ( "BudgetEntry.cost should be a number, not str: 'some string'" ) def test_cost_attribute_is_working_as_expected(setup_budget_test_base): """If the cost attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) test_value = 5.0 assert entry.cost != test_value entry.cost = test_value assert entry.cost == test_value def test_msrp_attribute_is_set_to_none(setup_budget_test_base): """Msrp attribute is 0 if it is set to None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) assert entry.msrp == data["test_good"].msrp entry.msrp = None assert entry.msrp == 0.0 def test_msrp_attribute_is_not_a_number(setup_budget_test_base): """TypeError is raised if msrp attribute is not a number.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) with pytest.raises(TypeError) as cm: entry.msrp = "some string" assert str(cm.value) == ( "BudgetEntry.msrp should be a number, not str: 'some string'" ) def test_msrp_attribute_is_working_as_expected(setup_budget_test_base): """Msrp attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) test_value = 5.0 assert entry.msrp != test_value entry.msrp = test_value assert entry.msrp == test_value def test_msrp_attribute_value_will_be_copied_from_the_supplied_good_argument( setup_budget_test_base, ): """Msrp attribute value is copied from the supplied good argument value.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) assert entry.msrp == data["test_good"].msrp def test_price_argument_is_skipped(setup_budget_test_base): """Price attribute is 0 if the price argument is skipped.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) assert entry.price == 0.0 def test_price_argument_is_set_to_none(setup_budget_test_base): """Price attribute is set to 0 if the price argument is set to None.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], price=None) assert entry.price == 0.0 def test_price_attribute_is_set_to_none(setup_budget_test_base): """Price attribute is set to 0 if price attribute is set to None.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], price=10.0) assert entry.price == 10.0 entry.price = None assert entry.price == 0.0 def test_price_argument_is_not_a_number(setup_budget_test_base): """TypeError is raised if the price arg is not a number.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: BudgetEntry( budget=data["test_budget"], good=data["test_good"], price="some string" ) assert str(cm.value) == ( "BudgetEntry.price should be a number, not str: 'some string'" ) def test_price_attribute_is_not_a_number(setup_budget_test_base): """TypeError is raised if price attribute is not a number.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], price=10) with pytest.raises(TypeError) as cm: entry.price = "some string" assert str(cm.value) == ( "BudgetEntry.price should be a number, not str: 'some string'" ) def test_price_argument_is_working_as_expected(setup_budget_test_base): """Price arg value is passed to the price attribute.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], price=10) assert entry.price == 10.0 def test_price_attribute_is_working_as_expected(setup_budget_test_base): """Price attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], price=10) test_value = 5.0 assert entry.price != test_value entry.price = test_value assert entry.price == test_value def test_realized_total_argument_is_skipped(setup_budget_test_base): """Realized_total attribute is 0 if the realized_total arg is skipped.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) assert entry.realized_total == 0.0 def test_realized_total_argument_is_set_to_none(setup_budget_test_base): """Realized_total attribute is set to 0 if realized_total arg is None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total=None ) assert entry.realized_total == 0.0 def test_realized_total_attribute_is_set_to_none(setup_budget_test_base): """Realized_total attribute is set to 0 if it is set to None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total=10.0 ) assert entry.realized_total == 10.0 entry.realized_total = None assert entry.realized_total == 0.0 def test_realized_total_argument_is_not_a_number(setup_budget_test_base): """TypeError is raised if the realized_total arg not a number.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total="some string", ) assert str(cm.value) == ( "BudgetEntry.realized_total should be a number, not str: 'some string'" ) def test_realized_total_attribute_is_not_a_number(setup_budget_test_base): """TypeError is raised if realized_total attribute is not a number.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total=10 ) with pytest.raises(TypeError) as cm: entry.realized_total = "some string" assert str(cm.value) == ( "BudgetEntry.realized_total should be a number, not str: 'some string'" ) def test_realized_total_argument_is_working_as_expected(setup_budget_test_base): """Realized_total arg value is passed to the realized_total attribute.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total=10 ) assert entry.realized_total == 10.0 def test_realized_total_attribute_is_working_as_expected(setup_budget_test_base): """Realized_total attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], realized_total=10 ) test_value = 5.0 assert entry.realized_total != test_value entry.realized_total = test_value assert entry.realized_total == test_value def test_unit_attribute_is_set_to_none(setup_budget_test_base): """Unit attribute is set to an empty value if it is set to None.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) assert entry.unit == data["test_good"].unit entry.unit = None assert entry.unit == "" def test_unit_attribute_is_not_a_string(setup_budget_test_base): """TypeError is raised if the unit attribute is not a str.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) with pytest.raises(TypeError) as cm: entry.unit = 100.212 assert str(cm.value) == ( "BudgetEntry.unit should be a string, not float: '100.212'" ) def test_unit_attribute_is_working_as_expected(setup_budget_test_base): """Unit attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) test_value = "TL/hour" assert entry.unit != test_value entry.unit = test_value assert entry.unit == test_value def test_unit_attribute_value_will_be_copied_from_the_supplied_good( setup_budget_test_base, ): """Unit attribute value is copied from the good argument value.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], ) assert entry.unit == data["test_good"].unit def test_amount_argument_is_skipped(setup_budget_test_base): """Amount attribute is 0 if the amount argument is skipped.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"]) assert entry.amount == 0.0 def test_amount_argument_is_set_to_none(setup_budget_test_base): """Amount attribute is 0 if the amount argument is set to None.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=None) assert entry.amount == 0.0 def test_amount_attribute_is_set_to_none(setup_budget_test_base): """Amount attribute is set to 0 if it is set to None.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10.0) assert entry.amount == 10.0 entry.amount = None assert entry.amount == 0.0 def test_amount_argument_is_not_a_number(setup_budget_test_base): """TypeError is raised if the amount arg not a number.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: BudgetEntry( budget=data["test_budget"], good=data["test_good"], amount="some string" ) assert str(cm.value) == ( "BudgetEntry.amount should be a number, not str: 'some string'" ) def test_amount_attribute_is_not_a_number(setup_budget_test_base): """TypeError is raised if amount attribute is not a number.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10) with pytest.raises(TypeError) as cm: entry.amount = "some string" assert str(cm.value) == ( "BudgetEntry.amount should be a number, not str: 'some string'" ) def test_amount_argument_is_working_as_expected(setup_budget_test_base): """Amount argument value is correctly passed to the amount attribute.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10) assert entry.amount == 10.0 def test_amount_attribute_is_working_as_expected(setup_budget_test_base): """Amount attribute is working as expected.""" data = setup_budget_test_base entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=10) test_value = 5.0 assert entry.amount != test_value entry.amount = test_value assert entry.amount == test_value def test_good_argument_is_skipped(setup_budget_test_base): """TypeError is raised when the good argument is skipped.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: BudgetEntry(budget=data["test_budget"]) assert str(cm.value) == ( "BudgetEntry.good should be a stalker.models.budget.Good instance, " "not NoneType: 'None'" ) def test_good_argument_is_none(setup_budget_test_base): """TypeError is raised when the good argument is None.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: BudgetEntry( budget=data["test_budget"], good=None, amount=53, ) assert str(cm.value) == ( "BudgetEntry.good should be a stalker.models.budget.Good instance, " "not NoneType: 'None'" ) def test_good_attribute_is_set_to_none(setup_budget_test_base): """TypeError is raised if the good attribute is set to None.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=Good(name="Some Good"), amount=53 ) with pytest.raises(TypeError) as cm: entry.good = None assert str(cm.value) == ( "BudgetEntry.good should be a stalker.models.budget.Good instance, " "not NoneType: 'None'" ) def test_good_argument_is_not_a_good_instance(setup_budget_test_base): """TypeError is raised when the good argument is not a Good instance.""" data = setup_budget_test_base with pytest.raises(TypeError) as cm: _ = BudgetEntry( budget=data["test_budget"], good="this is not a Good instance", amount=53, ) assert str(cm.value) == ( "BudgetEntry.good should be a stalker.models.budget.Good instance, " "not str: 'this is not a Good instance'" ) def test_good_attribute_is_not_a_good_instance(setup_budget_test_base): """TypeError is raised if the good attribute is not a Good instance.""" data = setup_budget_test_base entry = BudgetEntry( budget=data["test_budget"], good=data["test_good"], amount=53, ) with pytest.raises(TypeError) as cm: entry.good = "this is not a Good instance" assert ( str(cm.value) == "BudgetEntry.good should be a stalker.models.budget.Good " "instance, not str: 'this is not a Good instance'" ) def test_good_argument_is_working_as_expected(setup_budget_test_base): """Good argument value is correctly passed to the good attribute.""" data = setup_budget_test_base test_value = Good(name="Some Good") entry = BudgetEntry( budget=data["test_budget"], good=test_value, amount=53, ) assert entry.good == test_value def test_good_attribute_is_working_as_expected(setup_budget_test_base): """Good attribute can be correctly set.""" data = setup_budget_test_base test_value = Good(name="Some Other Good") entry = BudgetEntry(budget=data["test_budget"], good=data["test_good"], amount=53) assert entry.good != test_value entry.good = test_value assert entry.good == test_value def test_parent_child_relation(setup_budget_test_base): """Parent/child relation of Budgets.""" data = setup_budget_test_base b1 = Budget(**data["kwargs"]) b2 = Budget(**data["kwargs"]) b2.parent = b1 assert b1.children == [b2] ================================================ FILE: tests/models/test_client.py ================================================ # -*- coding: utf-8 -*- """Tests Client class.""" import datetime import pytest import pytz from stalker import Client, Entity, Good, Project, Repository, Status, StatusList, User @pytest.fixture(scope="function") def setup_client_tests(): """Set up the tests for the Client class.""" data = dict() # create a couple of test users data["test_user1"] = User( name="User1", login="user1", email="user1@test.com", password="123456", ) data["test_user2"] = User( name="User2", login="user2", email="user2@test.com", password="123456", ) data["test_user3"] = User( name="User3", login="user3", email="user3@test.com", password="123456", ) data["test_user4"] = User( name="User4", login="user4", email="user4@test.com", password="123456", ) data["users_list"] = [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], ] data["test_admin"] = User( name="admin", login="admin", email="admin@admins.com", password="1234" ) data["status_new"] = Status(name="New", code="NEW") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["project_statuses"] = StatusList( name="Project Status List", statuses=[data["status_new"], data["status_wip"], data["status_cmpl"]], target_entity_type="Project", ) data["test_repo"] = Repository(name="Test Repository", code="TR") data["test_project1"] = Project( name="Test Project 1", code="proj1", status_list=data["project_statuses"], repository=data["test_repo"], ) data["test_project2"] = Project( name="Test Project 1", code="proj2", status_list=data["project_statuses"], repository=data["test_repo"], ) data["test_project3"] = Project( name="Test Project 1", code="proj3", status_list=data["project_statuses"], repository=data["test_repo"], ) data["projects_list"] = [ data["test_project1"], data["test_project2"], data["test_project3"], ] data["date_created"] = data["date_updated"] = datetime.datetime.now(pytz.utc) data["kwargs"] = { "name": "Test Client", "description": "This is a client for testing purposes", "created_by": data["test_admin"], "updated_by": data["test_admin"], "date_created": data["date_created"], "date_updated": data["date_updated"], "users": data["users_list"], "projects": data["projects_list"], } # create a default client object data["test_client"] = Client(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Department class.""" assert Client.__auto_name__ is False def test_users_argument_accepts_an_empty_list(setup_client_tests): """users argument accepts an empty list.""" data = setup_client_tests # this should work without raising any error data["kwargs"]["users"] = [] new_dep = Client(**data["kwargs"]) assert isinstance(new_dep, Client) def test_users_attribute_accepts_an_empty_list(setup_client_tests): """users attribute accepts an empty list.""" data = setup_client_tests # this should work without raising any error data["test_client"].users = [] def test_users_argument_accepts_only_a_list_of_user_objects(setup_client_tests): """users argument accepts only a list of user objects.""" data = setup_client_tests test_value = [1, 2.3, [], {}] data["kwargs"]["users"] = test_value # this should raise a TypeError with pytest.raises(TypeError) as cm: Client(**data["kwargs"]) assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not int: '1'" ) def test_users_attribute_accepts_only_a_list_of_user_objects(setup_client_tests): """users attribute accepts only a list of user objects.""" data = setup_client_tests test_value = [1, 2.3, [], {}] # this should raise a TypeError with pytest.raises(TypeError) as cm: data["test_client"].users = test_value assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not int: '1'" ) def test_users_attribute_elements_accepts_user_only_append(setup_client_tests): """TypeError is raised if users list assigned a value other than a User instance.""" data = setup_client_tests # append with pytest.raises(TypeError) as cm: data["test_client"].users.append(0) assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not int: '0'" ) def test_users_attribute_elements_accepts_user_only_setitem(setup_client_tests): """TypeError is raised if users list assigned a value other than a User instance.""" data = setup_client_tests # __setitem__ with pytest.raises(TypeError) as cm: data["test_client"].users[0] = 0 assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not int: '0'" ) def test_users_argument_is_not_iterable(setup_client_tests): """TypeError is raised if the given users argument is not a list.""" data = setup_client_tests data["kwargs"]["users"] = "a user" with pytest.raises(TypeError) as cm: Client(**data["kwargs"]) assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not str: 'a'" ) def test_users_attribute_is_not_iterable(setup_client_tests): """TypeError is raised if the users attribute is not iterable.""" data = setup_client_tests test_value = "a user" with pytest.raises(TypeError) as cm: data["test_client"].users = test_value assert str(cm.value) == ( "ClientUser.user should be an instance of stalker.models.auth.User, " "not str: 'a'" ) def test_users_attribute_defaults_to_empty_list(setup_client_tests): """users attribute defaults to an empty list if the users argument is skipped.""" data = setup_client_tests data["kwargs"].pop("users") new_client = Client(**data["kwargs"]) assert new_client.users == [] def test_users_attribute_set_to_none(setup_client_tests): """TypeError will be raised if the users attribute is set to None.""" data = setup_client_tests with pytest.raises(TypeError) as cm: data["test_client"].users = None assert str(cm.value) == "'NoneType' object is not iterable" def test_projects_argument_accepts_an_empty_list(setup_client_tests): """projects argument accepts an empty list.""" data = setup_client_tests # this should work without raising any error data["kwargs"]["projects"] = [] new_dep = Client(**data["kwargs"]) assert isinstance(new_dep, Client) def test_projects_attribute_accepts_an_empty_list(setup_client_tests): """projects attribute accepts an empty list.""" data = setup_client_tests # this should work without raising any error data["test_client"].projects = [] def test_projects_argument_accepts_only_a_list_of_project_objects(setup_client_tests): """projects argument accepts only a list of project objects.""" data = setup_client_tests test_value = [1, 2.3, [], {}] data["kwargs"]["projects"] = test_value # this should raise a TypeError with pytest.raises(TypeError) as cm: Client(**data["kwargs"]) assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not int: '1'" ) def test_projects_attribute_accepts_only_a_list_of_project_objects(setup_client_tests): """users attribute accepts only a list of project objects.""" data = setup_client_tests test_value = [1, 2.3, "a project"] # this should raise a TypeError with pytest.raises(TypeError) as cm: data["test_client"].projects = test_value assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not int: '1'" ) def test_projects_attribute_elements_accepts_project_only_append(setup_client_tests): """TypeError is raised if assigned a non Project instance to the project attr.""" data = setup_client_tests # append with pytest.raises(TypeError) as cm: data["test_client"].projects.append(0) assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not int: '0'" ) def test_projects_attribute_elements_accepts_project_only_setitem(setup_client_tests): """TypeError is raised if assigned a non Project instance to the projects attr.""" data = setup_client_tests # __setitem__ with pytest.raises(TypeError) as cm: data["test_client"].projects[0] = 0 assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not int: '0'" ) def test_projects_argument_is_not_iterable(setup_client_tests): """TypeError is raised if the given projects argument is not a list.""" data = setup_client_tests data["kwargs"]["projects"] = "a project" with pytest.raises(TypeError) as cm: Client(**data["kwargs"]) assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not str: 'a'" ) def test_projects_attribute_is_not_iterable(setup_client_tests): """TypeError is raised if the projects attr is set to a non-iterable value.""" data = setup_client_tests test_value = "a project" with pytest.raises(TypeError) as cm: data["test_client"].projects = test_value assert str(cm.value) == ( "ProjectClient.project should be a stalker.models.project.Project instance, " "not str: 'a'" ) def test_projects_attribute_defaults_to_empty_list(setup_client_tests): """projects attr defaults to an empty list if the projects argument is skipped.""" data = setup_client_tests data["kwargs"].pop("projects") new_client = Client(**data["kwargs"]) assert new_client.projects == [] def test_projects_attribute_set_to_none(setup_client_tests): """TypeError is raised if the projects attribute is set to None.""" data = setup_client_tests with pytest.raises(TypeError) as cm: data["test_client"].projects = None assert str(cm.value) == "'NoneType' object is not iterable" def test_user_remove_also_removes_client_from_user(setup_client_tests): """Removing user from the users removes the client from the users companies.""" data = setup_client_tests # check if the user is in the company assert data["test_client"] in data["test_user1"].companies # now remove the user from the company data["test_client"].users.remove(data["test_user1"]) # now check if company is not in users companies anymore assert data["test_client"] not in data["test_user1"].companies # assign the user back data["test_user1"].companies.append(data["test_client"]) # check if the user is in the companies users list assert data["test_user1"] in data["test_client"].users # def test_project_remove_also_removes_project_from_client(setup_client_tests): # """removing user from the users removes the client from the users companies.""" # data = setup_client_tests # # check if the project is registered with the client # assert data["test_client"] in data["test_project1"].clients # # # now remove the project from the client # # data["test_client"].projects.remove(data["test_project1"]) # data["test_client"].project_role.remove(data["test_client"].project_role[0]) # # # now check if project no longer belongs to client # assert data["test_project1"] not in data["test_client"].projects # # # assign the project back # data["test_client"].projects.append(data["test_project1"]) # # # check if the project is in the companies projects list # assert data["test_project1"] in data["test_client"].projects def test_equality(setup_client_tests): """equality of two Client objects.""" data = setup_client_tests client1 = Client(**data["kwargs"]) client2 = Client(**data["kwargs"]) entity_kwargs = data["kwargs"].copy() entity_kwargs.pop("users") entity_kwargs.pop("projects") entity1 = Entity(**entity_kwargs) data["kwargs"]["name"] = "Company X" client3 = Client(**data["kwargs"]) assert client1 == client2 assert not client1 == client3 assert not client1 == entity1 def test_inequality(setup_client_tests): """inequality of two Client objects.""" data = setup_client_tests client1 = Client(**data["kwargs"]) client2 = Client(**data["kwargs"]) entity_kwargs = data["kwargs"].copy() entity_kwargs.pop("users") entity_kwargs.pop("projects") entity1 = Entity(**entity_kwargs) data["kwargs"]["name"] = "Company X" client3 = Client(**data["kwargs"]) assert not client1 != client2 assert client1 != client3 assert client1 != entity1 def test_to_tjp_method_is_working_as_expected(setup_client_tests): """to_tjp method is working as expected.""" data = setup_client_tests client1 = Client(**data["kwargs"]) assert client1.to_tjp == "" def test_hash_is_correctly_calculated(setup_client_tests): """hash value is correctly calculated.""" data = setup_client_tests client1 = Client(**data["kwargs"]) assert client1.__hash__() == hash( "{}:{}:{}".format(client1.id, client1.name, client1.entity_type) ) def test_goods_attribute_is_set_to_none(setup_client_tests): """TypeError is raised if good is set to None.""" data = setup_client_tests client1 = Client(**data["kwargs"]) with pytest.raises(TypeError) as cm: client1.goods = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_goods_attribute_is_set_to_a_list_of_non_good_instances(setup_client_tests): """TypeError is raised if the goods attr is set to a list of non Good instances.""" data = setup_client_tests client1 = Client(**data["kwargs"]) with pytest.raises(TypeError) as cm: client1.goods = ["not", 1, "list", "of", "goods"] assert str(cm.value) == ( "Client.goods should only contain instances of " "stalker.models.budget.Good, not str: 'not'" ) def test_goods_attribute_is_working_as_expected(setup_client_tests): """goods attribute is working as expected.""" data = setup_client_tests client1 = Client(**data["kwargs"]) good1 = Good(name="Test Good 1") good2 = Good(name="Test Good 2") good3 = Good(name="Test Good 3") client1.goods = [good1, good2, good3] assert client1.goods == [good1, good2, good3] ================================================ FILE: tests/models/test_client_user.py ================================================ # -*- coding: utf-8 -*- """Tests for the ClientUser class.""" import pytest from stalker import Client, ClientUser, User def test_role_argument_is_not_a_role_instance(): """TypeError will be raised when the role argument is not a Role instance.""" with pytest.raises(TypeError) as cm: ClientUser( client=Client(name="Test Client"), user=User( name="Test User", login="tuser", email="u@u.com", password="secret" ), role="not a role instance", ) assert str(cm.value) == ( "ClientUser.role should be a stalker.models.auth.Role instance, " "not str: 'not a role instance'" ) ================================================ FILE: tests/models/test_daily.py ================================================ # -*- coding: utf-8 -*- """Tests for the stalker.models.review.Daily class.""" import pytest from stalker import ( Daily, DailyFile, File, Project, Repository, Status, StatusList, Task, Version, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_daily_tests(): """Set up Daily test data.""" data = dict() data["status_new"] = Status(name="Mew", code="NEW") data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["status_open"] = Status(name="Open", code="OPEN") data["status_cls"] = Status(name="Closed", code="CLS") data["daily_status_list"] = StatusList( name="Daily Statuses", statuses=[data["status_open"], data["status_cls"]], target_entity_type="Daily", ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_cmpl"], ], target_entity_type="Task", ) data["test_project_status_list"] = StatusList( name="Project Statuses", target_entity_type="Project", statuses=[data["status_new"], data["status_wip"], data["status_cmpl"]], ) data["test_repo"] = Repository(name="Test Repository", code="TR") data["test_project"] = Project( name="Test Project", code="TP", repository=data["test_repo"], status_list=data["test_project_status_list"], ) data["test_task1"] = Task( name="Test Task 1", project=data["test_project"], status_list=data["task_status_list"], ) data["test_task2"] = Task( name="Test Task 2", project=data["test_project"], status_list=data["task_status_list"], ) data["test_task3"] = Task( name="Test Task 3", project=data["test_project"], status_list=data["task_status_list"], ) data["test_version1"] = Version(task=data["test_task1"]) data["test_version2"] = Version(task=data["test_task1"]) data["test_version3"] = Version(task=data["test_task1"]) data["test_version4"] = Version(task=data["test_task2"]) data["test_file1"] = File(original_filename="test_render1.jpg") data["test_file2"] = File(original_filename="test_render2.jpg") data["test_file3"] = File(original_filename="test_render3.jpg") data["test_file4"] = File(original_filename="test_render4.jpg") data["test_version1"].files = [ data["test_file1"], data["test_file2"], data["test_file3"], ] data["test_version4"].files = [data["test_file4"]] return data def test_daily_instance_creation(setup_daily_tests): """It is possible to create a Daily without a problem.""" data = setup_daily_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) assert isinstance(daily, Daily) def test_files_argument_is_skipped(setup_daily_tests): """files attribute is an empty list if the files argument is skipped.""" data = setup_daily_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) assert daily.files == [] def test_files_argument_is_none(setup_daily_tests): """files attribute is an empty list if the files argument is None.""" data = setup_daily_tests daily = Daily( name="Test Daily", files=None, project=data["test_project"], status_list=data["daily_status_list"], ) assert daily.files == [] def test_files_attribute_is_set_to_none(setup_daily_tests): """TypeError is raised if the files attribute is set to None.""" data = setup_daily_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) with pytest.raises(TypeError): daily.files = None def test_files_argument_is_not_a_list_instance(setup_daily_tests): """TypeError is raised if the files argument is not a list.""" data = setup_daily_tests with pytest.raises(TypeError) as cm: Daily( name="Test Daily", files="not a list of Daily instances", project=data["test_project"], status_list=data["daily_status_list"], ) assert ( str(cm.value) == "DailyFile.file should be an instance of " "stalker.models.file.File instance, not str: 'n'" ) def test_files_argument_is_not_a_list_of_file_instances(setup_daily_tests): """TypeError is raised if the files argument is not a list of File instances.""" data = setup_daily_tests with pytest.raises(TypeError) as cm: Daily( name="Test Daily", files=["not", 1, "list", "of", File, "instances"], project=data["test_project"], status_list=data["daily_status_list"], ) assert str(cm.value) == ( "DailyFile.file should be an instance of stalker.models.file.File instance, " "not str: 'not'" ) def test_files_argument_is_working_as_expected(setup_daily_tests): """files argument value is correctly passed to the files attribute.""" data = setup_daily_tests test_value = [data["test_file1"], data["test_file2"]] daily = Daily( name="Test Daily", files=test_value, project=data["test_project"], status_list=data["daily_status_list"], ) assert daily.files == test_value def test_files_attribute_is_working_as_expected(setup_daily_tests): """files attribute is working as expected.""" data = setup_daily_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) daily.files.append(data["test_file1"]) assert daily.files == [data["test_file1"]] def test_versions_attribute_is_read_only(setup_daily_tests): """versions attribute is a read only attribute.""" data = setup_daily_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) with pytest.raises(AttributeError): setattr(daily, "versions", 10) @pytest.fixture(scope="function") def setup_daily_db_tests(setup_postgresql_db): """Set up Daily test with a Postgres DB.""" data = dict() data["status_new"] = Status.query.filter_by(code="NEW").first() data["status_wfd"] = Status.query.filter_by(code="WFD").first() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_prev"] = Status.query.filter_by(code="PREV").first() data["status_hrev"] = Status.query.filter_by(code="HREV").first() data["status_drev"] = Status.query.filter_by(code="DREV").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() data["status_open"] = Status.query.filter_by(code="OPEN").first() data["status_cls"] = Status.query.filter_by(code="CLS").first() data["daily_status_list"] = StatusList.query.filter_by( target_entity_type="Daily" ).first() data["task_status_list"] = StatusList.query.filter_by( target_entity_type="Task" ).first() data["test_repo"] = Repository(name="Test Repository", code="TR") DBSession.add(data["test_repo"]) data["test_project"] = Project( name="Test Project", code="TP", repository=data["test_repo"], ) DBSession.add(data["test_project"]) data["test_task1"] = Task( name="Test Task 1", project=data["test_project"], status_list=data["task_status_list"], ) DBSession.add(data["test_task1"]) data["test_task2"] = Task( name="Test Task 2", project=data["test_project"], status_list=data["task_status_list"], ) DBSession.add(data["test_task2"]) data["test_task3"] = Task( name="Test Task 3", project=data["test_project"], status_list=data["task_status_list"], ) DBSession.add(data["test_task3"]) DBSession.commit() data["test_version1"] = Version(task=data["test_task1"]) DBSession.add(data["test_version1"]) DBSession.commit() data["test_version2"] = Version(task=data["test_task1"]) DBSession.add(data["test_version2"]) DBSession.commit() data["test_version3"] = Version(task=data["test_task1"]) DBSession.add(data["test_version3"]) DBSession.commit() data["test_version4"] = Version(task=data["test_task2"]) DBSession.add(data["test_version4"]) DBSession.commit() data["test_file1"] = File(original_filename="test_render1.jpg") data["test_file2"] = File(original_filename="test_render2.jpg") data["test_file3"] = File(original_filename="test_render3.jpg") data["test_file4"] = File(original_filename="test_render4.jpg") DBSession.add_all( [ data["test_file1"], data["test_file2"], data["test_file3"], data["test_file4"], ] ) data["test_version1"].files = [ data["test_file1"], data["test_file2"], data["test_file3"], ] data["test_version4"].files = [data["test_file4"]] DBSession.commit() yield data def test_tasks_attribute_will_return_a_list_of_tasks(setup_daily_db_tests): """tasks attribute is a list of Task instances related to the given files.""" data = setup_daily_db_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) daily.files = [data["test_file1"], data["test_file2"]] DBSession.add(daily) DBSession.commit() assert daily.tasks == [data["test_task1"]] def test_versions_attribute_will_return_a_list_of_versions(setup_daily_db_tests): """versions attribute is a list of Version instances related to the given files.""" data = setup_daily_db_tests daily = Daily( name="Test Daily", project=data["test_project"], status_list=data["daily_status_list"], ) daily.files = [data["test_file1"], data["test_file2"]] DBSession.add(daily) DBSession.commit() assert daily.versions == [data["test_version1"]] def test_rank_argument_is_skipped(): """rank attribute will use the default value is if skipped.""" dl = DailyFile() assert dl.rank == 0 def test_daily_argument_is_not_a_daily_instance(setup_daily_tests): """TypeError is raised if the daily argument is not a Daily and not None.""" with pytest.raises(TypeError) as cm: DailyFile(daily="not a daily") assert str(cm.value) == ( "DailyFile.daily should be an instance of stalker.models.review.Daily " "instance, not str: 'not a daily'" ) ================================================ FILE: tests/models/test_department.py ================================================ # -*- coding: utf-8 -*- """Tests for the Department class.""" import datetime import pytest import pytz from stalker import Department, DepartmentUser, Entity, User from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_department_tests(): """Set up the tests foe the Department class.""" data = dict() data["test_admin"] = User( name="admin", login="admin", email="admin@admins.com", password="12345", ) # create a couple of test users data["test_user1"] = User( name="User1", login="user1", email="user1@test.com", password="123456", ) data["test_user2"] = User( name="User2", login="user2", email="user2@test.com", password="123456", ) data["test_user3"] = User( name="User3", login="user3", email="user3@test.com", password="123456", ) data["test_user4"] = User( name="User4", login="user4", email="user4@test.com", password="123456", ) data["test_user5"] = User( name="User5", login="user5", email="user5@test.com", password="123456", ) data["users_list"] = [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], ] data["date_created"] = data["date_updated"] = datetime.datetime.now(pytz.utc) data["kwargs"] = { "name": "Test Department", "description": "This is a department for testing purposes", "created_by": data["test_admin"], "updated_by": data["test_admin"], "date_created": data["date_created"], "date_updated": data["date_updated"], "users": data["users_list"], } # create a default department object data["test_department"] = Department(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Department class.""" assert Department.__auto_name__ is False def test___hash___value_is_correctly_calculated(setup_department_tests): """__hash__ value is correctly calculated.""" data = setup_department_tests assert data["test_department"].__hash__() == hash( "{}:{}:{}".format( data["test_department"].id, data["test_department"].name, data["test_department"].entity_type, ) ) def test_users_argument_accepts_an_empty_list(setup_department_tests): """users argument accepts an empty list.""" data = setup_department_tests # this should work without raising any error data["kwargs"]["users"] = [] new_dep = Department(**data["kwargs"]) assert isinstance(new_dep, Department) def test_users_attribute_accepts_an_empty_list(setup_department_tests): """users attribute accepts an empty list.""" data = setup_department_tests # this should work without raising any error data["test_department"].users = [] def test_users_argument_accepts_only_a_list_of_user_objects(setup_department_tests): """users argument accepts only a list of user objects.""" data = setup_department_tests test_value = [1, 2.3, [], {}] data["kwargs"]["users"] = test_value # this should raise a TypeError with pytest.raises(TypeError) as cm: Department(**data["kwargs"]) assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not int: '1'" ) def test_users_attribute_accepts_only_a_list_of_user_objects(setup_department_tests): """users attribute accepts only a list of user objects.""" data = setup_department_tests test_value = [1, 2.3, [], {}] # this should raise a TypeError with pytest.raises(TypeError) as cm: data["test_department"].users = test_value assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not int: '1'" ) def test_users_attribute_elements_accepts_user_only_1(setup_department_tests): """TypeError is raised if append non-User to the users attr.""" data = setup_department_tests # append with pytest.raises(TypeError) as cm: data["test_department"].users.append(0) assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not int: '0'" ) def test_users_attribute_elements_accepts_user_only_2(setup_department_tests): """TypeError is raised if non list assigned to the users attr.""" data = setup_department_tests # __setitem__ with pytest.raises(TypeError) as cm: data["test_department"].users[0] = 0 assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not int: '0'" ) def test_users_argument_is_not_iterable(setup_department_tests): """TypeError is raised if the given users argument is not an instance of list.""" data = setup_department_tests data["kwargs"]["users"] = "a user" with pytest.raises(TypeError) as cm: Department(**data["kwargs"]) assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not str: 'a'" ) def test_users_attribute_is_not_iterable(setup_department_tests): """TypeError is raised if the users attr is not iterable value.""" data = setup_department_tests with pytest.raises(TypeError) as cm: data["test_department"].users = "a user" assert str(cm.value) == ( "DepartmentUser.user should be a stalker.models.auth.User instance, " "not str: 'a'" ) def test_users_attribute_defaults_to_empty_list(setup_department_tests): """users attribute defaults to an empty list if the users argument is skipped.""" data = setup_department_tests data["kwargs"].pop("users") new_department = Department(**data["kwargs"]) assert new_department.users == [] def test_users_attribute_set_to_none(setup_department_tests): """TypeError is raised if the users attribute is set to None.""" data = setup_department_tests with pytest.raises(TypeError) as cm: data["test_department"].users = None assert str(cm.value) == "'NoneType' object is not iterable" def test_equality(setup_department_tests): """equality of two Department objects.""" data = setup_department_tests dep1 = Department(**data["kwargs"]) dep2 = Department(**data["kwargs"]) entity_kwargs = data["kwargs"].copy() entity_kwargs.pop("users") entity1 = Entity(**entity_kwargs) data["kwargs"]["name"] = "Animation" dep3 = Department(**data["kwargs"]) assert dep1 == dep2 assert not dep1 == dep3 assert not dep1 == entity1 def test_inequality(setup_department_tests): """inequality of two Department objects.""" data = setup_department_tests dep1 = Department(**data["kwargs"]) dep2 = Department(**data["kwargs"]) entity_kwargs = data["kwargs"].copy() entity_kwargs.pop("users") entity1 = Entity(**entity_kwargs) data["kwargs"]["name"] = "Animation" dep3 = Department(**data["kwargs"]) assert not dep1 != dep2 assert dep1 != dep3 assert dep1 != entity1 @pytest.fixture(scope="function") def setup_department_db_tests(setup_postgresql_db): """set up Database tests for Department class.""" data = dict() data["test_admin"] = User.query.filter_by(login="admin").first() # create a couple of test users data["test_user1"] = User( name="User1", login="user1", email="user1@test.com", password="123456", ) DBSession.add(data["test_user1"]) data["test_user2"] = User( name="User2", login="user2", email="user2@test.com", password="123456", ) DBSession.add(data["test_user2"]) data["test_user3"] = User( name="User3", login="user3", email="user3@test.com", password="123456", ) DBSession.add(data["test_user3"]) data["test_user4"] = User( name="User4", login="user4", email="user4@test.com", password="123456", ) DBSession.add(data["test_user4"]) data["test_user5"] = User( name="User5", login="user5", email="user5@test.com", password="123456", ) DBSession.add(data["test_user5"]) data["users_list"] = [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], ] data["date_created"] = data["date_updated"] = datetime.datetime.now(pytz.utc) data["kwargs"] = { "name": "Test Department", "description": "This is a department for testing purposes", "created_by": data["test_admin"], "updated_by": data["test_admin"], "date_created": data["date_created"], "date_updated": data["date_updated"], "users": data["users_list"], } # create a default department object data["test_department"] = Department(**data["kwargs"]) DBSession.add(data["test_department"]) DBSession.commit() return data def test_user_role_attribute(setup_department_db_tests): """Automatic generation of the DepartmentUser class.""" data = setup_department_db_tests # assign a user to a department and search for a DepartmentUser # representing that relation DBSession.commit() with DBSession.no_autoflush: data["test_department"].users.append(data["test_user5"]) dus = ( DepartmentUser.query.filter(DepartmentUser.user == data["test_user5"]) .filter(DepartmentUser.department == data["test_department"]) .all() ) assert len(dus) > 0 du = dus[0] assert isinstance(du, DepartmentUser) assert du.department == data["test_department"] assert du.user == data["test_user5"] assert du.role is None def test_tjp_id_is_working_as_expected(setup_department_db_tests): """tjp_is working as expected.""" data = setup_department_db_tests dep = data["test_department"] assert dep.tjp_id == f"Department_{dep.id}" def test_to_tjp_is_working_as_expected(setup_department_db_tests): """to_tjp property is working as expected.""" data = setup_department_db_tests expected_tjp = """resource Department_33 "Department_33" { resource User_28 "User_28" { efficiency 1.0 } resource User_29 "User_29" { efficiency 1.0 } resource User_30 "User_30" { efficiency 1.0 } resource User_31 "User_31" { efficiency 1.0 } }""" assert data["test_department"].to_tjp == expected_tjp ================================================ FILE: tests/models/test_department_user.py ================================================ # -*- coding: utf-8 -*- """Tests for the DepartmentUser class.""" import pytest from stalker import Department, DepartmentUser, User def test_role_argument_is_not_a_role_instance(): """TypeError will be raised when the role argument is not a Role instance.""" with pytest.raises(TypeError) as cm: DepartmentUser( department=Department(name="Test Department"), user=User( name="Test User", login="tuser", email="u@u.com", password="secret" ), role="not a role instance", ) assert str(cm.value) == ( "DepartmentUser.role should be a stalker.models.auth.Role instance, " "not str: 'not a role instance'" ) ================================================ FILE: tests/models/test_dependency_target.py ================================================ # -*- coding: utf-8 -*- """DependencyTarget related tests are here.""" from enum import Enum import sys import pytest from stalker.models.enum import DependencyTarget, DependencyTargetDecorator @pytest.mark.parametrize( "target", [ DependencyTarget.OnStart, DependencyTarget.OnEnd, ], ) def test_it_is_an_enum(target): """DependencyTarget is an Enum.""" assert isinstance(target, Enum) @pytest.mark.parametrize( "target,expected_value", [ [DependencyTarget.OnStart, "onstart"], [DependencyTarget.OnEnd, "onend"], ], ) def test_enum_values(target, expected_value): """Test enum values.""" assert target.value == expected_value @pytest.mark.parametrize( "target,expected_name", [ [DependencyTarget.OnStart, "OnStart"], [DependencyTarget.OnEnd, "OnEnd"], ], ) def test_enum_names(target, expected_name): """Test enum names.""" assert target.name == expected_name @pytest.mark.parametrize( "target,expected_value", [ [DependencyTarget.OnStart, "onstart"], [DependencyTarget.OnEnd, "onend"], ], ) def test_enum_as_str(target, expected_value): """Test enum names.""" assert str(target) == expected_value def test_to_target_target_is_skipped(): """DependencyTarget.to_target() target is skipped.""" with pytest.raises(TypeError) as cm: _ = DependencyTarget.to_target() py_error_message = { 8: "to_target() missing 1 required positional argument: 'target'", 9: "to_target() missing 1 required positional argument: 'target'", 10: "DependencyTarget.to_target() missing 1 required positional argument: 'target'", 11: "DependencyTarget.to_target() missing 1 required positional argument: 'target'", 12: "DependencyTarget.to_target() missing 1 required positional argument: 'target'", 13: "DependencyTarget.to_target() missing 1 required positional argument: 'target'", }[sys.version_info.minor] assert str(cm.value) == py_error_message def test_to_target_target_is_none(): """DependencyTarget.to_target() target is None.""" with pytest.raises(TypeError) as cm: _ = DependencyTarget.to_target(None) assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', 'OnEnd', 'onstart', 'onend'], not NoneType: 'None'" ) def test_to_target_target_is_not_a_str(): """DependencyTarget.to_target() target is not a str.""" with pytest.raises(TypeError) as cm: _ = DependencyTarget.to_target(12334.123) assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', 'OnEnd', 'onstart', 'onend'], not float: '12334.123'" ) def test_to_target_target_is_not_a_valid_str(): """DependencyTarget.to_target() target is not a valid str.""" with pytest.raises(ValueError) as cm: _ = DependencyTarget.to_target("not a valid value") assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', " "'OnEnd', 'onstart', 'onend'], not 'not a valid value'" ) @pytest.mark.parametrize( "target_name,target", [ # OnStart ["OnStart", DependencyTarget.OnStart], ["onstart", DependencyTarget.OnStart], ["ONSTART", DependencyTarget.OnStart], ["oNsTART", DependencyTarget.OnStart], # OnEnd ["OnEnd", DependencyTarget.OnEnd], ["onend", DependencyTarget.OnEnd], ["ONEND", DependencyTarget.OnEnd], ["oNeNd", DependencyTarget.OnEnd], ["OnEnD", DependencyTarget.OnEnd], ], ) def test_to_target_is_working_properly(target_name, target): """DependencyTarget can parse dependency target names.""" assert DependencyTarget.to_target(target_name) == target def test_cache_ok_is_true_in_type_decorator(): """DependencyTargetDecorator.cache_ok is True.""" assert DependencyTargetDecorator.cache_ok is True ================================================ FILE: tests/models/test_entity.py ================================================ # -*- coding: utf-8 -*- """Tests for the Entity class.""" import copy import pytest from stalker import Entity, Note, Tag, User @pytest.fixture(scope="function") def setup_entity_tests(): """Set up Entity class test data.""" data = dict() # create a user data["test_user"] = User( name="Test User", login="testuser", email="test@user.com", password="test" ) # create some test Tag objects, not necessarily needed but create them data["test_tag1"] = Tag(name="Test Tag 1") data["test_tag2"] = Tag(name="Test Tag 1") # make it equal to tag1 data["test_tag3"] = Tag(name="Test Tag 3") data["tags"] = [data["test_tag1"], data["test_tag2"]] # create a couple of test Note objects data["test_note1"] = Note(name="test note1", content="test note1") data["test_note2"] = Note(name="test note2", content="test note2") data["test_note3"] = Note(name="test note3", content="test note3") data["notes"] = [data["test_note1"], data["test_note2"]] data["kwargs"] = { "name": "Test Entity", "description": "This is a test entity, and this is a proper \ description for it", "created_by": data["test_user"], "updated_by": data["test_user"], "tags": data["tags"], "notes": data["notes"], } # create a proper SimpleEntity to use it later in the tests data["test_entity"] = Entity(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to False for Entity class.""" assert Entity.__auto_name__ is True def test_notes_argument_being_omitted(setup_entity_tests): """no error raised if omitted the notes argument.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("notes") new_entity = Entity(**kwargs) assert isinstance(new_entity, Entity) def test_notes_argument_is_set_to_none(setup_entity_tests): """notes attr is set to an empty list if the notes argument is set to None.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs["notes"] = None new_entity = Entity(**kwargs) assert new_entity.notes == [] def test_notes_attribute_is_set_to_none(setup_entity_tests): """TypeError is raised if the notes attribute is set to None.""" data = setup_entity_tests with pytest.raises(TypeError) as cm: data["test_entity"].notes = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_notes_argument_set_to_something_other_than_a_list(setup_entity_tests): """TypeError is raised if setting the notes argument something other than a list.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs["notes"] = ["a string note"] with pytest.raises(TypeError) as cm: Entity(**kwargs) assert str(cm.value) == ( "Entity.note should be a stalker.models.note.Note instance, " "not str: 'a string note'" ) def test_notes_attribute_set_to_something_other_than_a_list(setup_entity_tests): """TypeError is raised if setting the notes argument something other than a list.""" data = setup_entity_tests with pytest.raises(TypeError) as cm: data["test_entity"].notes = ["a string note"] assert str(cm.value) == ( "Entity.note should be a stalker.models.note.Note instance, " "not str: 'a string note'" ) def test_notes_argument_set_to_a_list_of_other_objects(setup_entity_tests): """TypeError is raised if notes argument is a list of non-Note objects.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs["notes"] = [1, 12.2, "this is a string"] with pytest.raises(TypeError) as cm: Entity(**kwargs) assert str(cm.value) == ( "Entity.note should be a stalker.models.note.Note instance, not int: '1'" ) def test_notes_attribute_set_to_a_list_of_other_objects(setup_entity_tests): """TypeError is raised if notes attr set to a list of non Note objects.""" data = setup_entity_tests test_value = [1, 12.2, "this is a string"] with pytest.raises(TypeError) as cm: data["test_entity"].notes = test_value assert str(cm.value) == ( "Entity.note should be a stalker.models.note.Note instance, not int: '1'" ) def test_notes_attribute_works_as_expected(setup_entity_tests): """notes attribute works as expected,""" data = setup_entity_tests test_value = [data["test_note3"]] data["test_entity"].notes = test_value assert data["test_entity"].notes == test_value def test_notes_attribute_element_is_set_to_non_note_object(setup_entity_tests): """TypeError is raised if non-Note instance assigned to the notes list.""" data = setup_entity_tests with pytest.raises(TypeError) as cm: data["test_entity"].notes[0] = 0 assert str(cm.value) == ( "Entity.note should be a stalker.models.note.Note instance, not int: '0'" ) def test_tags_argument_being_omitted(setup_entity_tests): """no error is raised if creating an entity without setting the tags argument.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("tags") # this should work without errors new_entity = Entity(**kwargs) assert isinstance(new_entity, Entity) def test_tags_argument_being_initialized_as_an_empty_list(setup_entity_tests): """nothing happens if tags argument an empty list.""" data = setup_entity_tests # this should work without errors kwargs = copy.copy(data["kwargs"]) kwargs.pop("tags") new_entity = Entity(**kwargs) expected_result = [] assert new_entity.tags == expected_result def test_tags_argument_set_to_something_other_than_a_list(setup_entity_tests): """TypeError is raised if tags arg is not a list.""" data = setup_entity_tests kwargs = copy.copy(data["kwargs"]) kwargs["tags"] = ["a tag", 1243, 12.12] with pytest.raises(TypeError) as cm: Entity(**kwargs) assert str(cm.value) == ( "Entity.tag should be a stalker.models.tag.Tag instance, not str: 'a tag'" ) def test_tags_attribute_works_as_expected(setup_entity_tests): """tags attribute works as expected.""" data = setup_entity_tests test_value = [data["test_tag1"]] data["test_entity"].tags = test_value assert data["test_entity"].tags == test_value def test_tags_attribute_element_is_set_to_non_tag_object(setup_entity_tests): """TypeError is raised if assign something to tags list that is not a Tag.""" data = setup_entity_tests with pytest.raises(TypeError) as cm: data["test_entity"].tags[0] = 0 assert str(cm.value) == ( "Entity.tag should be a stalker.models.tag.Tag instance, not int: '0'" ) def test_tags_attribute_set_to_none(setup_entity_tests): """TypeError is raised if the tags attribute is set to None.""" data = setup_entity_tests with pytest.raises(TypeError) as cm: data["test_entity"].tags = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_equality(setup_entity_tests): """equality of two entities.""" data = setup_entity_tests # create two entities with same parameters and check for equality kwargs = copy.copy(data["kwargs"]) entity1 = Entity(**kwargs) entity2 = Entity(**kwargs) kwargs["name"] = "another entity" kwargs["tags"] = [data["test_tag3"]] kwargs["notes"] = [] entity3 = Entity(**kwargs) assert entity1 == entity2 assert not entity1 == entity3 def test_inequality(setup_entity_tests): """inequality of two entities.""" data = setup_entity_tests # change the tags and test it again, expect False kwargs = copy.copy(data["kwargs"]) entity1 = Entity(**kwargs) entity2 = Entity(**kwargs) kwargs["name"] = "another entity" kwargs["tags"] = [data["test_tag3"]] kwargs["notes"] = [] entity3 = Entity(**kwargs) assert not entity1 != entity2 assert entity1 != entity3 ================================================ FILE: tests/models/test_entity_group.py ================================================ # -*- coding: utf-8 -*- """Tests for the EntityGroup class.""" import pytest from stalker import ( Asset, EntityGroup, Project, Repository, Status, StatusList, Task, Type, User, ) from stalker.models.enum import TimeUnit @pytest.fixture(scope="function") def setup_entity_group_tests(): """Set up tests for the EntityGroup class.""" data = dict() # create a couple of task data["status_new"] = Status(name="Mew", code="NEW") data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["test_user1"] = User( name="User1", login="user1", email="user1@user.com", password="1234", ) data["test_user2"] = User( name="User2", login="user2", email="user2@user.com", password="1234", ) data["test_user3"] = User( name="User3", login="user3", email="user3@user.com", password="1234", ) data["project_status_list"] = StatusList( name="Project Status List", statuses=[data["status_new"], data["status_wip"], data["status_cmpl"]], target_entity_type="Project", ) data["repo"] = Repository( name="Test Repo", code="TR", linux_path="/mnt/M/JOBs", windows_path="M:/JOBs", macos_path="/Users/Shared/Servers/M", ) data["project1"] = Project( name="Tests Project", code="tp", status_list=data["project_status_list"], repository=data["repo"], ) data["char_asset_type"] = Type( name="Character Asset", code="char", target_entity_type="Asset" ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_cmpl"], ], target_entity_type="Task", ) data["asset_status_list"] = StatusList( name="Asset Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_cmpl"], ], target_entity_type="Asset", ) data["asset1"] = Asset( name="Char1", code="char1", type=data["char_asset_type"], project=data["project1"], responsible=[data["test_user1"]], status_list=data["asset_status_list"], ) data["task1"] = Task( name="Test Task", watchers=[data["test_user3"]], parent=data["asset1"], schedule_timing=5, schedule_unit=TimeUnit.Hour, bid_timing=52, bid_unit=TimeUnit.Hour, status_list=data["task_status_list"], ) data["child_task1"] = Task( name="Child Task 1", resources=[data["test_user1"], data["test_user2"]], parent=data["task1"], status_list=data["task_status_list"], ) data["child_task2"] = Task( name="Child Task 2", resources=[data["test_user1"], data["test_user2"]], parent=data["task1"], status_list=data["task_status_list"], ) data["task2"] = Task( name="Another Task", project=data["project1"], resources=[data["test_user1"]], responsible=[data["test_user2"]], status_list=data["task_status_list"], ) data["entity_group1"] = EntityGroup( name="My Tasks", entities=[data["task1"], data["child_task2"], data["task2"]] ) return data def test_entities_argument_is_skipped(): """entities attribute is an empty list if the entities argument is skipped.""" eg = EntityGroup() assert eg.entities == [] def test_entities_argument_is_none(): """entities attribute is an empty list if the entities argument is None.""" eg = EntityGroup(entities=None) assert eg.entities == [] def test_entities_argument_is_not_a_list(): """TypeError is raised if the entities argument is not a list.""" with pytest.raises(TypeError) as cm: EntityGroup(entities="not a list of SimpleEntities") assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_entities_argument_is_not_a_list_of_simple_entity_instances(): """TypeError is raised if the entities argument is not a list of SimpleEntities.""" with pytest.raises(TypeError) as cm: EntityGroup(entities=["not", 1, "list", "of", "SimpleEntities"]) assert str(cm.value) == ( "EntityGroup.entities should be a list of SimpleEntities, not str: 'not'" ) def test_entities_argument_is_working_as_expected(setup_entity_group_tests): """entities argument value is correctly passed to the entities attribute.""" data = setup_entity_group_tests test_value = [data["project1"], data["asset1"], data["status_cmpl"]] eg = EntityGroup(entities=test_value) assert eg.entities == test_value def test__eq__is_working_as_expected_with_same_data(setup_entity_group_tests): """__eq__ is working as expected with same data.""" data = setup_entity_group_tests eg2 = EntityGroup( name="My Tasks", entities=[data["task1"], data["child_task2"], data["task2"]] ) assert (data["entity_group1"] == eg2) is True def test__eq__is_working_as_expected_with_different_data(setup_entity_group_tests): """__eq__ is working as expected with same data.""" data = setup_entity_group_tests eg2 = EntityGroup(name="My Tasks", entities=[data["task1"], data["child_task2"]]) assert data["entity_group1"].entities == [ data["task1"], data["child_task2"], data["task2"], ] assert eg2.entities == [data["task1"], data["child_task2"]] assert (data["entity_group1"] == eg2) is False def test__hash__is_working_as_expected(setup_entity_group_tests): """__hash__ is working as expected.""" data = setup_entity_group_tests result = hash(data["entity_group1"]) assert isinstance(result, int) assert result == data["entity_group1"].__hash__() ================================================ FILE: tests/models/test_file.py ================================================ # -*- coding: utf-8 -*- import os import sys import pytest from stalker import File, Repository, Type from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_file_tests(): """Set up the test for the File class.""" data = dict() # create a Type object for Files data["test_file_type1"] = Type( name="Test Type 1", code="test type1", target_entity_type="File", ) data["test_file_type2"] = Type( name="Test Type 2", code="test type2", target_entity_type="File", ) image_sequence_type = Type( name="Image Sequence", code="ImSeq", target_entity_type="File", ) # a File for the input file data["test_input_file1"] = File( name="Input File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_beauty_v001.###.exr", type=image_sequence_type, ) data["test_input_file2"] = File( name="Input File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_occ_v001.###.exr", type=image_sequence_type, ) data["kwargs"] = { "name": "An Image File", "full_path": "C:/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.exr", "references": [data["test_input_file1"], data["test_input_file2"]], "original_filename": "this_is_an_image.jpg", "type": data["test_file_type1"], "created_with": "Houdini", } data["test_file"] = File(**data["kwargs"]) yield data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to False for File class.""" assert File.__auto_name__ is True def test_full_path_argument_is_none(setup_file_tests): """full_path argument is None is set the full_path attribute to an empty string.""" data = setup_file_tests data["kwargs"]["full_path"] = None new_file = File(**data["kwargs"]) assert new_file.full_path == "" def test_full_path_attribute_is_set_to_none(setup_file_tests): """full_path attr to None is set its value to an empty string.""" data = setup_file_tests data["test_file"].full_path = "" def test_full_path_argument_is_empty_an_empty_string(setup_file_tests): """full_path attr is set to an empty str if full_path arg is an empty string.""" data = setup_file_tests data["kwargs"]["full_path"] = "" new_file = File(**data["kwargs"]) assert new_file.full_path == "" def test_full_path_attribute_is_set_to_empty_string(setup_file_tests): """full_path attr value is set to an empty string.""" data = setup_file_tests data["test_file"].full_path = "" assert data["test_file"].full_path == "" def test_full_path_argument_is_not_a_string(setup_file_tests): """TypeError is raised if the full_path argument is not a string.""" data = setup_file_tests test_value = 1 data["kwargs"]["full_path"] = test_value with pytest.raises(TypeError) as cm: File(**data["kwargs"]) assert str(cm.value) == ("File.full_path should be a str, not int: '1'") def test_full_path_attribute_is_not_a_string(setup_file_tests): """TypeError is raised if the full_path attribute is not a string instance.""" data = setup_file_tests test_value = 1 with pytest.raises(TypeError) as cm: data["test_file"].full_path = test_value assert str(cm.value) == ("File.full_path should be a str, not int: '1'") def test_full_path_windows_to_other_conversion(setup_file_tests): """full_path is stored in internal format.""" data = setup_file_tests windows_path = "M:\\path\\to\\object" expected_result = "M:/path/to/object" data["test_file"].full_path = windows_path assert data["test_file"].full_path == expected_result def test_original_filename_argument_is_none(setup_file_tests): """original_filename arg is None will set the attr to filename of the full_path.""" data = setup_file_tests data["kwargs"]["original_filename"] = None new_file = File(**data["kwargs"]) filename = os.path.basename(new_file.full_path) assert new_file.original_filename == filename def test_original_filename_attribute_is_set_to_none(setup_file_tests): """original_filename is equal to the filename of the full_path if it is None.""" data = setup_file_tests data["test_file"].original_filename = None filename = os.path.basename(data["test_file"].full_path) assert data["test_file"].original_filename == filename def test_original_filename_argument_is_empty_string(setup_file_tests): """original_filename arg is empty str, attr is set to filename of the full_path.""" data = setup_file_tests data["kwargs"]["original_filename"] = "" new_file = File(**data["kwargs"]) filename = os.path.basename(new_file.full_path) assert new_file.original_filename == filename def test_original_filename_attribute_set_to_empty_string(setup_file_tests): """original_filename attr is empty str, attr is set to filename of the full_path.""" data = setup_file_tests data["test_file"].original_filename = "" filename = os.path.basename(data["test_file"].full_path) assert data["test_file"].original_filename == filename def test_original_filename_argument_accepts_string_only(setup_file_tests): """original_filename arg accepts str only and raises TypeError for other types.""" data = setup_file_tests test_value = 1 data["kwargs"]["original_filename"] = test_value with pytest.raises(TypeError) as cm: File(**data["kwargs"]) assert str(cm.value) == ("File.original_filename should be a str, not int: '1'") def test_original_filename_attribute_accepts_string_only(setup_file_tests): """original_filename attr accepts str only and raises TypeError for other types.""" data = setup_file_tests test_value = 1.1 with pytest.raises(TypeError) as cm: data["test_file"].original_filename = test_value assert str(cm.value) == ("File.original_filename should be a str, not float: '1.1'") def test_original_filename_argument_is_working_as_expected(setup_file_tests): """original_filename argument is working as expected.""" data = setup_file_tests assert data["kwargs"]["original_filename"] == data["test_file"].original_filename def test_original_filename_attribute_is_working_as_expected(setup_file_tests): """original_filename attribute is working as expected.""" data = setup_file_tests new_value = "this_is_the_original_filename.jpg" assert data["test_file"].original_filename != new_value data["test_file"].original_filename = new_value assert data["test_file"].original_filename == new_value def test_equality_of_two_files(setup_file_tests): """Equality operator.""" data = setup_file_tests # with same parameters mock_file1 = File(**data["kwargs"]) assert data["test_file"] == mock_file1 # with different parameters data["kwargs"]["type"] = data["test_file_type2"] mock_file2 = File(**data["kwargs"]) assert not data["test_file"] == mock_file2 def test_inequality_of_two_files(setup_file_tests): """Inequality operator.""" data = setup_file_tests # with same parameters mock_file1 = File(**data["kwargs"]) assert data["test_file"] == mock_file1 # with different parameters data["kwargs"]["type"] = data["test_file_type2"] mock_file2 = File(**data["kwargs"]) assert not data["test_file"] != mock_file1 assert data["test_file"] != mock_file2 def test_plural_class_name(setup_file_tests): """Plural name of File class.""" data = setup_file_tests assert data["test_file"].plural_class_name == "Files" def test_path_attribute_is_set_to_none(setup_file_tests): """TypeError is raised if the path attribute is set to None.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].path = None assert str(cm.value) == "File.path cannot be set to None" def test_path_attribute_is_set_to_empty_string(setup_file_tests): """ValueError is raised if the path attribute is set to an empty string.""" data = setup_file_tests with pytest.raises(ValueError) as cm: data["test_file"].path = "" assert str(cm.value) == "File.path cannot be an empty string" def test_path_attribute_is_set_to_a_value_other_then_string(setup_file_tests): """TypeError is raised if the path attribute is set to a value other than string.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].path = 1 assert str(cm.value) == "File.path should be a str, not int: '1'" def test_path_attribute_value_comes_from_full_path(setup_file_tests): """path attribute value is calculated from the full_path attribute.""" data = setup_file_tests assert data["test_file"].path == "C:/A_NEW_PROJECT/td/dsdf" def test_path_attribute_updates_the_full_path_attribute(setup_file_tests): """path attribute is updating the full_path attribute.""" data = setup_file_tests test_value = "/mnt/some/new/path" expected_full_path = "/mnt/some/new/path/" "22-fdfffsd-32342-dsf2332-dsfd-3.exr" assert data["test_file"].path != test_value data["test_file"].path = test_value assert data["test_file"].path == test_value assert data["test_file"].full_path == expected_full_path def test_filename_attribute_is_set_to_none(setup_file_tests): """filename attribute is an empty string if it is set a None.""" data = setup_file_tests data["test_file"].filename = None assert data["test_file"].filename == "" def test_filename_attribute_is_set_to_a_value_other_then_string(setup_file_tests): """TypeError is raised if the filename attr is set to a value other than string.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].filename = 3 assert str(cm.value) == "File.filename should be a str, not int: '3'" def test_filename_attribute_is_set_to_empty_string(setup_file_tests): """filename value can be set to an empty string.""" data = setup_file_tests data["test_file"].filename = "" assert data["test_file"].filename == "" def test_filename_attribute_value_comes_from_full_path(setup_file_tests): """filename attribute value is calculated from the full_path attribute.""" data = setup_file_tests assert data["test_file"].filename == "22-fdfffsd-32342-dsf2332-dsfd-3.exr" def test_filename_attribute_updates_the_full_path_attribute(setup_file_tests): """filename attribute is updating the full_path attribute.""" data = setup_file_tests test_value = "new_filename.tif" assert data["test_file"].filename != test_value data["test_file"].filename = test_value assert data["test_file"].filename == test_value assert data["test_file"].full_path == "C:/A_NEW_PROJECT/td/dsdf/new_filename.tif" def test_filename_attribute_changes_also_the_extension(setup_file_tests): """filename attribute also changes the extension attribute.""" data = setup_file_tests assert data["test_file"].extension != ".an_extension" data["test_file"].filename = "some_filename_and.an_extension" assert data["test_file"].extension == ".an_extension" def test_extension_attribute_is_set_to_none(setup_file_tests): """extension is an empty string if it is set to None.""" data = setup_file_tests data["test_file"].extension = None assert data["test_file"].extension == "" def test_extension_attribute_is_set_to_empty_string(setup_file_tests): """extension attr can be set to an empty string.""" data = setup_file_tests data["test_file"].extension = "" assert data["test_file"].extension == "" def test_extension_attribute_is_set_to_a_value_other_then_string(setup_file_tests): """TypeError is raised if the extension attr is not str.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].extension = 123 assert str(cm.value) == ("File.extension should be a str, not int: '123'") def test_extension_attribute_value_comes_from_full_path(setup_file_tests): """extension attribute value is calculated from the full_path attribute.""" data = setup_file_tests assert data["test_file"].extension == ".exr" def test_extension_attribute_updates_the_full_path_attribute(setup_file_tests): """extension attribute is updating the full_path attribute.""" data = setup_file_tests test_value = ".iff" assert data["test_file"].extension != test_value data["test_file"].extension = test_value assert data["test_file"].extension == test_value assert ( data["test_file"].full_path == "C:/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.iff" ) def test_extension_attribute_updates_the_full_path_attribute_with_the_dot( setup_file_tests, ): """full_path attr updated with the extension that doesn't have a dot.""" data = setup_file_tests test_value = "iff" expected_value = ".iff" assert data["test_file"].extension != test_value data["test_file"].extension = test_value assert data["test_file"].extension == expected_value assert ( data["test_file"].full_path == "C:/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.iff" ) def test_extension_attribute_is_also_change_the_filename_attribute(setup_file_tests): """extension attribute is updating the filename attribute.""" data = setup_file_tests test_value = ".targa" expected_value = "22-fdfffsd-32342-dsf2332-dsfd-3.targa" assert data["test_file"].filename != expected_value data["test_file"].extension = test_value assert data["test_file"].filename == expected_value def test_format_path_converts_bytes_to_strings(setup_file_tests): """_format_path() converts bytes to strings.""" data = setup_file_tests test_value = b"C:/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.iff" result = data["test_file"]._format_path(test_value) assert isinstance(result, str) assert result == test_value.decode("utf-8") def test__hash__is_working_as_expected(setup_file_tests): """__hash__ is working as expected.""" data = setup_file_tests result = hash(data["test_file"]) assert isinstance(result, int) assert result == data["test_file"].__hash__() def test_references_arg_is_skipped(setup_file_tests): """references attr is an empty list if the references argument is skipped.""" data = setup_file_tests data["kwargs"].pop("references") new_version = File(**data["kwargs"]) assert new_version.references == [] def test_references_arg_is_none(setup_file_tests): """references attr is an empty list if the references argument is None.""" data = setup_file_tests data["kwargs"]["references"] = None new_file = File(**data["kwargs"]) assert new_file.references == [] def test_references_attr_is_none(setup_file_tests): """TypeError raised if the references attr is set to None.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].references = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_references_arg_is_not_a_list_of_file_instances(setup_file_tests): """TypeError raised if the references arg is not a File instance.""" data = setup_file_tests test_value = [132, "231123"] data["kwargs"]["references"] = test_value with pytest.raises(TypeError) as cm: File(**data["kwargs"]) assert str(cm.value) == ( "File.references should only contain instances of " "stalker.models.file.File, not int: '132'" ) def test_references_attr_is_not_a_list_of_file_instances(setup_file_tests): """TypeError raised if the references attr is set to something other than a File.""" data = setup_file_tests test_value = [132, "231123"] with pytest.raises(TypeError) as cm: data["test_file"].references = test_value assert str(cm.value) == ( "File.references should only contain instances of " "stalker.models.file.File, not int: '132'" ) def test_references_attr_is_working_as_expected(setup_file_tests): """references attr is working as expected.""" data = setup_file_tests data["kwargs"].pop("references") new_file = File(**data["kwargs"]) assert data["test_input_file1"] not in new_file.references assert data["test_input_file2"] not in new_file.references new_file.references = [data["test_input_file1"], data["test_input_file2"]] assert data["test_input_file1"] in new_file.references assert data["test_input_file2"] in new_file.references def test_created_with_argument_can_be_skipped(setup_file_tests): """created_with argument can be skipped.""" data = setup_file_tests data["kwargs"].pop("created_with") File(**data["kwargs"]) def test_created_with_argument_can_be_none(setup_file_tests): """created_with argument can be None.""" data = setup_file_tests data["kwargs"]["created_with"] = None File(**data["kwargs"]) def test_created_with_attribute_can_be_set_to_none(setup_file_tests): """created with attribute can be set to None.""" data = setup_file_tests data["test_file"].created_with = None def test_created_with_argument_accepts_only_string_or_none(setup_file_tests): """TypeError raised if the created_with arg is not a string or None.""" data = setup_file_tests data["kwargs"]["created_with"] = 234 with pytest.raises(TypeError) as cm: File(**data["kwargs"]) assert str(cm.value) == ( "File.created_with should be an instance of str, not int: '234'" ) def test_created_with_attribute_accepts_only_string_or_none(setup_file_tests): """TypeError raised if the created_with attr is not a str or None.""" data = setup_file_tests with pytest.raises(TypeError) as cm: data["test_file"].created_with = 234 assert str(cm.value) == ( "File.created_with should be an instance of str, not int: '234'" ) def test_created_with_argument_is_working_as_expected(setup_file_tests): """created_with argument value is passed to created_with attribute.""" data = setup_file_tests test_value = "Maya" data["kwargs"]["created_with"] = test_value test_file = File(**data["kwargs"]) assert test_file.created_with == test_value def test_created_with_attribute_is_working_as_expected(setup_file_tests): """created_with attribute is working as expected.""" data = setup_file_tests test_value = "Maya" assert data["test_file"].created_with != test_value data["test_file"].created_with = test_value assert data["test_file"].created_with == test_value def test_walk_references_is_working_as_expected_in_dfs_mode(setup_file_tests): """walk_references() method is working in DFS mode correctly.""" # data = setup_file_tests repr_type = Type(name="Representation", code="Repr", target_entity_type="File") # DBSession.add(repr_type) # DBSession.commit() # File 1 # v1 = Version(task=data["test_task1"]) v1_base_repr = File( name="Base Repr.", # full_path=str(v1.generate_path().with_suffix(".ma")), type=repr_type, ) # v1.files.append(v1_base_repr) # Version 2 # v2 = Version(task=data["test_task1"]) v2_base_repr = File( name="Base Repr.", # full_path=str(v2.generate_path().with_suffix(".ma")), type=repr_type, ) # v2.files.append(v2_base_repr) # Version 3 # v3 = Version(task=data["test_task1"]) v3_base_repr = File( name="Base Repr.", # full_path=str(v3.generate_path().with_suffix(".ma")), type=repr_type, ) # v3.files.append(v3_base_repr) # v4 = Version(task=data["test_task1"]) v4_base_repr = File( name="Base Repr.", # full_path=str(v4.generate_path().with_suffix(".ma")), type=repr_type, ) # v4.files.append(v4_base_repr) # v5 = Version(task=data["test_task1"]) v5_base_repr = File( name="Base Repr.", # full_path=str(v5.generate_path().with_suffix(".ma")), type=repr_type, ) # v5.files.append(v5_base_repr) v5_base_repr.references = [v4_base_repr] v4_base_repr.references = [v3_base_repr, v2_base_repr] v3_base_repr.references = [v1_base_repr] v2_base_repr.references = [v1_base_repr] expected_result = [ v5_base_repr, v4_base_repr, v3_base_repr, v1_base_repr, v2_base_repr, v1_base_repr, ] visited_versions = [] for v in v5_base_repr.walk_references(): visited_versions.append(v) assert expected_result == visited_versions def test_absolute_path_is_read_only(setup_file_tests): """absolute_path property is read-only.""" data = setup_file_tests with pytest.raises(AttributeError) as cm: data["test_file"].absolute_path = "C:/A_NEW_PROJECT/td/dsdf" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'absolute_path'", 11: "property 'absolute_path' of 'File' object has no setter", }.get(sys.version_info.minor, "property 'absolute_path' of 'File' object has no setter") assert str(cm.value) == error_message def test_absolute_path_returns_the_absolute_path(setup_file_tests): """absolute_path property returns the absolute path of the full_path attribute.""" data = setup_file_tests file = data["test_file"] os.environ["REPOPR1"] = "/mnt/project_server/Projects" file.full_path = "$REPOPR1/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.exr" expected_result = "/mnt/project_server/Projects/A_NEW_PROJECT/td/dsdf" assert data["test_file"].absolute_path == expected_result def test_absolute_full_path_is_read_only(setup_file_tests): """absolute_full_path property is read-only.""" data = setup_file_tests with pytest.raises(AttributeError) as cm: data["test_file"].absolute_full_path = "C:/A_NEW_PROJECT/td/dsdf" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'absolute_full_path'", 11: "property 'absolute_full_path' of 'File' object has no setter", }.get(sys.version_info.minor, "property 'absolute_full_path' of 'File' object has no setter") assert str(cm.value) == error_message def test_absolute_full_path_returns_the_absolute_full_path(setup_file_tests): """absolute_full_path property returns the absolute path of the full_path attribute.""" data = setup_file_tests os.environ["REPOPR1"] = "/mnt/project_server/Projects" file = data["test_file"] file.full_path = "$REPOPR1/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.exr" expected_result = "/mnt/project_server/Projects/A_NEW_PROJECT/td/dsdf/22-fdfffsd-32342-dsf2332-dsfd-3.exr" assert data["test_file"].absolute_full_path == expected_result ================================================ FILE: tests/models/test_filename_template.py ================================================ # -*- coding: utf-8 -*- """Tests for the stalker.models.template.FilenameTemplate class.""" import sys import pytest from stalker import ( Entity, FilenameTemplate, Project, Sequence, Shot, Structure, Task, Type, Version, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_filename_template_tests(): """Set up tests for the FilenameTemplate class.""" data = dict() data["kwargs"] = { "name": "Test FilenameTemplate", "type": Type( name="Test Type", code="tt", target_entity_type="FilenameTemplate" ), "path": "ASSETS/{{asset.code}}/{{task.type.code}}/", "filename": "{{asset.code}}_{{task.type.code}}_" "{{version.version}}_{{user.initials}}", "output_path": "", "target_entity_type": "Asset", } data["filename_template"] = FilenameTemplate(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Asset class.""" assert FilenameTemplate.__auto_name__ is False def test_filename_template_is_not_strictly_typed(setup_filename_template_tests): """FilenameTemplate class is not strictly typed.""" data = setup_filename_template_tests data["kwargs"].pop("type") # no errors ft = FilenameTemplate(**data["kwargs"]) assert isinstance(ft, FilenameTemplate) def test_target_entity_type_argument_is_skipped(setup_filename_template_tests): """TypeError is raised if the target_entity_type argument is skipped.""" data = setup_filename_template_tests data["kwargs"].pop("target_entity_type") with pytest.raises(TypeError) as cm: FilenameTemplate(**data["kwargs"]) assert str(cm.value) == "FilenameTemplate.target_entity_type cannot be None" def test_target_entity_type_argument_is_none(setup_filename_template_tests): """TypeError is raised if the target_entity_type argument is given as None.""" data = setup_filename_template_tests data["kwargs"]["target_entity_type"] = None with pytest.raises(TypeError) as cm: FilenameTemplate(**data["kwargs"]) assert str(cm.value) == "FilenameTemplate.target_entity_type cannot be None" def test_target_entity_type_attribute_is_read_only(setup_filename_template_tests): """AttributeError is raised if the target_entity_type attribute is set.""" data = setup_filename_template_tests with pytest.raises(AttributeError) as cm: data["filename_template"].target_entity_type = "Asset" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'FilenameTemplate' object has no setter", 12: "property of 'FilenameTemplate' object has no setter", }.get( sys.version_info.minor, "property '_target_entity_type_getter' of 'FilenameTemplate' " "object has no setter", ) assert str(cm.value) == error_message def test_target_entity_type_argument_accepts_classes(setup_filename_template_tests): """target_entity_type can be set to a class directly.""" data = setup_filename_template_tests data["kwargs"]["target_entity_type"] = "Asset" _ = FilenameTemplate(**data["kwargs"]) def test_target_entity_type_attribute_is_converted_to_a_string_if_given_as_a_class( setup_filename_template_tests, ): """target_entity_type attr is converted if the target_entity_type is a class.""" data = setup_filename_template_tests data["kwargs"]["target_entity_type"] = "Asset" ft = FilenameTemplate(**data["kwargs"]) assert ft.target_entity_type == "Asset" def test_path_argument_is_skipped(setup_filename_template_tests): """Nothing happens if the path argument is skipped.""" data = setup_filename_template_tests data["kwargs"].pop("path") ft = FilenameTemplate(**data["kwargs"]) assert isinstance(ft, FilenameTemplate) def test_path_argument_skipped_path_attribute_is_empty_string( setup_filename_template_tests, ): """path attribute is an empty string if the path argument is skipped.""" data = setup_filename_template_tests data["kwargs"].pop("path") ft = FilenameTemplate(**data["kwargs"]) assert ft.path == "" def test_path_argument_is_none_path_attribute_is_empty_string( setup_filename_template_tests, ): """path attribute is an empty string if the path argument is None.""" data = setup_filename_template_tests data["kwargs"]["path"] = None ft = FilenameTemplate(**data["kwargs"]) assert ft.path == "" def test_path_argument_is_empty_string(setup_filename_template_tests): """Nothing happens if the path argument is empty string.""" data = setup_filename_template_tests data["kwargs"]["path"] = "" ft = FilenameTemplate(**data["kwargs"]) assert isinstance(ft, FilenameTemplate) def test_path_attribute_is_empty_string(setup_filename_template_tests): """Nothing happens if the path attribute is set to empty string.""" data = setup_filename_template_tests data["filename_template"].path = "" def test_path_argument_is_not_string(setup_filename_template_tests): """TypeError is raised if the path argument is not a string.""" data = setup_filename_template_tests test_value = list("a list from a string") data["kwargs"]["path"] = test_value with pytest.raises(TypeError) as cm: FilenameTemplate(**data["kwargs"]) assert str(cm.value) == ( "FilenameTemplate.path attribute should be string, not list: '['a', ' ', 'l', " "'i', 's', 't', ' ', 'f', 'r', 'o', 'm', ' ', 'a', ' ', 's', 't', 'r', 'i', " "'n', 'g']'" ) def test_path_attribute_is_not_string(setup_filename_template_tests): """TypeError is raised if the path attribute is not set to a string.""" data = setup_filename_template_tests test_value = list("a list from a string") with pytest.raises(TypeError) as cm: data["filename_template"].path = test_value assert str(cm.value) == ( "FilenameTemplate.path attribute should be string, not list: '['a', ' ', 'l', " "'i', 's', 't', ' ', 'f', 'r', 'o', 'm', ' ', 'a', ' ', 's', 't', 'r', 'i', " "'n', 'g']'" ) def test_filename_argument_is_skipped(setup_filename_template_tests): """Nothing happens if the filename argument is skipped.""" data = setup_filename_template_tests data["kwargs"].pop("filename") ft = FilenameTemplate(**data["kwargs"]) assert isinstance(ft, FilenameTemplate) def test_filename_argument_skipped_filename_attribute_is_empty_string( setup_filename_template_tests, ): """filename attribute is an empty string if the filename argument is skipped.""" data = setup_filename_template_tests data["kwargs"].pop("filename") ft = FilenameTemplate(**data["kwargs"]) assert ft.filename == "" def test_filename_argument_is_none_filename_attribute_is_empty_string( setup_filename_template_tests, ): """filename attribute is an empty string if the filename argument is None.""" data = setup_filename_template_tests data["kwargs"]["filename"] = None ft = FilenameTemplate(**data["kwargs"]) assert ft.filename == "" def test_filename_argument_is_empty_string(setup_filename_template_tests): """Nothing happens if the filename argument is empty string.""" data = setup_filename_template_tests data["kwargs"]["filename"] = "" ft = FilenameTemplate(**data["kwargs"]) assert isinstance(ft, FilenameTemplate) def test_filename_attribute_is_empty_string(setup_filename_template_tests): """Nothing happens if the filename attribute is set to empty string.""" data = setup_filename_template_tests data["filename_template"].filename = "" def test_filename_argument_is_not_string(setup_filename_template_tests): """TypeError is raised if filename argument is not string.""" data = setup_filename_template_tests test_value = list("a list from a string") data["kwargs"]["filename"] = test_value with pytest.raises(TypeError) as cm: FilenameTemplate(**data["kwargs"]) assert str(cm.value) == ( "FilenameTemplate.filename attribute should be string, not list: '['a', ' ', " "'l', 'i', 's', 't', ' ', 'f', 'r', 'o', 'm', ' ', 'a', ' ', 's', 't', 'r', " "'i', 'n', 'g']'" ) def test_filename_attribute_is_not_string(setup_filename_template_tests): """Given value converted to string for the filename attribute.""" data = setup_filename_template_tests test_value = list("a list from a string") with pytest.raises(TypeError) as cm: data["filename_template"].filename = test_value assert str(cm.value) == ( "FilenameTemplate.filename attribute should be string, not list: " "'['a', ' ', 'l', 'i', 's', 't', ' ', 'f', 'r', 'o', 'm', ' ', 'a', ' ', " "'s', 't', 'r', 'i', 'n', 'g']'" ) def test_equality(setup_filename_template_tests): """Equality of FilenameTemplate objects.""" data = setup_filename_template_tests ft1 = FilenameTemplate(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "Entity" ft2 = FilenameTemplate(**data["kwargs"]) data["kwargs"]["path"] = "different path" ft3 = FilenameTemplate(**data["kwargs"]) data["kwargs"]["filename"] = "different filename" ft4 = FilenameTemplate(**data["kwargs"]) assert data["filename_template"] == ft1 assert not data["filename_template"] == new_entity assert not ft1 == ft2 assert not ft2 == ft3 assert not ft3 == ft4 def test_inequality(setup_filename_template_tests): """Inequality of FilenameTemplate objects.""" data = setup_filename_template_tests ft1 = FilenameTemplate(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "Entity" ft2 = FilenameTemplate(**data["kwargs"]) data["kwargs"]["path"] = "different path" ft3 = FilenameTemplate(**data["kwargs"]) data["kwargs"]["filename"] = "different filename" ft4 = FilenameTemplate(**data["kwargs"]) assert not data["filename_template"] != ft1 assert data["filename_template"] != new_entity assert ft1 != ft2 assert ft2 != ft3 assert ft3 != ft4 def test_naming_case(setup_postgresql_db): """Naming should contain both Sequence Shot and other stuff. (this is based on https://github.com/eoyilmaz/anima/issues/23) """ ft = FilenameTemplate( name="Normal Naming Convention", target_entity_type="Task", path="$REPO{{project.repository.id}}/{{project.code}}/{%- for parent_task in " "parent_tasks -%}{{parent_task.nice_name}}/{%- endfor -%}", filename="""{%- for p in parent_tasks -%} {%- if p.entity_type == 'Sequence' -%} {{p.name}} {%- elif p.entity_type == 'Shot' -%} _{{p.name}}{{p.children[0].name}} {%- endif -%} {%- endfor -%} {%- set fx = parent_tasks[-2] -%} _{{fx.name}}_v{{"%02d"|format(version.version_number)}}""", ) DBSession.add(ft) st = Structure(name="Normal Project Structure", templates=[ft]) DBSession.add(st) test_project = Project(name="test001", code="test001", structure=st) DBSession.add(test_project) DBSession.commit() seq_task = Task(name="seq", project=test_project) DBSession.add(seq_task) ep101 = Sequence(name="ep101", code="ep101", parent=seq_task) DBSession.add(ep101) shot_task = Task(name="shot", parent=ep101) DBSession.add(shot_task) s001 = Shot(name="s001", code="s001", parent=shot_task) DBSession.add(s001) c001 = Task(name="c001", parent=s001) DBSession.add(c001) effects_scene = Task(name="effect scene", parent=c001) DBSession.add(effects_scene) fxA = Task(name="fxA", parent=effects_scene) DBSession.add(fxA) maya = Task(name="maya", parent=fxA) DBSession.add(maya) DBSession.commit() v = Version(task=maya) DBSession.add(v) DBSession.commit() path = v.generate_path(extension=".ma") assert path.name == "ep101_s001c001_fxA_v01.ma" def test__hash__is_working_as_expected(setup_filename_template_tests): """__hash__ is working as expected.""" data = setup_filename_template_tests result = hash(data["filename_template"]) assert isinstance(result, int) assert result == data["filename_template"].__hash__() ================================================ FILE: tests/models/test_generic.py ================================================ # -*- coding: utf-8 -*- """Tests utility functions.""" import datetime import pytest import pytz from stalker.utils import make_plural, utc_to_local, local_to_utc @pytest.mark.parametrize( "test_value,expected", [ ("asset", "assets"), ("client", "clients"), ("department", "departments"), ("entity", "entities"), ("template", "templates"), ("group", "groups"), ("format", "formats"), ("file", "files"), ("session", "sessions"), ("note", "notes"), ("permission", "permissions"), ("project", "projects"), ("repository", "repositories"), ("review", "reviews"), ("scene", "scenes"), ("sequence", "sequences"), ("shot", "shots"), ("status", "statuses"), ("list", "lists"), ("structure", "structures"), ("studio", "studios"), ("tag", "tags"), ("task", "tasks"), ("dependency", "dependencies"), ("type", "types"), ("bench", "benches"), ("thief", "thieves"), ], ) def test_make_plural_is_working_as_expected(test_value, expected): """make_plural() is working as expected.""" assert expected == make_plural(test_value) def test_utc_to_local_is_working_as_expected(): """utc_to_local() is working as expected.""" local_now = datetime.datetime.now() utc_now = datetime.datetime.now(pytz.utc) utc_without_tz = datetime.datetime( utc_now.year, utc_now.month, utc_now.day, utc_now.hour, utc_now.minute, ) local_from_utc = utc_to_local(utc_without_tz) assert local_from_utc.year == local_now.year assert local_from_utc.month == local_now.month assert local_from_utc.day == local_now.day assert local_from_utc.hour == local_now.hour assert local_from_utc.minute == local_now.minute def test_local_to_utc_is_working_as_expected(): """local_to_utc() is working as expected.""" local_now = datetime.datetime.now() utc_now = datetime.datetime.now(pytz.utc) utc_without_tz = datetime.datetime( utc_now.year, utc_now.month, utc_now.day, utc_now.hour, utc_now.minute, ) utc_from_local = local_to_utc(local_now) assert utc_from_local.year == utc_without_tz.year assert utc_from_local.month == utc_without_tz.month assert utc_from_local.day == utc_without_tz.day assert utc_from_local.hour == utc_without_tz.hour assert utc_from_local.minute == utc_without_tz.minute ================================================ FILE: tests/models/test_good.py ================================================ # -*- coding: utf-8 -*- """Tests for the Good class.""" import pytest from stalker import Client, Good @pytest.fixture(scope="function") def setup_good_tests(): """Set up the test for the stalker.models.budget.Good class.""" data = dict() data["kwargs"] = {"name": "Comp", "cost": 10, "msrp": 12, "unit": "TL/hour"} return data def test_cost_argument_is_skipped(setup_good_tests): """cost attribute value is 0.0 if the cost argument is skipped.""" data = setup_good_tests data["kwargs"].pop("cost") g = Good(**data["kwargs"]) assert g.cost == 0 def test_cost_argument_is_none(setup_good_tests): """cost attribute value is 0.0 if the cost argument is None.""" data = setup_good_tests data["kwargs"]["cost"] = None g = Good(**data["kwargs"]) assert g.cost == 0 def test_cost_attribute_is_none(setup_good_tests): """cost attribute is 0.0 if it is set to None.""" data = setup_good_tests g = Good(**data["kwargs"]) assert g.cost != 0 g.cost = None assert g.cost == 0 def test_cost_argument_is_not_a_number(setup_good_tests): """TypeError is raised if cost argument is not a number.""" data = setup_good_tests data["kwargs"]["cost"] = "not a number" with pytest.raises(TypeError) as cm: _ = Good(**data["kwargs"]) assert str(cm.value) == ( "Good.cost should be a non-negative number, not str: 'not a number'" ) def test_cost_attribute_is_not_a_number(setup_good_tests): """TypeError is raised if the cost attr is not a number.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(TypeError) as cm: g.cost = "not a number" assert str(cm.value) == ( "Good.cost should be a non-negative number, not str: 'not a number'" ) def test_cost_argument_is_zero(setup_good_tests): """It is totally ok to set the cost to 0.""" data = setup_good_tests data["kwargs"]["cost"] = 0 g = Good(**data["kwargs"]) assert g.cost == 0.0 def test_cost_attribute_is_zero(setup_good_tests): """It is totally ok to test the cost attribute to 0.""" data = setup_good_tests g = Good(**data["kwargs"]) assert g.cost != 0.0 g.cost = 0.0 assert g.cost == 0.0 def test_cost_argument_is_negative(setup_good_tests): """ValueError is raised if the cost argument is a negative number.""" data = setup_good_tests data["kwargs"]["cost"] = -10 with pytest.raises(ValueError) as cm: _ = Good(**data["kwargs"]) assert str(cm.value) == "Good.cost should be a non-negative number" def test_cost_attribute_is_negative(setup_good_tests): """ValueError is raised if the cost attribute is set to a negative number.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(ValueError) as cm: g.cost = -10 assert str(cm.value) == "Good.cost should be a non-negative number" def test_cost_argument_is_working_as_expected(setup_good_tests): """cost argument value is passed to the cost attribute.""" data = setup_good_tests test_value = 113 data["kwargs"]["cost"] = test_value g = Good(**data["kwargs"]) assert g.cost == test_value def test_cost_attribute_is_working_as_expected(setup_good_tests): """cost attribute value can be changed.""" data = setup_good_tests test_value = 145 g = Good(**data["kwargs"]) assert g.cost != test_value g.cost = test_value assert g.cost == test_value def test_msrp_argument_is_skipped(setup_good_tests): """msrp attribute value is 0.0 if the msrp argument is skipped.""" data = setup_good_tests data["kwargs"].pop("msrp") g = Good(**data["kwargs"]) assert g.msrp == 0 def test_msrp_argument_is_none(setup_good_tests): """msrp attribute value is 0.0 if the msrp argument is None.""" data = setup_good_tests data["kwargs"]["msrp"] = None g = Good(**data["kwargs"]) assert g.msrp == 0 def test_msrp_attribute_is_none(setup_good_tests): """msrp attribute is 0.0 if it is set to None.""" data = setup_good_tests g = Good(**data["kwargs"]) assert g.msrp != 0 g.msrp = None assert g.msrp == 0 def test_msrp_argument_is_not_a_number(setup_good_tests): """TypeError is raised if msrp argument is not a number.""" data = setup_good_tests data["kwargs"]["msrp"] = "not a number" with pytest.raises(TypeError) as cm: _ = Good(**data["kwargs"]) assert str(cm.value) == ( "Good.msrp should be a non-negative number, not str: 'not a number'" ) def test_msrp_attribute_is_not_a_number(setup_good_tests): """TypeError is raised if the msrp attr is not a number.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(TypeError) as cm: g.msrp = "not a number" assert str(cm.value) == ( "Good.msrp should be a non-negative number, not str: 'not a number'" ) def test_msrp_argument_is_zero(setup_good_tests): """It is totally ok to set the msrp to 0.""" data = setup_good_tests data["kwargs"]["msrp"] = 0 g = Good(**data["kwargs"]) assert g.msrp == 0.0 def test_msrp_attribute_is_zero(setup_good_tests): """It is totally ok to test the msrp attribute to 0.""" data = setup_good_tests g = Good(**data["kwargs"]) assert g.msrp != 0.0 g.msrp = 0.0 assert g.msrp == 0.0 def test_msrp_argument_is_negative(setup_good_tests): """ValueError is raised if the msrp argument is a negative number.""" data = setup_good_tests data["kwargs"]["msrp"] = -10 with pytest.raises(ValueError) as cm: _ = Good(**data["kwargs"]) assert str(cm.value) == "Good.msrp should be a non-negative number" def test_msrp_attribute_is_negative(setup_good_tests): """ValueError is raised if the msrp attribute is set to a negative number.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(ValueError) as cm: g.msrp = -10 assert str(cm.value) == "Good.msrp should be a non-negative number" def test_msrp_argument_is_working_as_expected(setup_good_tests): """msrp argument value is passed to the msrp attribute.""" data = setup_good_tests test_value = 113 data["kwargs"]["msrp"] = test_value g = Good(**data["kwargs"]) assert g.msrp == test_value def test_msrp_attribute_is_working_as_expected(setup_good_tests): """msrp attribute value can be changed.""" data = setup_good_tests test_value = 145 g = Good(**data["kwargs"]) assert g.msrp != test_value g.msrp = test_value assert g.msrp == test_value def test_unit_argument_is_skipped(setup_good_tests): """unit attribute is an empty string if the unit argument is skipped.""" data = setup_good_tests data["kwargs"].pop("unit") g = Good(**data["kwargs"]) assert g.unit == "" def test_unit_argument_is_none(setup_good_tests): """unit attribute is an empty string if the unit argument is None.""" data = setup_good_tests data["kwargs"]["unit"] = None g = Good(**data["kwargs"]) assert g.unit == "" def test_unit_attribute_is_set_to_none(setup_good_tests): """unit attribute is an empty string if it is set to None.""" data = setup_good_tests g = Good(**data["kwargs"]) assert g.unit != "" g.unit = None assert g.unit == "" def test_unit_argument_is_not_a_string(setup_good_tests): """TypeError is raised if the unit argument is not a string.""" data = setup_good_tests data["kwargs"]["unit"] = 12312 with pytest.raises(TypeError) as cm: g = Good(**data["kwargs"]) assert str(cm.value) == "Good.unit should be a string, not int: '12312'" def test_unit_attribute_is_not_a_string(setup_good_tests): """TypeError is raised if the unit attr is set to a value which is not a string.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(TypeError) as cm: g.unit = 2342 assert str(cm.value) == "Good.unit should be a string, not int: '2342'" def test_unit_argument_is_working_as_expected(setup_good_tests): """unit argument value is passed to the unit attribute.""" data = setup_good_tests test_value = "this is my unit" data["kwargs"]["unit"] = test_value g = Good(**data["kwargs"]) assert g.unit == test_value def test_unit_attribute_is_working_as_expected(setup_good_tests): """unit attribute value can be changed.""" data = setup_good_tests test_value = "this is my unit" g = Good(**data["kwargs"]) assert g.unit != test_value g.unit = test_value assert g.unit == test_value def test_client_argument_is_skipped(setup_good_tests): """Good can be created without a Client.""" data = setup_good_tests data["kwargs"].pop("client", None) g = Good(**data["kwargs"]) assert g is not None assert isinstance(g, Good) def test_client_argument_is_none(setup_good_tests): """Good can be created without a Client.""" data = setup_good_tests data["kwargs"]["client"] = None g = Good(**data["kwargs"]) assert g is not None assert isinstance(g, Good) def test_client_argument_is_not_a_client_instance(setup_good_tests): """TypeError is raised if the client argument is not a Client instance.""" data = setup_good_tests data["kwargs"]["client"] = "not a client" with pytest.raises(TypeError) as cm: Good(**data["kwargs"]) assert str(cm.value) == ( "Good.client attribute should be a stalker.models.client.Client instance, " "not str: 'not a client'" ) def test_client_attribute_is_set_to_a_value_other_than_a_client(setup_good_tests): """TypeError is raised if the client attr is not a Client instance.""" data = setup_good_tests g = Good(**data["kwargs"]) with pytest.raises(TypeError) as cm: g.client = "not a client" assert str(cm.value) == ( "Good.client attribute should be a stalker.models.client.Client instance, " "not str: 'not a client'" ) def test_client_argument_is_working_as_expected(setup_good_tests): """client argument is working as expected.""" data = setup_good_tests client = Client(name="Test Client") data["kwargs"]["client"] = client g = Good(**data["kwargs"]) assert g.client == client def test_client_attribute_is_working_as_expected(setup_good_tests): """client attribute is working as expected.""" data = setup_good_tests client = Client(name="Test Client") g = Good(**data["kwargs"]) assert g.client != client g.client = client assert g.client == client ================================================ FILE: tests/models/test_group.py ================================================ # -*- coding: utf-8 -*- """Tests for the Group class.""" import pytest from stalker import Group, Permission, User @pytest.fixture(scope="function") def setup_group_tests(): """Set up the test for the Group class.""" data = dict() # create a couple of Users data["test_user1"] = User( name="User1", login="user1", password="1234", email="user1@test.com", ) data["test_user2"] = User( name="User2", login="user2", password="1234", email="user1@test.com", ) data["test_user3"] = User( name="User3", login="user3", password="1234", email="user3@test.com", ) # create a test group data["kwargs"] = { "name": "Test Group", "users": [data["test_user1"], data["test_user2"], data["test_user3"]], } data["test_group"] = Group(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Group class.""" assert Group.__auto_name__ is False def test_users_argument_is_skipped(setup_group_tests): """users argument is skipped the users attribute is an empty list.""" data = setup_group_tests data["kwargs"].pop("users") new_group = Group(**data["kwargs"]) assert new_group.users == [] def test_users_argument_is_not_a_list_of_user_instances(setup_group_tests): """TypeError is raised if the users argument is not a list of User instances.""" data = setup_group_tests data["kwargs"]["users"] = [12, "not a user"] with pytest.raises(TypeError) as cm: Group(**data["kwargs"]) assert str(cm.value) == ( "Group.users should only contain instances of " "stalker.models.auth.User, not int: '12'" ) def test_users_attribute_is_not_a_list_of_user_instances(setup_group_tests): """TypeError is raised if the users attribute is not a list of User instances.""" data = setup_group_tests with pytest.raises(TypeError) as cm: data["test_group"].users = [12, "not a user"] assert str(cm.value) == ( "Group.users should only contain instances of " "stalker.models.auth.User, not int: '12'" ) def test_users_argument_updates_the_groups_attribute_in_the_given_user_instances( setup_group_tests, ): """users arg will have the current Group instance in their groups attribute.""" data = setup_group_tests data["kwargs"]["name"] = "New Group" new_group = Group(**data["kwargs"]) assert all(new_group in user.groups for user in data["kwargs"]["users"]) def test_users_attribute_updates_the_groups_attribute_in_the_given_user_instances( setup_group_tests, ): """users attr will have the current Group instance in their groups attribute.""" data = setup_group_tests test_users = data["kwargs"].pop("users") new_group = Group(**data["kwargs"]) new_group.users = test_users assert all(new_group in user.groups for user in test_users) def test_permissions_argument_is_working_as_expected(setup_group_tests): """permissions can be added to the Group on __init__().""" data = setup_group_tests # create a couple of permissions perm1 = Permission("Allow", "Create", "User") perm2 = Permission("Allow", "Read", "User") perm3 = Permission("Deny", "Delete", "User") new_group = Group( name="Test Group", users=[data["test_user1"], data["test_user2"]], permissions=[perm1, perm2, perm3], ) assert new_group.permissions == [perm1, perm2, perm3] def test_hash_value(setup_group_tests): """__hash__ returns the hash of the Group instance.""" data = setup_group_tests result = hash(data["test_group"]) assert isinstance(result, int) ================================================ FILE: tests/models/test_image_format.py ================================================ # -*- coding: utf-8 -*- """Tests for the ImageFormat class.""" import sys import pytest from stalker import ImageFormat @pytest.fixture(scope="function") def setup_image_format_tests(): """Set up test data for the ImageFormat.""" data = dict() # some proper values data["kwargs"] = { "name": "HD", "width": 1920, "height": 1080, "pixel_aspect": 1.0, "print_resolution": 300, } data["test_image_format"] = ImageFormat(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for ImageFormat class.""" assert ImageFormat.__auto_name__ is False def test_width_argument_accepts_int_or_float_only(setup_image_format_tests): """TypeError is raised if the width argument is not integer or float.""" data = setup_image_format_tests # the width should be an integer or float test_value = "1920" data["kwargs"]["width"] = test_value with pytest.raises(TypeError) as cm: ImageFormat(**data["kwargs"]) assert ( str(cm.value) == "ImageFormat.width should be an instance of int or float, not str: '1920'" ) def test_width_attribute_int_or_float(setup_image_format_tests): """TypeError is raised if the width attribute is not an integer or float.""" data = setup_image_format_tests test_value = "1920" with pytest.raises(TypeError) as cm: data["test_image_format"].width = test_value assert ( str(cm.value) == "ImageFormat.width should be an instance of int or float, not str: '1920'" ) def test_width_argument_float_to_int_conversion(setup_image_format_tests): """width argument is given as a float and converted to int successfully.""" data = setup_image_format_tests # the given floats should be converted to integer data["kwargs"]["width"] = 1920.0 an_image_format = ImageFormat(**data["kwargs"]) assert isinstance(an_image_format.width, int) def test_width_attribute_float_to_int_conversion(setup_image_format_tests): """width attribute against being converted to int successfully.""" data = setup_image_format_tests # the given floats should be converted to integer data["test_image_format"].width = 1920.0 assert isinstance(data["test_image_format"].width, int) def test_width_argument_being_zero(setup_image_format_tests): """ValueError is raised if the width argument is zero.""" data = setup_image_format_tests # could not be zero data["kwargs"]["width"] = 0 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.width cannot be zero or negative" def test_width_attribute_being_zero(setup_image_format_tests): """ValueError is raised if the width attribute is zero.""" data = setup_image_format_tests # also test the attribute for this with pytest.raises(ValueError) as cm: data["test_image_format"].width = 0 assert str(cm.value) == "ImageFormat.width cannot be zero or negative" def test_width_argument_being_negative(setup_image_format_tests): """ValueError is raised if the width argument is negative.""" data = setup_image_format_tests data["kwargs"]["width"] = -10 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.width cannot be zero or negative" def test_width_attribute_being_negative(setup_image_format_tests): """ValueError is raised if the width attribute is negative.""" data = setup_image_format_tests # also test the attribute for this with pytest.raises(ValueError) as cm: data["test_image_format"].width = -100 assert str(cm.value) == "ImageFormat.width cannot be zero or negative" def test_height_argument_int_or_float(setup_image_format_tests): """TypeError is raised if the height argument is not an integer or float.""" data = setup_image_format_tests test_value = "1080" data["kwargs"]["height"] = test_value with pytest.raises(TypeError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == ( "ImageFormat.height should be an instance of int or float, not str: '1080'" ) def test_height_attribute_int_or_float(setup_image_format_tests): """TypeError is raised if the height attribute is not an integer or float.""" data = setup_image_format_tests # test also the attribute test_value = "1080" with pytest.raises(TypeError) as cm: data["test_image_format"].height = test_value assert str(cm.value) == ( "ImageFormat.height should be an instance of int or float, not str: '1080'" ) def test_height_argument_float_to_int_conversion(setup_image_format_tests): """height argument given as float is converted to int successfully.""" data = setup_image_format_tests data["kwargs"]["height"] = 1080.0 an_image_format = ImageFormat(**data["kwargs"]) assert isinstance(an_image_format.height, int) def test_height_attribute_float_to_int_conversion(setup_image_format_tests): """height attribute given as float being converted to int successfully.""" data = setup_image_format_tests # also test the attribute for this data["test_image_format"].height = 1080.0 assert isinstance(data["test_image_format"].height, int) def test_height_argument_being_zero(setup_image_format_tests): """ValueError is raised if the height argument is zero.""" data = setup_image_format_tests data["kwargs"]["height"] = 0 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.height cannot be zero or negative" def test_height_attribute_being_zero(setup_image_format_tests): """ValueError is raised if the height attribute is zero.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].height = 0 assert str(cm.value) == "ImageFormat.height cannot be zero or negative" def test_height_argument_being_negative(setup_image_format_tests): """ValueError is raised if the height argument is negative.""" data = setup_image_format_tests data["kwargs"]["height"] = -10 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.height cannot be zero or negative" def test_height_attribute_being_negative(setup_image_format_tests): """ValueError is raised if the height attribute is negative.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].height = -100 assert str(cm.value) == "ImageFormat.height cannot be zero or negative" def test_device_aspect_attribute_float(setup_image_format_tests): """device aspect ratio is calculated as a float value.""" data = setup_image_format_tests assert isinstance(data["test_image_format"].device_aspect, float) def test_device_aspect_ratio_correctly_calculated_1(setup_image_format_tests): """device aspect ratio is correctly calculated.""" data = setup_image_format_tests # the device aspect is something calculated using width, height and # the pixel aspect ratio # Test HD data["kwargs"].update( { "name": "HD", "width": 1920, "height": 1080, "pixel_aspect": 1.0, "print_resolution": 300, } ) an_image_format = ImageFormat(**data["kwargs"]) # the device aspect for this setup should be around 1.7778 assert "%1.4g" % an_image_format.device_aspect == "%1.4g" % 1.7778 def test_device_aspect_ratio_correctly_calculated_2(setup_image_format_tests): """device aspect ratio is correctly calculated.""" data = setup_image_format_tests # test PAL data["kwargs"].update( { "name": "PAL", "width": 720, "height": 576, "pixel_aspect": 1.0667, "print_resolution": 300, } ) an_image_format = ImageFormat(**data["kwargs"]) # the device aspect for this setup should be around 4/3 assert "%1.4g" % an_image_format.device_aspect == "%1.4g" % 1.3333 def test_device_aspect_attribute_updates(setup_image_format_tests): """device_aspect_ratio attr is updated if width, height or pixel_aspect updated.""" data = setup_image_format_tests # just changing one of the width or height should be causing an update # in device_aspect # start with PAL data["kwargs"].update( { "name": "PAL", "width": 720, "height": 576, "pixel_aspect": 1.0667, "print_resolution": 300, } ) an_image_format = ImageFormat(**data["kwargs"]) previous_device_aspect = an_image_format.device_aspect # change to HD an_image_format.width = 1920 an_image_format.height = 1080 an_image_format.pixel_aspect = 1.0 assert abs(an_image_format.device_aspect - 1.77778) < 0.001 assert an_image_format.device_aspect != previous_device_aspect def test_device_aspect_attribute_write_protected(setup_image_format_tests): """device_aspect attribute is write protected.""" data = setup_image_format_tests # the device aspect should be write protected with pytest.raises(AttributeError) as cm: data["test_image_format"].device_aspect = 10 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'device_aspect'", }.get( sys.version_info.minor, "property 'device_aspect' of 'ImageFormat' object has no setter", ) assert str(cm.value) == error_message def test_pixel_aspect_int_float(setup_image_format_tests): """TypeError is raised if the pixel aspect ratio is not an integer or float.""" data = setup_image_format_tests # the pixel aspect ratio should be a given as float or integer number # any other variable type than int and float is not ok data["kwargs"]["pixel_aspect"] = "1.0" with pytest.raises(TypeError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == ( "ImageFormat.pixel_aspect should be an instance of int or float, " "not str: '1.0'" ) def test_pixel_aspect_int_float_2(setup_image_format_tests): """TypeError is raised if the pixel aspect ratio is not an integer or float.""" data = setup_image_format_tests # float is ok data["kwargs"]["pixel_aspect"] = 1.0 ImageFormat(**data["kwargs"]) def test_pixel_aspect_int_float_3(setup_image_format_tests): """TypeError is raised if the pixel aspect ratio is not an integer or float.""" data = setup_image_format_tests # int is ok data["kwargs"]["pixel_aspect"] = 2 ImageFormat(**data["kwargs"]) def test_pixel_aspect_float_conversion(setup_image_format_tests): """pixel aspect ratio converted to float.""" data = setup_image_format_tests # given an integer for the pixel aspect ratio, # the returned pixel aspect ratio should be a float data["kwargs"]["pixel_aspect"] = 1 an_image_format = ImageFormat(**data["kwargs"]) assert isinstance(an_image_format.pixel_aspect, float) def test_pixel_aspect_argument_zero(setup_image_format_tests): """ValueError is raised if the pixel_aspect argument is zero.""" data = setup_image_format_tests # the pixel aspect ratio cannot be zero data["kwargs"]["pixel_aspect"] = 0 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_attribute_zero(setup_image_format_tests): """ValueError is raised if the pixel_aspect attribute is zero.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].pixel_aspect = 0 assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_argument_negative_float(setup_image_format_tests): """ValueError is raised if pixel_aspect argument is negative.""" data = setup_image_format_tests # the pixel aspect ratio cannot be negative data["kwargs"]["pixel_aspect"] = -1.0 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_argument_negative_int(setup_image_format_tests): """ValueError is raised if pixel_aspect argument is negative.""" data = setup_image_format_tests # the pixel aspect ratio cannot be negative data["kwargs"]["pixel_aspect"] = -1 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_attribute_negative_integer(setup_image_format_tests): """ValueError is raised if pixel_aspect attribute is negative.""" data = setup_image_format_tests # also test the attribute with pytest.raises(ValueError) as cm: data["test_image_format"].pixel_aspect = -1.0 assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_attribute_negative_float(setup_image_format_tests): """ValueError is raised if pixel_aspect attribute is negative.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].pixel_aspect = -1 assert ( str(cm.value) == "ImageFormat.pixel_aspect cannot be zero or a negative value" ) def test_pixel_aspect_attribute_if_being_initialized_correctly( setup_image_format_tests, ): """pixel_aspect attr is correctly initialized to its default value if omitted.""" data = setup_image_format_tests data["kwargs"].pop("pixel_aspect") an_image_format = ImageFormat(**data["kwargs"]) default_value = 1.0 assert an_image_format.pixel_aspect == default_value def test_print_resolution_omit(setup_image_format_tests): """print_resolution against being omitted.""" data = setup_image_format_tests # the print timing_resolution can be omitted data["kwargs"].pop("print_resolution") imf = ImageFormat(**data["kwargs"]) # and the default value should be a float instance assert isinstance(imf.print_resolution, float) def test_print_resolution_argument_accepts_int_float_only(setup_image_format_tests): """TypeError is raised if the print_resolution arg is not an integer or float.""" data = setup_image_format_tests # the print timing_resolution should be initialized with an integer or a float data["kwargs"]["print_resolution"] = "300.0" with pytest.raises(TypeError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == ( "ImageFormat.print_resolution should be an instance of int or float, " "not str: '300.0'" ) def test_print_resolution_argument_accepts_int_float_only_2(setup_image_format_tests): """TypeError is raised if the print_resolution arg is not an integer or float.""" # the print timing_resolution should be initialized with an integer or a float data = setup_image_format_tests data["kwargs"]["print_resolution"] = 300 imf = ImageFormat(**data["kwargs"]) assert isinstance(imf.print_resolution, float) def test_print_resolution_argument_accepts_int_float_only_3(setup_image_format_tests): """TypeError is raised if the print_resolution arg is not an integer or float.""" data = setup_image_format_tests # the print timing_resolution should be initialized with an integer or # a float data["kwargs"]["print_resolution"] = 300.0 imf = ImageFormat(**data["kwargs"]) assert isinstance(imf.print_resolution, float) def test_print_resolution_argument_zero(setup_image_format_tests): """ValueError is raised if the print_resolution argument is zero.""" data = setup_image_format_tests data["kwargs"]["print_resolution"] = 0 # the print timing_resolution cannot be zero with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_print_resolution_attribute_zero(setup_image_format_tests): """ValueError is raised if the print_resolution attribute is zero.""" data = setup_image_format_tests # also test the attribute with pytest.raises(ValueError) as cm: data["test_image_format"].print_resolution = 0 assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_print_resolution_argument_negative_int(setup_image_format_tests): """ValueError is raised if the print_resolution argument is negative.""" data = setup_image_format_tests # the print timing_resolution cannot be negative data["kwargs"]["print_resolution"] = -300 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_print_resolution_argument_negative_float(setup_image_format_tests): """ValueError is raised if the print_resolution argument is negative.""" data = setup_image_format_tests # the print timing_resolution cannot be negative data["kwargs"]["print_resolution"] = -300.0 with pytest.raises(ValueError) as cm: ImageFormat(**data["kwargs"]) assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_print_resolution_attribute_negative_int(setup_image_format_tests): """ValueError is raised if the print_resolution attribute is negative.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].print_resolution = -300 assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_print_resolution_attribute_negative_float(setup_image_format_tests): """ValueError is raised if the print_resolution attribute is negative.""" data = setup_image_format_tests with pytest.raises(ValueError) as cm: data["test_image_format"].print_resolution = -300.0 assert str(cm.value) == "ImageFormat.print_resolution cannot be zero or negative" def test_equality(setup_image_format_tests): """Equality operator.""" data = setup_image_format_tests image_format1 = ImageFormat(**data["kwargs"]) image_format2 = ImageFormat(**data["kwargs"]) data["kwargs"].update( { "width": 720, "height": 480, "pixel_aspect": 0.888888, } ) image_format3 = ImageFormat(**data["kwargs"]) assert image_format1 == image_format2 assert not image_format1 == image_format3 def test_inequality(setup_image_format_tests): """Inequality operator.""" data = setup_image_format_tests image_format1 = ImageFormat(**data["kwargs"]) image_format2 = ImageFormat(**data["kwargs"]) data["kwargs"].update( { "name": "NTSC", "description": "The famous NTSC image format", "width": 720, "height": 480, "pixel_aspect": 0.888888, } ) image_format3 = ImageFormat(**data["kwargs"]) assert not image_format1 != image_format2 assert image_format1 != image_format3 def test_plural_class_name(setup_image_format_tests): """Plural name of ImageFormat class.""" data = setup_image_format_tests assert data["test_image_format"].plural_class_name == "ImageFormats" def test_hash_value(setup_image_format_tests): """hash value is correctly calculated.""" data = setup_image_format_tests assert data["test_image_format"].__hash__() == hash( "{}:{}:{}".format( data["test_image_format"].id, data["test_image_format"].name, data["test_image_format"].entity_type, ) ) ================================================ FILE: tests/models/test_invoice.py ================================================ # -*- coding: utf-8 -*- """Tests for the Invoice class.""" import datetime import pytest import pytz from stalker import ( Budget, Client, Invoice, Project, Repository, Status, StatusList, Type, User, ) @pytest.fixture(scope="function") def setup_invoice_tests(): """Set up invoice class related tests.""" data = dict() data["status_new"] = Status(name="Mew", code="NEW") data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["status_new"] = Status(name="New", code="NEW") data["status_app"] = Status(name="Approved", code="APP") data["budget_status_list"] = StatusList( name="Budget Statuses", target_entity_type="Budget", statuses=[data["status_new"], data["status_prev"], data["status_app"]], ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Task", ) data["test_project_status_list"] = StatusList( name="Project Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type=Project, ) data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234" ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234" ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234" ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234" ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234" ) data["test_client"] = Client( name="Test Client", ) data["test_project"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], status_list=data["test_project_status_list"], repository=data["test_repository"], clients=[data["test_client"]], ) data["test_budget"] = Budget( project=data["test_project"], name="Test Budget 1", status_list=data["budget_status_list"], ) return data def test_creating_an_invoice_instance(setup_invoice_tests): """Creation of an Invoice instance.""" data = setup_invoice_tests invoice = Invoice( budget=data["test_budget"], amount=1500, unit="TL", client=data["test_client"], date_created=datetime.datetime(2016, 11, 7, tzinfo=pytz.utc), ) assert isinstance(invoice, Invoice) def test_budget_argument_is_skipped(setup_invoice_tests): """TypeError is raised if the budget argument is skipped.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice(client=data["test_client"], amount=1500, unit="TRY") assert str(cm.value) == ( "Invoice.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_argument_is_none(setup_invoice_tests): """TypeError is raised if the budget argument is None.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice(budget=None, client=data["test_client"], amount=1500, unit="TRY") assert str(cm.value) == ( "Invoice.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_attribute_is_set_to_none(setup_invoice_tests): """TypeError is raised if the budget attribute is set to None.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=1500, unit="TRY" ) with pytest.raises(TypeError) as cm: test_invoice.budget = None assert str(cm.value) == ( "Invoice.budget should be a Budget instance, not NoneType: 'None'" ) def test_budget_argument_is_not_a_budget_instance(setup_invoice_tests): """TypeError is raised if the Budget argument is not a Budget instance.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice( budget="Not a budget instance", client=data["test_client"], amount=1500, unit="TRY", ) assert str(cm.value) == ( "Invoice.budget should be a Budget instance, not str: 'Not a budget instance'" ) def test_budget_attribute_is_set_to_a_value_other_than_a_budget_instance( setup_invoice_tests, ): """TypeError is raised if the Budget attr is not a Budget instance.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=1500, unit="TRY" ) with pytest.raises(TypeError) as cm: test_invoice.budget = "Not a budget instance" assert str(cm.value) == ( "Invoice.budget should be a Budget instance, not str: 'Not a budget instance'" ) def test_budget_argument_is_working_as_expected(setup_invoice_tests): """budget argument value is passed to the budget attribute.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=1500, unit="TRY" ) assert test_invoice.budget == data["test_budget"] def test_client_argument_is_skipped(setup_invoice_tests): """TypeError is raised if the client argument is skipped.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice(budget=data["test_budget"], amount=100, unit="TRY") assert str(cm.value) == ( "Invoice.client should be a Client instance, not NoneType: 'None'" ) def test_client_argument_is_none(setup_invoice_tests): """TypeError is raised if the client argument is None.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice(budget=data["test_budget"], client=None, amount=100, unit="TRY") assert str(cm.value) == ( "Invoice.client should be a Client instance, not NoneType: 'None'" ) def test_client_attribute_is_set_to_none(setup_invoice_tests): """TypeError is raised if the client attribute is set to None.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=100, unit="TRY" ) with pytest.raises(TypeError) as cm: test_invoice.client = None assert str(cm.value) == ( "Invoice.client should be a Client instance, not NoneType: 'None'" ) def test_client_argument_is_not_a_client_instance(setup_invoice_tests): """TypeError is raised if the client argument is not a Client instance.""" data = setup_invoice_tests with pytest.raises(TypeError) as cm: Invoice( budget=data["test_budget"], client="not a client instance", amount=100, unit="TRY", ) assert str(cm.value) == ( "Invoice.client should be a Client instance, not str: 'not a client instance'" ) def test_client_attribute_is_set_to_a_value_other_than_a_client_instance( setup_invoice_tests, ): """TypeError is raised if the client attr is set to a non Client instance.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=100, unit="TRY" ) with pytest.raises(TypeError) as cm: test_invoice.client = "not a client instance" assert str(cm.value) == ( "Invoice.client should be a Client instance, not str: 'not a client instance'" ) def test_client_argument_is_working_as_expected(setup_invoice_tests): """client argument value is correctly passed to the client attribute.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=100, unit="TRY" ) assert test_invoice.client == data["test_client"] def test_client_attribute_is_working_as_expected(setup_invoice_tests): """client attribute value an be changed.""" data = setup_invoice_tests test_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=100, unit="TRY" ) test_client = Client(name="Test Client 2") assert test_invoice.client != test_client test_invoice.client = test_client assert test_invoice.client == test_client ================================================ FILE: tests/models/test_local_session.py ================================================ # -*- coding: utf-8 -*- """Tests for the LocalSession class.""" import datetime import json import os import shutil import tempfile import pytest import pytz from stalker import LocalSession, User, defaults from stalker.db.session import DBSession from stalker.utils import datetime_to_millis @pytest.fixture(scope="function") def setup_local_session_tester(): """Set up the LocalSession related tests.""" defaults["local_storage_path"] = tempfile.mktemp() yield shutil.rmtree(defaults.local_storage_path, True) def test_save_serializes_the_class_itself(setup_local_session_tester): """save function serializes the class to the filesystem.""" new_local_session = LocalSession() new_local_session.save() # check if a file is created in the users local storage assert os.path.exists( os.path.join(defaults.local_storage_path, defaults.local_session_data_file_name) ) def test_save_serializes_the_class_itself_with_real_data(setup_local_session_tester): """save function serializes the class to the filesystem.""" new_local_session = LocalSession() new_local_session.logged_in_user_id = 1 new_local_session.save() # check if a file is created in the users local storage assert os.path.exists( os.path.join(defaults.local_storage_path, defaults.local_session_data_file_name) ) def test_local_session_initialized_with_previous_session_data( setup_local_session_tester, ): """new LocalSession instance the class is restored from previous session.""" # test data logged_in_user_id = -10 # create a local_session local_session = LocalSession() # store some data local_session.logged_in_user_id = logged_in_user_id local_session.save() # now create a new LocalSession local_session2 = LocalSession() # now try to get the data back assert local_session2.logged_in_user_id == logged_in_user_id def test_delete_will_delete_the_session_cache(setup_local_session_tester): """LocalSession.delete() will delete the current cache file.""" # create a new user new_user = User( name="Test User", login="test_user", email="test_user@users.com", password="secret", ) # save it to the Database new_user.id = 1023 assert new_user.id is not None # save it to the local storage local_session = LocalSession() local_session.store_user(new_user) # save the session local_session.save() # check if the file is created # check if a file is created in the users local storage assert os.path.exists( os.path.join(defaults.local_storage_path, defaults.local_session_data_file_name) ) # now delete the session by calling delete() local_session.delete() # check if the file is gone # check if a file is created in the users local storage assert not os.path.exists( os.path.join(defaults.local_storage_path, defaults.local_session_data_file_name) ) # delete a second time # this should not raise an OSError local_session.delete() def test_local_session_will_not_use_the_stored_data_if_it_is_invalid( setup_postgresql_db, ): """LocalSession will not use the stored session if it is not valid anymore.""" # create a new user new_user = User( name="Test User", login="test_user", email="test_user@users.com", password="secret", ) # save it to the Database DBSession.add(new_user) DBSession.commit() assert new_user.id is not None # save it to the local storage local_session = LocalSession() local_session.store_user(new_user) # save the session local_session.save() # set the valid time to an early date local_session.valid_to = datetime.datetime.now(pytz.utc) - datetime.timedelta(10) # pickle the data data = json.dumps( { "valid_to": datetime_to_millis(local_session.valid_to), "logged_in_user_id": -1, }, ) local_session._write_data(data) # now get it back with a new local_session local_session2 = LocalSession() assert local_session2.logged_in_user_id is None assert local_session2.logged_in_user is None def test_logged_in_user_returns_the_stored_user_instance_from_last_time( setup_postgresql_db, ): """logged_in_user returns the logged in user.""" # create a new user new_user = User( name="Test User", login="test_user", email="test_user@users.com", password="secret", ) # save it to the Database DBSession.add(new_user) DBSession.commit() assert new_user.id is not None # save it to the local storage local_session = LocalSession() local_session.store_user(new_user) # save the session local_session.save() # now get it back with a new local_session local_session2 = LocalSession() assert local_session2.logged_in_user_id == new_user.id assert local_session2.logged_in_user == new_user ================================================ FILE: tests/models/test_message.py ================================================ # -*- coding: utf-8 -*- """Tests related to the Message class.""" from stalker import Message, Status, StatusList def test_message_instance_creation(): """message instance creation.""" status_unread = Status(name="Unread", code="UR") status_read = Status(name="Read", code="READ") status_replied = Status(name="Replied", code="REP") message_status_list = StatusList( name="Message Statuses", statuses=[status_unread, status_read, status_replied], target_entity_type="Message", ) new_message = Message( description="This is a test message", status_list=message_status_list ) assert new_message.description == "This is a test message" ================================================ FILE: tests/models/test_note.py ================================================ # -*- coding: utf-8 -*- """Tests for the Not class.""" import pytest from stalker import Note @pytest.fixture(scope="function") def setup_note_tests(): """Set up the test Note related tests.""" data = dict() data["kwargs"] = { "name": "Note to something", "description": "this is a simple note", "content": "this is a note content", } # create a Note object data["test_note"] = Note(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for Note class.""" assert Note.__auto_name__ is True def test_content_argument_is_missing(setup_note_tests): """Nothing is going to happen if no content argument is given.""" data = setup_note_tests data["kwargs"].pop("content") new_note = Note(**data["kwargs"]) assert isinstance(new_note, Note) def test_content_argument_is_set_to_none(setup_note_tests): """nothing is going to happen if content argument is given as None.""" data = setup_note_tests data["kwargs"]["content"] = None new_note = Note(**data["kwargs"]) assert isinstance(new_note, Note) def test_content_attribute_is_set_to_none(setup_note_tests): """nothing is going to happen if content attribute is set to None.""" data = setup_note_tests # nothing should happen data["test_note"].content = None def test_content_argument_is_set_to_empty_string(setup_note_tests): """nothing is going to happen if content argument is given as an empty string.""" data = setup_note_tests data["kwargs"]["content"] = "" Note(**data["kwargs"]) def test_content_attribute_is_set_to_empty_string(setup_note_tests): """nothing is going to happen if content argument is set to an empty string.""" data = setup_note_tests # nothing should happen data["test_note"].content = "" def test_content_argument_is_set_to_something_other_than_a_string(setup_note_tests): """TypeError is raised if content arg is not a str.""" data = setup_note_tests test_value = 1.24 data["kwargs"]["content"] = test_value with pytest.raises(TypeError) as cm: Note(**data["kwargs"]) assert str(cm.value) == "Note.description should be a string, not float: '1.24'" def test_content_attribute_is_set_to_something_other_than_a_string(setup_note_tests): """TypeError is raised if content attr is not a string.""" data = setup_note_tests test_value = 1 with pytest.raises(TypeError) as cm: data["test_note"].content = test_value assert str(cm.value) == "Note.description should be a string, not int: '1'" def test_content_attribute_is_working_as_expected(setup_note_tests): """content attribute is working as expected.""" data = setup_note_tests new_content = ( "This is my new content for the note, and I expect it to " "work fine if I assign it to a Note object" ) data["test_note"].content = new_content assert data["test_note"].content == new_content def test_equality_operator(setup_note_tests): """Equality operator.""" data = setup_note_tests note1 = Note(**data["kwargs"]) note2 = Note(**data["kwargs"]) data["kwargs"]["content"] = "this is a different content" note3 = Note(**data["kwargs"]) assert note1 == note2 assert not note1 == note3 def test_inequality_operator(setup_note_tests): """Inequality operator.""" data = setup_note_tests note1 = Note(**data["kwargs"]) note2 = Note(**data["kwargs"]) data["kwargs"]["content"] = "this is a different content" note3 = Note(**data["kwargs"]) assert not note1 != note2 assert note1 != note3 def test_plural_class_name(setup_note_tests): """plural name of Note class.""" data = setup_note_tests assert data["test_note"].plural_class_name == "Notes" def test__hash__is_working_as_expected(setup_note_tests): """__hash__ is working as expected.""" data = setup_note_tests result = hash(data["test_note"]) assert isinstance(result, int) assert result == data["test_note"].__hash__() ================================================ FILE: tests/models/test_payment.py ================================================ # -*- coding: utf-8 -*- """Tests for the Payment class.""" import pytest from stalker import ( Budget, Client, Invoice, Payment, Project, Repository, Status, StatusList, Type, User, ) @pytest.fixture(scope="function") def setup_payment_tests(): """Payment class related tests.""" data = dict() data["status_new"] = Status(name="Mew", code="NEW") data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["status_new"] = Status(name="New", code="NEW") data["status_app"] = Status(name="Approved", code="APP") data["budget_status_list"] = StatusList( name="Budget Statuses", target_entity_type="Budget", statuses=[data["status_new"], data["status_prev"], data["status_app"]], ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Task", ) data["test_project_status_list"] = StatusList( name="Project Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type=Project, ) data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type=Project, ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type=Repository, ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234" ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234" ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234" ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234" ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234" ) data["test_client"] = Client( name="Test Client", ) data["test_project"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], status_list=data["test_project_status_list"], repository=data["test_repository"], clients=[data["test_client"]], ) data["test_budget"] = Budget( project=data["test_project"], name="Test Budget 1", status_list=data["budget_status_list"], ) data["test_invoice"] = Invoice( budget=data["test_budget"], client=data["test_client"], amount=1500, unit="TRY" ) return data def test_creating_a_payment_instance(setup_payment_tests): """Payment instance creation.""" data = setup_payment_tests payment = Payment(invoice=data["test_invoice"], amount=1000, unit="TRY") assert isinstance(payment, Payment) def test_invoice_argument_is_skipped(setup_payment_tests): """TypeError is raised if the invoice argument is skipped.""" data = setup_payment_tests with pytest.raises(TypeError) as cm: _ = Payment(amount=1499, unit="TRY") assert str(cm.value) == ( "Payment.invoice should be an Invoice instance, not NoneType: 'None'" ) def test_invoice_argument_is_none(setup_payment_tests): """TypeError is raised if the invoice argument is None.""" with pytest.raises(TypeError) as cm: _ = Payment(invoice=None, amount=1499, unit="TRY") assert str(cm.value) == ( "Payment.invoice should be an Invoice instance, not NoneType: 'None'" ) def test_invoice_attribute_is_none(setup_payment_tests): """TypeError is raised if the invoice attribute is set to None.""" data = setup_payment_tests p = Payment(invoice=data["test_invoice"], amount=1499, unit="TRY") with pytest.raises(TypeError) as cm: p.invoice = None assert str(cm.value) == ( "Payment.invoice should be an Invoice instance, not NoneType: 'None'" ) def test_invoice_argument_is_not_an_invoice_instance(): """TypeError is raised if the invoice argument is not an Invoice instance.""" with pytest.raises(TypeError) as cm: _ = Payment(invoice="not an invoice instance", amount=1499, unit="TRY") assert str(cm.value) == ( "Payment.invoice should be an Invoice instance, " "not str: 'not an invoice instance'" ) def test_invoice_attribute_is_set_to_a_value_other_than_an_invoice_instance( setup_payment_tests, ): """TypeError is raised if the invoice attr is not an Invoice instance.""" data = setup_payment_tests p = Payment(invoice=data["test_invoice"], amount=1499, unit="TRY") with pytest.raises(TypeError) as cm: p.invoice = "not an invoice instance" assert str(cm.value) == ( "Payment.invoice should be an Invoice instance, " "not str: 'not an invoice instance'" ) def test_invoice_argument_is_working_as_expected(setup_payment_tests): """invoice argument value is correctly passed to the invoice attribute.""" data = setup_payment_tests p = Payment(invoice=data["test_invoice"], amount=1499, unit="TRY") assert p.invoice == data["test_invoice"] def test_invoice_attribute_is_working_as_expected(setup_payment_tests): """invoice attribute value can be correctly changed.""" data = setup_payment_tests p = Payment(invoice=data["test_invoice"], amount=1499, unit="TRY") new_invoice = Invoice( budget=data["test_budget"], client=data["test_client"], amount=2500, unit="TRY" ) assert p.invoice != new_invoice p.invoice = new_invoice assert p.invoice == new_invoice ================================================ FILE: tests/models/test_permission.py ================================================ # -*- coding: utf-8 -*- """Permission class tests.""" import sys import pytest from stalker.models.auth import Permission @pytest.fixture(scope="function") def setup_permission_tests(): """Set up the tests for the Permission class.""" data = dict() data["kwargs"] = {"access": "Allow", "action": "Create", "class_name": "Project"} data["test_permission"] = Permission(**data["kwargs"]) return data def test_access_argument_is_skipped(setup_permission_tests): """TypeError is raised if the access argument is skipped.""" data = setup_permission_tests data["kwargs"].pop("access") with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert ( str(cm.value) == "__init__() missing 1 required positional argument: 'access'" ) def test_access_argument_is_none(setup_permission_tests): """TypeError is raised if the access argument is None.""" data = setup_permission_tests data["kwargs"]["access"] = None with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert str(cm.value) == ( "Permission.access should be an instance of str, not NoneType: 'None'" ) def test_access_argument_accepts_only_allow_or_deny_as_value(setup_permission_tests): """ValueError is raised if access is something other than 'Allow' or 'Deny'.""" data = setup_permission_tests data["kwargs"]["access"] = "Allowed" with pytest.raises(ValueError) as cm: Permission(**data["kwargs"]) assert str(cm.value) == 'Permission.access should be "Allow" or "Deny" not Allowed' def test_access_argument_is_setting_access_attribute_value(setup_permission_tests): """access argument is setting the access attribute value correctly.""" data = setup_permission_tests assert data["kwargs"]["access"] == data["test_permission"].access def test_access_attribute_is_read_only(setup_permission_tests): """access attribute is read only.""" data = setup_permission_tests with pytest.raises(AttributeError) as cm: data["test_permission"].access = "Deny" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Permission' object has no setter", 12: "property of 'Permission' object has no setter", }.get( sys.version_info.minor, "property '_access_getter' of 'Permission' object has no setter", ) assert str(cm.value) == error_message def test_action_argument_is_skipped_will_raise_a_type_error(setup_permission_tests): """TypeError is raised if the action argument is skipped.""" data = setup_permission_tests data["kwargs"].pop("action") with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert ( str(cm.value) == "__init__() missing 1 required positional argument: 'action'" ) def test_action_argument_is_none(setup_permission_tests): """TypeError is raised if the action argument is set to None.""" data = setup_permission_tests data["kwargs"]["action"] = None with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert str(cm.value) == ( "Permission.action should be an instance of str, not NoneType: 'None'" ) def test_action_argument_accepts_default_values_only(setup_permission_tests): """ValueError is raised if the action arg is not in the defaults.DEFAULT_ACTIONS.""" data = setup_permission_tests data["kwargs"]["action"] = "Add" with pytest.raises(ValueError) as cm: Permission(**data["kwargs"]) assert ( str(cm.value) == "Permission.action should be one of the values of ['Create', " "'Read', 'Update', 'Delete', 'List'] not 'Add'" ) def test_action_argument_is_setting_the_argument_attribute(setup_permission_tests): """action argument is setting the argument attribute value.""" data = setup_permission_tests assert data["kwargs"]["action"] == data["test_permission"].action def test_action_attribute_is_read_only(setup_permission_tests): """action attribute is read only.""" data = setup_permission_tests with pytest.raises(AttributeError) as cm: data["test_permission"].action = "Add" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Permission' object has no setter", 12: "property of 'Permission' object has no setter", }.get( sys.version_info.minor, "property '_action_getter' of 'Permission' object has no setter", ) assert str(cm.value) == error_message def test_class_name_argument_skipped(setup_permission_tests): """TypeError is raised if the class_name argument is skipped.""" data = setup_permission_tests data["kwargs"].pop("class_name") with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert ( str(cm.value) == "__init__() missing 1 required positional argument: " "'class_name'" ) def test_class_name_argument_is_none(setup_permission_tests): """TypeError is raised if the class_name argument is None.""" data = setup_permission_tests data["kwargs"]["class_name"] = None with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert str(cm.value) == ( "Permission.class_name should be an instance of str, not NoneType: 'None'" ) def test_class_name_argument_is_not_a_string(setup_permission_tests): """TypeError is raised if the class_name argument is not a string instance.""" data = setup_permission_tests data["kwargs"]["class_name"] = 10 with pytest.raises(TypeError) as cm: Permission(**data["kwargs"]) assert str(cm.value) == ( "Permission.class_name should be an instance of str, not int: '10'" ) def test_class_name_argument_is_setting_the_class_name_attribute_value( setup_permission_tests, ): """class_name argument value is correctly passed to the class_name attribute.""" data = setup_permission_tests assert data["test_permission"].class_name == data["kwargs"]["class_name"] def test_class_name_attribute_is_read_only(setup_permission_tests): """class_name attribute is read only.""" data = setup_permission_tests with pytest.raises(AttributeError) as cm: data["test_permission"].class_name = "Asset" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Permission' object has no setter", 12: "property of 'Permission' object has no setter", }.get( sys.version_info.minor, "property '_class_name_getter' of 'Permission' object has no setter", ) assert str(cm.value) == error_message def test_hash_value(setup_permission_tests): """__hash__ returns the hash of the Permission instance.""" data = setup_permission_tests result = hash(data["test_permission"]) assert isinstance(result, int) ================================================ FILE: tests/models/test_price_list.py ================================================ # -*- coding: utf-8 -*- """Tests for the PriceList class.""" import pytest from stalker import Good, PriceList @pytest.fixture(scope="function") def setup_price_list_tests(): """Set up tests for the PriceList class.""" data = dict() data["kwargs"] = { "name": "Test Price List", } return data def test_goods_argument_is_skipped(setup_price_list_tests): """goods attribute is an empty list if the goods argument is skipped.""" data = setup_price_list_tests p = PriceList(**data["kwargs"]) assert p.goods == [] def test_goods_argument_is_none(setup_price_list_tests): """goods attribute is an empty list if the goods argument is None.""" data = setup_price_list_tests data["kwargs"]["goods"] = None p = PriceList(**data["kwargs"]) assert p.goods == [] def test_goods_attribute_is_none(setup_price_list_tests): """TypeError is raised if the goods attribute is set to None.""" data = setup_price_list_tests g1 = Good(name="Test Good") data["kwargs"]["goods"] = [g1] p = PriceList(**data["kwargs"]) assert p.goods == [g1] with pytest.raises(TypeError) as cm: p.goods = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_goods_argument_is_not_a_list(setup_price_list_tests): """TypeError is raised if the goods argument value is not a list.""" data = setup_price_list_tests data["kwargs"]["goods"] = "this is not a list" with pytest.raises(TypeError) as cm: PriceList(**data["kwargs"]) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_goods_attribute_is_not_a_list(setup_price_list_tests): """TypeError is raised if the goods attribute is not a list.""" data = setup_price_list_tests g1 = Good(name="Test Good") data["kwargs"]["goods"] = [g1] p = PriceList(**data["kwargs"]) with pytest.raises(TypeError) as cm: p.goods = "this is not a list" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_goods_argument_is_a_list_of_objects_which_are_not_goods( setup_price_list_tests, ): """TypeError is raised if the goods argument is not a list of Good instances.""" data = setup_price_list_tests data["kwargs"]["goods"] = ["not", 1, "good", "instances"] with pytest.raises(TypeError) as cm: PriceList(**data["kwargs"]) assert str(cm.value) == ( "PriceList.goods should only contain instances of " "stalker.model.budget.Good, not str: 'not'" ) def test_good_attribute_is_a_list_of_objects_which_are_not_goods( setup_price_list_tests, ): """TypeError is raised if the goods attribute is not a list of Good instances.""" data = setup_price_list_tests p = PriceList(**data["kwargs"]) with pytest.raises(TypeError) as cm: p.goods = ["not", 1, "good", "instances"] assert str(cm.value) == ( "PriceList.goods should only contain instances of " "stalker.model.budget.Good, not str: 'not'" ) def test_good_argument_is_working_as_expected(setup_price_list_tests): """good argument value is passed to the good attribute.""" data = setup_price_list_tests g1 = Good(name="Good1") g2 = Good(name="Good2") g3 = Good(name="Good3") test_value = [g1, g2, g3] data["kwargs"]["goods"] = test_value p = PriceList(**data["kwargs"]) assert p.goods == test_value def test_good_attribute_is_working_as_expected(setup_price_list_tests): """good attribute value can be set.""" data = setup_price_list_tests g1 = Good(name="Good1") g2 = Good(name="Good2") g3 = Good(name="Good3") test_value = [g1, g2, g3] p = PriceList(**data["kwargs"]) assert p.goods != test_value p.goods = test_value assert p.goods == test_value ================================================ FILE: tests/models/test_project.py ================================================ # -*- coding: utf-8 -*- """Tests for the Project class.""" import datetime import logging import re import sys import pytest import pytz from stalker import ( log, Asset, Entity, Client, ImageFormat, File, Project, Repository, Sequence, Shot, Status, StatusList, Structure, Task, Ticket, TimeLog, Type, User, ) from stalker.db.session import DBSession from stalker.models.ticket import FIXED, CANTFIX, INVALID logger = logging.getLogger("stalker.models.project") log.register_logger(logger) def condition_tjp_output(data: str) -> str: """Conditions the tjp output. Args: data (str): The data. Returns: str: The formatted data. """ assert isinstance(data, str) data_out = re.subn(r"[\s]+", " ", data)[0] return data_out @pytest.mark.parametrize( "get_data_file", [["project_to_tjp_output_rendered", "project_to_tjp_output_formatted"]], indirect=True, ) def test_condition_tjp_output_is_working_as_expected(get_data_file): """condition_tjp_output_is_working_as_expected.""" test_data = get_data_file rendered_path = test_data[0] formatted_path = test_data[1] with open(rendered_path, "r") as f: rendered_data = f.read() with open(formatted_path, "r") as f: formatted_data = f.read() assert condition_tjp_output(rendered_data) == formatted_data @pytest.fixture(scope="function") def setup_project_db_test(setup_postgresql_db): """Set up the Project class tests.""" data = dict() # create test objects data["start"] = datetime.datetime(2016, 11, 17, tzinfo=pytz.utc) data["end"] = data["start"] + datetime.timedelta(days=20) data["test_lead"] = User( name="lead", login="lead", email="lead@lead.com", password="lead" ) data["test_user1"] = User( name="User1", login="user1", email="user1@users.com", password="123456" ) data["test_user2"] = User( name="User2", login="user2", email="user2@users.com", password="123456" ) data["test_user3"] = User( name="User3", login="user3", email="user3@users.com", password="123456" ) data["test_user4"] = User( name="User4", login="user4", email="user4@users.com", password="123456" ) data["test_user5"] = User( name="User5", login="user5", email="user5@users.com", password="123456" ) data["test_user6"] = User( name="User6", login="user6", email="user6@users.com", password="123456" ) data["test_user7"] = User( name="User7", login="user7", email="user7@users.com", password="123456" ) data["test_user8"] = User( name="User8", login="user8", email="user8@users.com", password="123456" ) data["test_user9"] = User( name="user9", login="user9", email="user9@users.com", password="123456" ) data["test_user10"] = User( name="User10", login="user10", email="user10@users.com", password="123456" ) data["test_user_client"] = User( name="User Client", login="userClient", email="user@client.com", password="123456", ) DBSession.save( [ data["test_lead"], data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], data["test_user6"], data["test_user7"], data["test_user8"], data["test_user9"], data["test_user10"], data["test_user_client"], ] ) data["test_image_format"] = ImageFormat( name="HD", width=1920, height=1080, ) # type for project data["test_project_type"] = Type( name="Project Type 1", code="projt1", target_entity_type="Project" ) data["test_project_type2"] = Type( name="Project Type 2", code="projt2", target_entity_type="Project" ) # type for structure data["test_structure_type1"] = Type( name="Structure Type 1", code="struct1", target_entity_type="Structure" ) data["test_structure_type2"] = Type( name="Structure Type 2", code="struct2", target_entity_type="Structure" ) data["test_project_structure"] = Structure( name="Project Structure 1", type=data["test_structure_type1"], ) data["test_project_structure2"] = Structure( name="Project Structure 2", type=data["test_structure_type2"], ) data["test_repo1"] = Repository( name="Commercials Repository 1", code="CR1", ) data["test_repo2"] = Repository(name="Commercials Repository 2", code="CR2") data["test_client"] = Client(name="Test Company", users=[data["test_user_client"]]) DBSession.save( [ data["test_image_format"], data["test_project_type"], data["test_project_type2"], data["test_structure_type1"], data["test_structure_type2"], data["test_project_structure"], data["test_project_structure2"], data["test_repo1"], data["test_repo2"], data["test_client"], ] ) # create a project object data["kwargs"] = { "name": "Test Project", "code": "tp", "description": "This is a project object for testing purposes", "image_format": data["test_image_format"], "fps": 25, "type": data["test_project_type"], "structure": data["test_project_structure"], "repositories": [data["test_repo1"], data["test_repo2"]], "is_stereoscopic": False, "display_width": 15, "start": data["start"], "end": data["end"], "clients": [data["test_client"]], } data["test_project"] = Project(**data["kwargs"]) DBSession.add(data["test_project"]) DBSession.commit() # sequences without tasks data["test_seq1"] = Sequence( name="Seq1", code="Seq1", project=data["test_project"], resources=[data["test_user1"]], responsible=[data["test_user1"]], ) data["test_seq2"] = Sequence( name="Seq2", code="Seq2", project=data["test_project"], resources=[data["test_user2"]], responsible=[data["test_user2"]], ) data["test_seq3"] = Sequence( name="Seq3", code="Seq3", project=data["test_project"], resources=[data["test_user3"]], responsible=[data["test_user1"]], ) # sequences with tasks data["test_seq4"] = Sequence( name="Seq4", code="Seq4", project=data["test_project"], responsible=[data["test_user1"]], ) data["test_seq5"] = Sequence( name="Seq5", code="Seq5", project=data["test_project"], responsible=[data["test_user1"]], ) # sequences without tasks but with shots data["test_seq6"] = Sequence( name="Seq6", code="Seq6", project=data["test_project"], responsible=[data["test_user1"]], ) data["test_seq7"] = Sequence( name="Seq7", code="Seq7", project=data["test_project"], responsible=[data["test_user1"]], ) # shots data["test_shot1"] = Shot( code="SH001", project=data["test_project"], sequence=data["test_seq6"], responsible=[data["test_lead"]], ) data["test_shot2"] = Shot( code="SH002", project=data["test_project"], sequence=data["test_seq6"], responsible=[data["test_lead"]], ) data["test_shot3"] = Shot( code="SH003", project=data["test_project"], sequence=data["test_seq7"], responsible=[data["test_lead"]], ) data["test_shot4"] = Shot( code="SH004", project=data["test_project"], sequence=data["test_seq7"], responsible=[data["test_lead"]], ) # asset types data["asset_type"] = Type( name="Character", code="char", target_entity_type="Asset", ) # assets without tasks data["test_asset1"] = Asset( name="Test Asset 1", code="ta1", type=data["asset_type"], project=data["test_project"], resources=[data["test_user2"]], responsible=[data["test_lead"]], ) data["test_asset2"] = Asset( name="Test Asset 2", code="ta2", type=data["asset_type"], project=data["test_project"], responsible=[data["test_lead"]], ) data["test_asset3"] = Asset( name="Test Asset 3", code="ta3", type=data["asset_type"], project=data["test_project"], responsible=[data["test_lead"]], ) # assets with tasks data["test_asset4"] = Asset( name="Test Asset 4", code="ta4", type=data["asset_type"], project=data["test_project"], responsible=[data["test_lead"]], ) data["test_asset5"] = Asset( name="Test Asset 5", code="ta5", type=data["asset_type"], project=data["test_project"], ) # for project data["test_task1"] = Task( name="Test Task 1", project=data["test_project"], resources=[data["test_user1"]], ) data["test_task2"] = Task( name="Test Task 2", project=data["test_project"], resources=[data["test_user2"]], ) data["test_task3"] = Task( name="Test Task 3", project=data["test_project"], resources=[data["test_user3"]], ) # for sequence4 data["test_task4"] = Task( name="Test Task 4", parent=data["test_seq4"], resources=[data["test_user4"]], ) data["test_task5"] = Task( name="Test Task 5", parent=data["test_seq4"], resources=[data["test_user5"]], ) data["test_task6"] = Task( name="Test Task 6", parent=data["test_seq4"], resources=[data["test_user6"]], ) # for sequence5 data["test_task7"] = Task( name="Test Task 7", parent=data["test_seq5"], resources=[data["test_user7"]], ) data["test_task8"] = Task( name="Test Task 8", parent=data["test_seq5"], resources=[data["test_user8"]], ) data["test_task9"] = Task( name="Test Task 9", parent=data["test_seq5"], resources=[data["test_user9"]], ) # for shot1 of sequence6 data["test_task10"] = Task( name="Test Task 10", parent=data["test_shot1"], resources=[data["test_user10"]], schedule_timing=10, ) data["test_task11"] = Task( name="Test Task 11", parent=data["test_shot1"], resources=[data["test_user1"], data["test_user2"]], ) data["test_task12"] = Task( name="Test Task 12", parent=data["test_shot1"], resources=[data["test_user3"], data["test_user4"]], ) # for shot2 of sequence6 data["test_task13"] = Task( name="Test Task 13", parent=data["test_shot2"], resources=[data["test_user5"], data["test_user6"]], ) data["test_task14"] = Task( name="Test Task 14", parent=data["test_shot2"], resources=[data["test_user7"], data["test_user8"]], ) data["test_task15"] = Task( name="Test Task 15", parent=data["test_shot2"], resources=[data["test_user9"], data["test_user10"]], ) # for shot3 of sequence7 data["test_task16"] = Task( name="Test Task 16", parent=data["test_shot3"], resources=[data["test_user1"], data["test_user2"], data["test_user3"]], ) data["test_task17"] = Task( name="Test Task 17", parent=data["test_shot3"], resources=[data["test_user4"], data["test_user5"], data["test_user6"]], ) data["test_task18"] = Task( name="Test Task 18", parent=data["test_shot3"], resources=[data["test_user7"], data["test_user8"], data["test_user9"]], ) # for shot4 of sequence7 data["test_task19"] = Task( name="Test Task 19", parent=data["test_shot4"], resources=[data["test_user10"], data["test_user1"], data["test_user2"]], ) data["test_task20"] = Task( name="Test Task 20", parent=data["test_shot4"], resources=[data["test_user3"], data["test_user4"], data["test_user5"]], ) data["test_task21"] = Task( name="Test Task 21", parent=data["test_shot4"], resources=[data["test_user6"], data["test_user7"], data["test_user8"]], ) # for asset4 data["test_task22"] = Task( name="Test Task 22", parent=data["test_asset4"], resources=[data["test_user9"], data["test_user10"], data["test_user1"]], ) data["test_task23"] = Task( name="Test Task 23", parent=data["test_asset4"], resources=[data["test_user2"], data["test_user3"]], ) data["test_task24"] = Task( name="Test Task 24", parent=data["test_asset4"], resources=[data["test_user4"], data["test_user5"]], ) # for asset5 data["test_task25"] = Task( name="Test Task 25", parent=data["test_asset5"], resources=[data["test_user6"], data["test_user7"]], ) data["test_task26"] = Task( name="Test Task 26", parent=data["test_asset5"], resources=[data["test_user8"], data["test_user9"]], ) data["test_task27"] = Task( name="Test Task 27", parent=data["test_asset5"], resources=[data["test_user10"], data["test_user1"]], ) # final task hierarchy # test_seq1 # test_seq2 # test_seq3 # # test_seq4 # test_task4 # test_task5 # test_task6 # test_seq5 # test_task7 # test_task8 # test_task9 # test_seq6 # test_seq7 # # test_shot1 # test_task10 # test_task11 # test_task12 # test_shot2 # test_task13 # test_task14 # test_task15 # test_shot3 # test_task16 # test_task17 # test_task18 # test_shot4 # test_task19 # test_task20 # test_task21 # # test_asset1 # test_asset2 # test_asset3 # test_asset4 # test_task22 # test_task23 # test_task24 # test_asset5 # test_task25 # test_task26 # test_task27 # # test_task1 # test_task2 # test_task3 DBSession.save( [ data["test_seq1"], data["test_seq2"], data["test_seq3"], data["test_seq4"], data["test_seq5"], data["test_seq6"], data["test_seq7"], data["test_shot1"], data["test_shot2"], data["test_shot3"], data["test_shot4"], data["test_asset1"], data["test_asset2"], data["test_asset3"], data["test_asset4"], data["test_asset5"], data["test_task1"], data["test_task2"], data["test_task3"], data["test_task4"], data["test_task5"], data["test_task6"], data["test_task7"], data["test_task8"], data["test_task9"], data["test_task10"], data["test_task11"], data["test_task12"], data["test_task13"], data["test_task14"], data["test_task15"], data["test_task16"], data["test_task17"], data["test_task18"], data["test_task19"], data["test_task20"], data["test_task21"], data["test_task22"], data["test_task23"], data["test_task24"], data["test_task25"], data["test_task26"], data["test_task27"], ] ) DBSession.add(data["test_project"]) DBSession.commit() return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Project class.""" assert Project.__auto_name__ is False def test_setup_is_working_correctly(setup_project_db_test): """Setup is done correctly.""" data = setup_project_db_test assert isinstance(data["test_project_type"], Type) assert isinstance(data["test_project_type2"], Type) def test_sequences_attribute_is_read_only(setup_project_db_test): """Sequence attribute is read-only.""" data = setup_project_db_test with pytest.raises(AttributeError) as cm: data["test_project"].sequences = ["some non sequence related data"] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'sequences'", }.get( sys.version_info.minor, "property 'sequences' of 'Project' object has no setter" ) assert str(cm.value) == error_message def test_assets_attribute_is_read_only(setup_project_db_test): """assets attribute is read only.""" data = setup_project_db_test with pytest.raises(AttributeError) as _: data["test_project"].assets = ["some list"] def test_image_format_argument_is_skipped(setup_project_db_test): """image_format attribute is None if the image_format argument is skipped.""" data = setup_project_db_test data["kwargs"].pop("image_format") new_project = Project(**data["kwargs"]) assert new_project.image_format is None def test_image_format_argument_is_none(setup_project_db_test): """nothing is going to happen if the image_format is set to None.""" data = setup_project_db_test data["kwargs"]["image_format"] = None new_project = Project(**data["kwargs"]) assert new_project.image_format is None def test_image_format_attribute_is_set_to_none(setup_project_db_test): """nothing will happen if the image_format attribute is set to None.""" data = setup_project_db_test data["test_project"].image_format = None def test_image_format_argument_accepts_image_format_only(setup_project_db_test): """TypeError is raised if the image_format argument value is not an ImageFormat.""" data = setup_project_db_test data["kwargs"]["image_format"] = "a str" with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "Project.image_format should be an instance of " "stalker.models.format.ImageFormat, not str: 'a str'" ) def test_image_format_argument_is_working_as_expected(setup_project_db_test): """image_format argument value is correctly passed to the image_format attribute.""" data = setup_project_db_test # and a proper image format data["kwargs"]["image_format"] = data["test_image_format"] new_project = Project(**data["kwargs"]) assert new_project.image_format == data["test_image_format"] @pytest.mark.parametrize("test_value", [1, 1.2, "a str", ["a", "list"], {"a": "dict"}]) def test_image_format_attribute_accepts_image_format_only( setup_project_db_test, test_value ): """TypeError is raised if the image_format attr set not to an ImageFormat.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].image_format = test_value # and a proper image format data["test_project"].image_format = data["test_image_format"] def test_image_format_attribute_works_as_expected(setup_project_db_test): """image_format attribute is working as expected.""" data = setup_project_db_test new_image_format = ImageFormat(name="Foo Image Format", width=10, height=10) data["test_project"].image_format = new_image_format assert data["test_project"].image_format == new_image_format def test_fps_argument_is_skipped(setup_project_db_test): """Default value is used if fps is skipped.""" data = setup_project_db_test data["kwargs"].pop("fps") new_project = Project(**data["kwargs"]) assert new_project.fps == 25.0 def test_fps_attribute_is_set_to_none(setup_project_db_test): """TypeError is raised if the fps attribute is set to None.""" data = setup_project_db_test data["kwargs"]["fps"] = None with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "Project.fps should be a positive float or int, not NoneType: 'None'" ) def test_fps_argument_is_given_as_non_float_or_integer_1(setup_project_db_test): """TypeError is raised if the fps arg is not a float, int.""" data = setup_project_db_test data["kwargs"]["fps"] = "a str" with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "Project.fps should be a positive float or int, not str: 'a str'" ) def test_fps_argument_is_given_as_non_float_or_integer_2(setup_project_db_test): """TypeError is raised if the fps arg not a float or int.""" data = setup_project_db_test data["kwargs"]["fps"] = ["a", "list"] with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "Project.fps should be a positive float or int, not list: '['a', 'list']'" ) def test_fps_attribute_is_given_as_non_float_or_integer_1(setup_project_db_test): """TypeError is raised if the fps attr set not to a float, int.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].fps = "a str" assert str(cm.value) == ( "Project.fps should be a positive float or int, not str: 'a str'" ) def test_fps_attribute_is_given_as_non_float_or_integer_2(setup_project_db_test): """TypeError is raised if the fps attr set not to a float, int.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].fps = ["a", "list"] assert str(cm.value) == ( "Project.fps should be a positive float or int, not list: '['a', 'list']'" ) def test_fps_argument_string_to_float_conversion(setup_project_db_test): """TypeError is raised if a string containing a float has been passed.""" data = setup_project_db_test data["kwargs"]["fps"] = "2.3" with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "Project.fps should be a positive float or int, not str: '2.3'" ) def test_fps_attribute_string_to_float_conversion(setup_project_db_test): """TypeError is raised if the fps attr is set to a string containing a float.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].fps = "2.3" assert str(cm.value) == ( "Project.fps should be a positive float or int, not str: '2.3'" ) def test_fps_attribute_float_conversion(setup_project_db_test): """fps attr is converted to float if the float argument is given as an integer.""" data = setup_project_db_test test_value = 1 data["kwargs"]["fps"] = test_value new_project = Project(**data["kwargs"]) assert isinstance(new_project.fps, float) assert new_project.fps == float(test_value) def test_fps_attribute_float_conversion_2(setup_project_db_test): """fps attribute is converted to float if it is set to an integer value.""" data = setup_project_db_test test_value = 1 data["test_project"].fps = test_value assert isinstance(data["test_project"].fps, float) assert data["test_project"].fps == float(test_value) def test_fps_argument_is_zero(setup_project_db_test): """ValueError is raised if the fps is 0.""" data = setup_project_db_test data["kwargs"]["fps"] = 0 with pytest.raises(ValueError) as cm: Project(**data["kwargs"]) assert str(cm.value) == "Project.fps should be a positive float or int, not 0.0" def test_fps_attribute_is_set_to_zero(setup_project_db_test): """value error is raised if the fps attribute is set to zero.""" data = setup_project_db_test with pytest.raises(ValueError) as cm: data["test_project"].fps = 0 assert str(cm.value) == "Project.fps should be a positive float or int, not 0.0" def test_fps_argument_is_negative(setup_project_db_test): """ValueError is raised if the fps argument is negative.""" data = setup_project_db_test data["kwargs"]["fps"] = -1.0 with pytest.raises(ValueError) as cm: Project(**data["kwargs"]) assert str(cm.value) == "Project.fps should be a positive float or int, not -1.0" def test_fps_attribute_is_negative(setup_project_db_test): """ValueError is raised if the fps attribute is set to a negative value.""" data = setup_project_db_test with pytest.raises(ValueError) as cm: data["test_project"].fps = -1 assert str(cm.value) == "Project.fps should be a positive float or int, not -1.0" def test_repositories_argument_is_skipped(setup_project_db_test): """repositories attr is an empty list if the repositories argument is skipped.""" data = setup_project_db_test data["kwargs"].pop("repositories") p = Project(**data["kwargs"]) assert p.repositories == [] def test_repositories_argument_is_none(setup_project_db_test): """the repositories attr is an empty list if the repositories argument is None.""" data = setup_project_db_test data["kwargs"]["repositories"] = None p = Project(**data["kwargs"]) assert p.repositories == [] def test_repositories_attribute_is_set_to_none(setup_project_db_test): """TypeError is raised if the repositories attribute is set to None.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].repositories = None assert str(cm.value) == "'NoneType' object is not iterable" def test_repositories_argument_is_not_a_list(setup_project_db_test): """TypeError is raised if the repositories argument value is not a list.""" data = setup_project_db_test data["kwargs"]["repositories"] = "not a list" with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert ( str(cm.value) == "ProjectRepository.repositories should be a list of " "stalker.models.repository.Repository instances or derivatives, " "not str: 'n'" ) def test_repositories_attribute_is_not_a_list(setup_project_db_test): """TypeError raised if the repositories attr is set to not a list.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].repositories = "not a list" assert str(cm.value) == ( "ProjectRepository.repositories should be a list of " "stalker.models.repository.Repository instances or derivatives, " "not str: 'n'" ) def test_repositories_argument_is_not_a_list_of_repository_instances( setup_project_db_test, ): """TypeError raised if the repositories arg is not Repository instances.""" data = setup_project_db_test data["kwargs"]["repositories"] = ["not", 1, "list", "of", Repository, "instances"] with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert ( str(cm.value) == "ProjectRepository.repositories should be a list of " "stalker.models.repository.Repository instances or derivatives, " "not str: 'not'" ) def test_repositories_attribute_is_not_a_list_of_repository_instances( setup_project_db_test, ): """TypeError raised if the repositories attr is set to a list of non Repository.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].repositories = [ "not", 1, "list", "of", Repository, "instances", ] assert str(cm.value) == ( "ProjectRepository.repositories should be a list of " "stalker.models.repository.Repository instances or derivatives, not str: 'not'" ) def test_repositories_argument_is_working_as_expected(setup_project_db_test): """repositories argument value is passed to the repositories attr.""" data = setup_project_db_test assert data["test_project"].repositories == data["kwargs"]["repositories"] def test_repositories_attribute_is_working_as_expected(setup_project_db_test): """repository attribute is working as expected.""" data = setup_project_db_test new_repo1 = Repository( name="Some Random Repo", code="SRP", linux_path="/mnt/S/random/repo", windows_path="S:/random/repo", macos_path="/Volumes/S/random/repo", ) assert data["test_project"].repositories != [new_repo1] data["test_project"].repositories = [new_repo1] assert data["test_project"].repositories == [new_repo1] def test_repositories_attribute_value_order_is_not_changing(setup_project_db_test): """Order of the repositories attribute is preserved.""" data = setup_project_db_test repo1 = Repository(name="Repo1", code="R1") repo2 = Repository(name="Repo2", code="R1") repo3 = Repository(name="Repo3", code="R1") DBSession.add_all([repo1, repo2, repo3]) DBSession.commit() test_value = [repo3, repo1, repo2] data["test_project"].repositories = test_value DBSession.commit() for i in range(10): db_proj = Project.query.first() assert db_proj.repositories == test_value DBSession.commit() def test_is_stereoscopic_argument_skipped(setup_project_db_test): """is_stereoscopic will set the is_stereoscopic attribute to False.""" data = setup_project_db_test data["kwargs"].pop("is_stereoscopic") new_project = Project(**data["kwargs"]) assert new_project.is_stereoscopic is False @pytest.mark.parametrize("test_value", [0, 1, 1.2, "", "str", ["a", "list"]]) def test_is_stereoscopic_argument_bool_conversion(test_value, setup_project_db_test): """is_stereoscopic arg is converted to a bool value.""" data = setup_project_db_test data["kwargs"]["is_stereoscopic"] = test_value new_project = Project(**data["kwargs"]) assert new_project.is_stereoscopic == bool(test_value) @pytest.mark.parametrize("test_value", [0, 1, 1.2, "", "str", ["a", "list"]]) def test_is_stereoscopic_attribute_bool_conversion(test_value, setup_project_db_test): """is_stereoscopic attr is converted to a bool value correctly.""" data = setup_project_db_test data["test_project"].is_stereoscopic = test_value assert data["test_project"].is_stereoscopic == bool(test_value) def test_structure_argument_is_none(setup_project_db_test): """structure argument can be None.""" data = setup_project_db_test data["kwargs"]["structure"] = None new_project = Project(**data["kwargs"]) assert isinstance(new_project, Project) def test_structure_attribute_is_none(setup_project_db_test): """structure attribute can be set to None.""" data = setup_project_db_test data["test_project"].structure = None def test_structure_argument_not_instance_of_structure(setup_project_db_test): """TypeError is raised if the structure argument is not an instance of Structure.""" data = setup_project_db_test data["kwargs"]["structure"] = 1.215 with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert ( str(cm.value) == "Project.structure should be an instance of " "stalker.models.structure.Structure, not float: '1.215'" ) def test_structure_attribute_not_instance_of_structure(setup_project_db_test): """TypeError raised if the structure attr is not a Structure.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].structure = 1.2 assert ( str(cm.value) == "Project.structure should be an instance of " "stalker.models.structure.Structure, not float: '1.2'" ) def test_structure_attribute_is_working_as_expected(setup_project_db_test): """structure attribute is working as expected.""" data = setup_project_db_test data["test_project"].structure = data["test_project_structure2"] assert data["test_project"].structure == data["test_project_structure2"] def test_equality(setup_project_db_test): """Equality of two projects.""" data = setup_project_db_test # create a new project with the same arguments new_project1 = Project(**data["kwargs"]) # create a new entity with the same arguments new_entity = Entity(**data["kwargs"]) # create another project with different name data["kwargs"]["name"] = "a different project" new_project2 = Project(**data["kwargs"]) assert not data["test_project"] != new_project1 assert data["test_project"] != new_project2 assert data["test_project"] != new_entity def test_inequality(setup_project_db_test): """Inequality of two projects""" data = setup_project_db_test # create a new project with the same arguments new_project1 = Project(**data["kwargs"]) # create a new entity with the same arguments new_entity = Entity(**data["kwargs"]) # create another project with different name data["kwargs"]["name"] = "a different project" new_project2 = Project(**data["kwargs"]) assert not data["test_project"] != new_project1 assert data["test_project"] != new_project2 assert data["test_project"] != new_entity def test_reference_mixin_initialization(setup_project_db_test): """ReferenceMixin part is initialized correctly.""" data = setup_project_db_test file_type_1 = Type(name="Image", code="image", target_entity_type="File") file1 = File( name="Artwork 1", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="a.jpg", type=file_type_1, ) file2 = File( name="Artwork 2", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="b.jbg", type=file_type_1, ) references = [file1, file2] data["kwargs"]["references"] = references new_project = Project(**data["kwargs"]) assert new_project.references == references def test_status_mixin_initialization(setup_project_db_test): """StatusMixin part is initialized correctly.""" data = setup_project_db_test status_list = StatusList.query.filter_by(target_entity_type="Project").first() data["kwargs"]["status"] = 0 data["kwargs"]["status_list"] = status_list new_project = Project(**data["kwargs"]) assert new_project.status_list == status_list def test_schedule_mixin_initialization(setup_project_db_test): """DateRangeMixin part is initialized correctly.""" data = setup_project_db_test start = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) + datetime.timedelta( days=25 ) end = start + datetime.timedelta(days=12) data["kwargs"]["start"] = start data["kwargs"]["end"] = end new_project = Project(**data["kwargs"]) assert new_project.start == start assert new_project.end == end assert new_project.duration == end - start def test___strictly_typed___is_false(setup_project_db_test): """__strictly_typed__ is True for Project class.""" assert Project.__strictly_typed__ is False def test___strictly_typed___not_forces_type_initialization(setup_project_db_test): """Project cannot be created without defining a type for it.""" data = setup_project_db_test data["kwargs"].pop("type") Project(**data["kwargs"]) # should be possible @pytest.mark.parametrize( "test_data", [ "test_task1", "test_task2", "test_task3", "test_task4", "test_task5", "test_task6", "test_task7", "test_task8", "test_task9", "test_task10", "test_task11", "test_task12", "test_task13", "test_task14", "test_task15", "test_task16", "test_task17", "test_task18", "test_task19", "test_task20", "test_task21", "test_task22", "test_task23", "test_task24", "test_task25", "test_task26", "test_task27", "test_seq1", "test_seq2", "test_seq3", "test_seq4", "test_seq5", "test_seq6", "test_seq7", "test_asset1", "test_asset2", "test_asset3", "test_asset4", "test_asset5", "test_shot1", "test_shot2", "test_shot3", "test_shot4", ], ) def test_tasks_attribute_returns_the_tasks_instances_related_to_this_project( test_data, setup_project_db_test ): """tasks attr returns a list of Task instances related to this Project instance.""" data = setup_project_db_test # test if we are going to get all the Tasks for project.tasks assert len(data["test_project"].tasks) == 43 assert data[test_data] in data["test_project"].tasks @pytest.mark.parametrize( "test_data", [ "test_task1", "test_task2", "test_task3", "test_seq1", "test_seq2", "test_seq3", "test_seq4", "test_seq5", "test_seq6", "test_seq7", "test_asset1", "test_asset2", "test_asset3", "test_asset4", "test_asset5", "test_shot1", "test_shot2", "test_shot3", "test_shot4", ], ) def test_root_tasks_attribute_returns_the_tasks_instances_with_no_parent_in_this_project( test_data, setup_project_db_test ): """root_tasks attr returns Task instances on this Project that has no parent.""" data = setup_project_db_test # test if we are going to get all the Tasks for project.tasks root_tasks = data["test_project"].root_tasks assert len(root_tasks) == 19 assert data[test_data] in root_tasks def test_users_argument_is_skipped(setup_project_db_test): """users attribute is an empty list if the users argument is skipped.""" data = setup_project_db_test data["kwargs"]["name"] = "New Project Name" try: data["kwargs"].pop("users") except KeyError: pass new_project = Project(**data["kwargs"]) assert new_project.users == [] def test_users_argument_is_none(setup_project_db_test): """the users attribute is an empty list if the users argument is set to None.""" data = setup_project_db_test data["kwargs"]["name"] = "New Project Name" data["kwargs"]["users"] = None new_project = Project(**data["kwargs"]) assert new_project.users == [] def test_users_attribute_is_set_to_none(setup_project_db_test): """TypeError is raised if the users attribute is set to None.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].users = None assert str(cm.value) == "'NoneType' object is not iterable" def test_users_argument_is_not_a_list_of_user_instances(setup_project_db_test): """TypeError is raised if the users argument is not a list of Users.""" data = setup_project_db_test data["kwargs"]["name"] = "New Project Name" data["kwargs"]["users"] = ["not a list of User instances"] with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert ( str(cm.value) == "ProjectUser.user should be a stalker.models.auth.User " "instance, not str: 'not a list of User instances'" ) def test_users_attribute_is_set_to_a_value_which_is_not_a_list_of_User_instances( setup_project_db_test, ): """TypeError raised if the user attribute is set to not a list of User instances.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].users = ["not a list of Users"] assert ( str(cm.value) == "ProjectUser.user should be a stalker.models.auth.User " "instance, not str: 'not a list of Users'" ) def test_users_argument_is_working_as_expected(setup_project_db_test): """users argument value is passed to the users attribute.""" data = setup_project_db_test data["kwargs"]["users"] = [ data["test_user1"], data["test_user2"], data["test_user3"], ] new_proj = Project(**data["kwargs"]) assert sorted(data["kwargs"]["users"], key=lambda x: x.name) == sorted( new_proj.users, key=lambda x: x.name ) def test_users_attribute_is_working_as_expected(setup_project_db_test): """users attribute is working as expected.""" data = setup_project_db_test users = [data["test_user1"], data["test_user2"], data["test_user3"]] data["test_project"].users = users assert sorted(users, key=lambda x: x.name) == sorted( data["test_project"].users, key=lambda x: x.name ) def test_tjp_id_is_working_as_expected(setup_project_db_test): """tjp_id attribute is working as expected.""" data = setup_project_db_test data["test_project"].id = 654654 assert data["test_project"].tjp_id == "Project_654654" @pytest.mark.parametrize( "entity_name", [ "test_task1", "test_task2", "test_task3", "test_task4", "test_task5", "test_task6", "test_task7", "test_task8", "test_task9", "test_task10", "test_task11", "test_task12", "test_task13", "test_task14", "test_task15", "test_task16", "test_task17", "test_task18", "test_task19", "test_task20", "test_task21", "test_task22", "test_task23", "test_task24", "test_task25", "test_task26", "test_task27", "test_asset1", "test_asset2", "test_asset3", "test_asset4", "test_asset5", "test_shot1", "test_shot2", "test_shot3", "test_shot4", "test_seq1", "test_seq2", "test_seq3", "test_seq4", "test_seq5", "test_seq6", "test_seq7", ], ) def test_to_tjp_is_working_as_expected(setup_project_db_test, entity_name): """to_tjp attribute is working as expected.""" data = setup_project_db_test # because of the randomness in the order of the test data being created, # we can't exactly know the output, so it might be better to check if the # tjp output starts with the correct line and every single child is # represented in the tjp output. result = data["test_project"].to_tjp # format the output so that it is more predictable result = condition_tjp_output(result) assert result.startswith( 'task Project_{id} "Project_{id}" {{'.format(id=data["test_project"].id) ) entity = data[entity_name] assert condition_tjp_output(entity.to_tjp) in result def test_project_instance_does_not_have_active_attribute(setup_project_db_test): """Project instances does not have active attribute.""" data = setup_project_db_test new_project = Project(**data["kwargs"]) assert hasattr(new_project, "active") is False @pytest.mark.parametrize( "status, expected", [ ["RTS", False], ["WIP", True], ["CMPL", False], ], ) def test_is_active_property_depends_on_the_status( setup_project_db_test, status, expected ): """is_active property depends on the Project.status.""" data = setup_project_db_test new_project = Project(**data["kwargs"]) status_ins = Status.query.filter_by(code=status).first() assert status_ins is not None new_project.status = status_ins assert new_project.is_active is expected def test_is_active_is_read_only(setup_project_db_test): """is_active is a read only property.""" data = setup_project_db_test with pytest.raises(AttributeError) as cm: data["test_project"].is_active = True error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_active'", }.get( sys.version_info.minor, "property 'is_active' of 'Project' object has no setter" ) assert str(cm.value) == error_message def test_total_logged_seconds_attribute_is_read_only(setup_project_db_test): """total_logged_seconds attribute is a read-only attribute.""" data = setup_project_db_test with pytest.raises(AttributeError) as cm: data["test_project"].total_logged_seconds = 32.3 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'total_logged_seconds'", }.get( sys.version_info.minor, "property 'total_logged_seconds' of 'Project' object has no setter", ) assert str(cm.value) == error_message def test_total_logged_seconds_is_0_for_a_project_with_no_child_tasks( setup_project_db_test, ): """total_logged_seconds.""" data = setup_project_db_test new_project = Project(**data["kwargs"]) DBSession.save(new_project) assert new_project.total_logged_seconds == 0 def test_total_logged_seconds_attribute_is_working_as_expected(setup_project_db_test): """total_logged_seconds attribute is working as expected.""" data = setup_project_db_test # create some time logs t1 = TimeLog( task=data["test_task1"], resource=data["test_task1"].resources[0], start=datetime.datetime(2013, 8, 1, 1, 0, tzinfo=pytz.utc), duration=datetime.timedelta(hours=1), ) DBSession.save(t1) assert data["test_project"].total_logged_seconds == 3600 # add more time logs t2 = TimeLog( task=data["test_seq1"], resource=data["test_seq1"].resources[0], start=datetime.datetime(2013, 8, 1, 2, 0, tzinfo=pytz.utc), duration=datetime.timedelta(hours=1), ) DBSession.save(t2) assert data["test_project"].total_logged_seconds == 7200 # create more deeper time logs t3 = TimeLog( task=data["test_task10"], resource=data["test_task10"].resources[0], start=datetime.datetime(2013, 8, 1, 3, 0, tzinfo=pytz.utc), duration=datetime.timedelta(hours=3), ) DBSession.save(t3) assert data["test_project"].total_logged_seconds == 18000 # create a time log for one asset t4 = TimeLog( task=data["test_asset1"], resource=data["test_asset1"].resources[0], start=datetime.datetime(2013, 8, 1, 6, 0, tzinfo=pytz.utc), duration=datetime.timedelta(hours=10), ) DBSession.save(t4) assert data["test_project"].total_logged_seconds == 15 * 3600 def test_schedule_seconds_attribute_is_read_only(setup_project_db_test): """schedule_seconds is a read-only attribute.""" data = setup_project_db_test with pytest.raises(AttributeError) as cm: data["test_project"].schedule_seconds = 3 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'schedule_seconds'", }.get( sys.version_info.minor, "property 'schedule_seconds' of 'Project' object has no setter", ) assert str(cm.value) == error_message def test_schedule_seconds_attribute_value_is_0_for_a_project_with_no_tasks( setup_project_db_test, ): """schedule_seconds attribute value is 0 for a project with no tasks.""" data = setup_project_db_test new_project = Project(**data["kwargs"]) DBSession.add(new_project) DBSession.commit() assert new_project.schedule_seconds == 0 @pytest.mark.parametrize( "test_entity,expected_value", [ ["test_seq1", 3600], ["test_seq2", 3600], ["test_seq3", 3600], ["test_seq4", 3 * 3600], ["test_seq5", 3 * 3600], ["test_seq6", 3600], ["test_seq7", 3600], ["test_shot1", 12 * 3600], ["test_shot2", 3 * 3600], ["test_shot3", 3 * 3600], ["test_shot4", 3 * 3600], ["test_asset1", 3600], ["test_asset2", 3600], ["test_asset3", 3600], ["test_asset4", 3 * 3600], ["test_asset5", 3 * 3600], ["test_task1", 3600], ["test_task2", 3600], ["test_task3", 3600], ["test_task4", 3600], ["test_task5", 3600], ["test_task6", 3600], ["test_task7", 3600], ["test_task8", 3600], ["test_task9", 3600], ["test_task10", 10 * 3600], ["test_task11", 3600], ["test_task12", 3600], ["test_task13", 3600], ["test_task14", 3600], ["test_task15", 3600], ["test_task16", 3600], ["test_task17", 3600], ["test_task18", 3600], ["test_task19", 3600], ["test_task20", 3600], ["test_task21", 3600], ["test_task22", 3600], ["test_task23", 3600], ["test_task24", 3600], ["test_task25", 3600], ["test_task26", 3600], ["test_task27", 3600], ["test_project", 44 * 3600], ], ) def test_schedule_seconds_attribute_is_working_as_expected( test_entity, expected_value, setup_project_db_test ): """schedule_seconds attribute value is gathered from the child tasks.""" data = setup_project_db_test assert data["test_shot1"].is_container assert data["test_task10"].parent == data["test_shot1"] assert data[test_entity].schedule_seconds == expected_value def test_percent_complete_attribute_is_read_only(setup_project_db_test): """percent_complete is a read-only attribute.""" data = setup_project_db_test with pytest.raises(AttributeError) as cm: data["test_project"].percent_complete = 32.3 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'percent_complete'", }.get( sys.version_info.minor, "property 'percent_complete' of 'Project' object has no setter", ) assert str(cm.value) == error_message def test_percent_complete_is_0_for_a_project_with_no_tasks(setup_project_db_test): """percent_complete attribute value is 0 for a project with no tasks.""" data = setup_project_db_test new_project = Project(**data["kwargs"]) DBSession.add(new_project) DBSession.commit() assert new_project.percent_complete == 0 def test_percent_complete_attribute_is_working_as_expected(setup_project_db_test): """percent_complete attribute is working as expected""" data = setup_project_db_test assert data["test_project"].percent_complete == 0 assert data["test_shot1"].is_container is True assert data["test_task10"].parent == data["test_shot1"] assert data["test_task10"].schedule_seconds == 36000 assert data["test_task11"].schedule_seconds == 3600 assert data["test_task12"].schedule_seconds == 3600 assert data["test_shot1"].schedule_seconds == 12 * 3600 # create some time logs t = TimeLog( task=data["test_task1"], resource=data["test_task1"].resources[0], start=datetime.datetime(2013, 8, 1, 1, 0, tzinfo=pytz.utc), duration=datetime.timedelta(hours=1), ) DBSession.add(t) DBSession.commit() assert data["test_project"].percent_complete == (1.0 / 44.0 * 100) def test_clients_argument_is_skipped(setup_project_db_test): """clients attribute is set to None if the clients argument is skipped.""" data = setup_project_db_test data["kwargs"]["name"] = "New Project Name" try: data["kwargs"].pop("clients") except KeyError: pass new_project = Project(**data["kwargs"]) assert new_project.clients == [] def test_clients_argument_is_none(setup_project_db_test): """clients argument can be None.""" data = setup_project_db_test data["kwargs"]["clients"] = None new_project = Project(**data["kwargs"]) assert new_project.clients == [] def test_clients_attribute_is_set_to_none(setup_project_db_test): """it a TypeError is raised if the clients attribute is set to None.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].clients = None assert str(cm.value) == "'NoneType' object is not iterable" def test_clients_argument_is_given_as_something_other_than_a_client( setup_project_db_test, ): """TypeError raised if the client arg is not a Client.""" data = setup_project_db_test data["kwargs"]["clients"] = "a user" with pytest.raises(TypeError) as cm: Project(**data["kwargs"]) assert str(cm.value) == ( "ProjectClient.client should be an instance of " "stalker.models.auth.Client, not str: 'a'" ) def test_clients_attribute_is_not_a_client_instance(setup_project_db_test): """TypeError raised if the client attribute is not a Client.""" data = setup_project_db_test with pytest.raises(TypeError) as cm: data["test_project"].clients = "a user" assert str(cm.value) == ( "ProjectClient.client should be an instance of stalker.models.auth.Client, " "not str: 'a'" ) # def test_client_argument_is_working_as_expected(setup_project_db_test): # """client argument value is correctly passed to the client attribute.""" # data = setup_project_db_test # assert data["test_project"].client == data["kwargs"]['client'] def test_clients_attribute_is_working_as_expected(setup_project_db_test): """clients attribute value can be updated correctly.""" data = setup_project_db_test new_client = Client(name="New Client") assert data["test_project"].clients != [new_client] data["test_project"].clients = [new_client] assert data["test_project"].clients == [new_client] @pytest.fixture(scope="function") def setup_project_tickets_db_tests(setup_postgresql_db): """Set up the tests for the Project <-> Ticket relation.""" data = dict() # create test objects data["start"] = datetime.datetime(2016, 11, 17, tzinfo=pytz.utc) data["end"] = data["start"] + datetime.timedelta(days=20) data["test_lead"] = User( name="lead", login="lead", email="lead@lead.com", password="lead" ) data["test_user1"] = User( name="User1", login="user1", email="user1@users.com", password="123456" ) data["test_user2"] = User( name="User2", login="user2", email="user2@users.com", password="123456" ) data["test_user3"] = User( name="User3", login="user3", email="user3@users.com", password="123456" ) data["test_user4"] = User( name="User4", login="user4", email="user4@users.com", password="123456" ) data["test_user5"] = User( name="User5", login="user5", email="user5@users.com", password="123456" ) data["test_user6"] = User( name="User6", login="user6", email="user6@users.com", password="123456" ) data["test_user7"] = User( name="User7", login="user7", email="user7@users.com", password="123456" ) data["test_user8"] = User( name="User8", login="user8", email="user8@users.com", password="123456" ) data["test_user9"] = User( name="user9", login="user9", email="user9@users.com", password="123456" ) data["test_user10"] = User( name="User10", login="user10", email="user10@users.com", password="123456" ) DBSession.save( [ data["test_lead"], data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], data["test_user6"], data["test_user7"], data["test_user8"], data["test_user9"], data["test_user10"], ] ) data["test_image_format"] = ImageFormat( name="HD", width=1920, height=1080, ) # type for project data["test_project_type"] = Type( name="Project Type 1", code="projt1", target_entity_type="Project" ) data["test_project_type2"] = Type( name="Project Type 2", code="projt2", target_entity_type="Project" ) # type for structure data["test_structure_type1"] = Type( name="Structure Type 1", code="struct1", target_entity_type="Structure" ) data["test_structure_type2"] = Type( name="Structure Type 2", code="struct2", target_entity_type="Structure" ) data["test_project_structure"] = Structure( name="Project Structure 1", type=data["test_structure_type1"], ) data["test_project_structure2"] = Structure( name="Project Structure 2", type=data["test_structure_type2"], ) data["test_repo"] = Repository( name="Commercials Repository", code="CR", ) # create a project object data["kwargs"] = { "name": "Test Project", "code": "tp", "description": "This is a project object for testing purposes", "image_format": data["test_image_format"], "fps": 25, "type": data["test_project_type"], "structure": data["test_project_structure"], "repository": data["test_repo"], "is_stereoscopic": False, "display_width": 15, "start": data["start"], "end": data["end"], } data["test_project"] = Project(**data["kwargs"]) # ********************************************************************* # Tickets # ********************************************************************* # no need to create status list for tickets cause we have a database # set up an running so it is automatically linked # tickets for version1 data["test_ticket1"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket1"]) # set it to closed data["test_ticket1"].resolve() DBSession.commit() # create a new ticket and leave it open data["test_ticket2"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket2"]) DBSession.commit() # create a new ticket and close and then reopen it data["test_ticket3"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket3"]) data["test_ticket3"].resolve() data["test_ticket3"].reopen() DBSession.commit() # ********************************************************************* # tickets for version2 # create a new ticket and leave it open data["test_ticket4"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket4"]) DBSession.commit() # create a new Ticket and close it data["test_ticket5"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket5"]) data["test_ticket5"].resolve() DBSession.commit() # create a new Ticket and close it data["test_ticket6"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket6"]) data["test_ticket6"].resolve() DBSession.commit() # ********************************************************************* # tickets for version3 # create a new ticket and close it data["test_ticket7"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket7"]) data["test_ticket7"].resolve() DBSession.commit() # create a new ticket and close it data["test_ticket8"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket8"]) data["test_ticket8"].resolve() DBSession.commit() # ********************************************************************* # tickets for version4 # create a new ticket and close it data["test_ticket9"] = Ticket(project=data["test_project"]) DBSession.add(data["test_ticket9"]) data["test_ticket9"].resolve() DBSession.commit() # ********************************************************************* DBSession.add(data["test_project"]) DBSession.commit() return data def test_tickets_attribute_is_an_empty_list_by_default(setup_project_tickets_db_tests): """Project.tickets is an empty list by default.""" data = setup_project_tickets_db_tests project1 = Project(**data["kwargs"]) assert project1.tickets == [] def test_open_tickets_attribute_is_an_empty_list_by_default( setup_project_tickets_db_tests, ): """Project.open_tickets is an empty list by default.""" data = setup_project_tickets_db_tests project1 = Project(**data["kwargs"]) DBSession.add(project1) DBSession.commit() assert project1.open_tickets == [] def test_open_tickets_attribute_is_read_only(setup_project_tickets_db_tests): """Project.open_tickets attribute is a read only attribute.""" data = setup_project_tickets_db_tests with pytest.raises(AttributeError) as cm: data["test_project"].open_tickets = [] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'open_tickets'", }.get( sys.version_info.minor, "property 'open_tickets' of 'Project' object has no setter", ) assert str(cm.value) == error_message def test_tickets_attribute_returns_all_tickets_in_this_project( setup_project_tickets_db_tests, ): """Project.tickets returns all the tickets in this project.""" data = setup_project_tickets_db_tests # there should be tickets in this project already assert data["test_project"].tickets != [] # now we should have some tickets assert len(data["test_project"].tickets) > 0 # now check for exact items assert sorted(data["test_project"].tickets, key=lambda x: x.name) == sorted( [ data["test_ticket1"], data["test_ticket2"], data["test_ticket3"], data["test_ticket4"], data["test_ticket5"], data["test_ticket6"], data["test_ticket7"], data["test_ticket8"], data["test_ticket9"], ], key=lambda x: x.name, ) def test_open_tickets_attribute_returns_all_open_tickets_owned_by_this_user( setup_project_tickets_db_tests, ): """User.open_tickets returns all the open tickets owned by this user.""" data = setup_project_tickets_db_tests # there should be tickets in this project already assert data["test_project"].open_tickets != [] # assign the user to some tickets data["test_ticket1"].reopen(data["test_user1"]) data["test_ticket2"].reopen(data["test_user1"]) data["test_ticket3"].reopen(data["test_user1"]) data["test_ticket4"].reopen(data["test_user1"]) data["test_ticket5"].reopen(data["test_user1"]) data["test_ticket6"].reopen(data["test_user1"]) data["test_ticket7"].reopen(data["test_user1"]) data["test_ticket8"].reopen(data["test_user1"]) # be careful not all of these are open tickets data["test_ticket1"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket2"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket3"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket4"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket5"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket6"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket7"].reassign(data["test_user1"], data["test_user1"]) data["test_ticket8"].reassign(data["test_user1"], data["test_user1"]) # now we should have some open tickets assert len(data["test_project"].open_tickets) > 0 # now check for exact items assert sorted(data["test_project"].open_tickets, key=lambda x: x.name) == sorted( [ data["test_ticket1"], data["test_ticket2"], data["test_ticket3"], data["test_ticket4"], data["test_ticket5"], data["test_ticket6"], data["test_ticket7"], data["test_ticket8"], ], key=lambda x: x.name, ) # close a couple of them data["test_ticket1"].resolve(data["test_user1"], FIXED) data["test_ticket2"].resolve(data["test_user1"], INVALID) data["test_ticket3"].resolve(data["test_user1"], CANTFIX) # new check again assert sorted(data["test_project"].open_tickets, key=lambda x: x.name) == sorted( [ data["test_ticket4"], data["test_ticket5"], data["test_ticket6"], data["test_ticket7"], data["test_ticket8"], ], key=lambda x: x.name, ) def test__hash__is_working_as_expected(setup_project_db_test): """__hash__ is working as expected.""" data = setup_project_db_test result = hash(data["test_project"]) assert isinstance(result, int) assert result == data["test_project"].__hash__() ================================================ FILE: tests/models/test_project_client.py ================================================ # -*- coding: utf-8 -*- """Tests related to the ProjectClient class.""" import pytest from stalker import Client, Project, ProjectClient, Repository, Role, Status, User @pytest.fixture(scope="function") def setup_project_client_db_test(setup_postgresql_db): """Set the test up ProjectClient class tests with a DB.""" data = dict() data["test_repo"] = Repository(name="Test Repo", code="TR") data["status_new"] = Status(name="New", code="NEW") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["test_user1"] = User( name="Test User 1", login="testuser1", email="testuser1@users.com", password="secret", ) data["test_client"] = Client(name="Test Client") data["test_project"] = Project( name="Test Project 1", code="TP1", repositories=[data["test_repo"]], ) data["test_role"] = Role(name="Test Client") return data def test_project_client_creation(setup_project_client_db_test): """Project client creation.""" data = setup_project_client_db_test ProjectClient( project=data["test_project"], client=data["test_client"], role=data["test_role"] ) assert data["test_client"] in data["test_project"].clients def test_role_argument_is_not_a_role_instance(setup_project_client_db_test): """TypeError will be raised when the role argument is not a Role instance.""" data = setup_project_client_db_test with pytest.raises(TypeError) as cm: ProjectClient( project=data["test_project"], client=data["test_client"], role="not a role instance", ) assert str(cm.value) == ( "ProjectClient.role should be a stalker.models.auth.Role " "instance, not str: 'not a role instance'" ) ================================================ FILE: tests/models/test_project_user.py ================================================ # -*- coding: utf-8 -*- """Tests related to the ProjectUser class.""" import pytest from stalker import Project, ProjectUser, Repository, Role, User from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_project_user_db_tests(setup_postgresql_db): """Set up the tests database and data for the ProjectUser class related tests.""" data = dict() data["test_repo"] = Repository(name="Test Repo", code="TR") DBSession.add(data["test_repo"]) DBSession.commit() data["test_user1"] = User( name="Test User 1", login="testuser1", email="testuser1@users.com", password="secret", ) DBSession.add(data["test_user1"]) data["test_project"] = Project( name="Test Project 1", code="TP1", repositories=[data["test_repo"]], ) DBSession.add(data["test_project"]) data["test_role"] = Role(name="Test User") DBSession.add(data["test_role"]) DBSession.commit() return data def test_project_user_creation(setup_project_user_db_tests): """project user creation.""" data = setup_project_user_db_tests puser = ProjectUser( project=data["test_project"], user=data["test_user1"], role=data["test_role"] ) DBSession.save(puser) assert data["test_user1"] in data["test_project"].users def test_role_argument_is_not_a_role_instance(setup_project_user_db_tests): """TypeError will be raised if the role argument is not a Role instance.""" data = setup_project_user_db_tests with pytest.raises(TypeError) as cm: ProjectUser( project=data["test_project"], user=data["test_user1"], role="not a role instance", ) assert str(cm.value) == ( "ProjectUser.role should be a stalker.models.auth.Role instance, " "not str: 'not a role instance'" ) def test_rate_attribute_is_copied_from_user(setup_project_user_db_tests): """rate attribute value is copied from the user on init.""" data = setup_project_user_db_tests data["test_user1"].rate = 100.0 project_user1 = ProjectUser( project=data["test_project"], user=data["test_user1"], role=data["test_role"] ) assert data["test_user1"].rate == project_user1.rate def test_rate_attribute_initialization_through_user(setup_project_user_db_tests): """rate attribute initialization through ``user.projects`` attribute.""" data = setup_project_user_db_tests data["test_user1"].rate = 102.0 data["test_user1"].projects = [data["test_project"]] assert data["test_project"].user_role[0].rate == data["test_user1"].rate def test_rate_attribute_initialization_through_project(setup_project_user_db_tests): """rate attribute initialization through ``project.users`` attribute.""" data = setup_project_user_db_tests data["test_user1"].rate = 102.0 data["test_project"].users = [data["test_user1"]] assert data["test_project"].user_role[0].rate == data["test_user1"].rate ================================================ FILE: tests/models/test_repository.py ================================================ # -*- coding: utf-8 -*- """Tests related to the Repository class.""" import os import sys import pytest from stalker import CodeMixin, Repository, Tag, defaults from stalker.db.session import DBSession from tests.utils import PlatformPatcher @pytest.fixture(scope="function") def setup_repository_db_tests(setup_postgresql_db): """Set up the tests for the Repository class with a DB.""" data = dict() data["patcher"] = PlatformPatcher() # create a couple of test tags data["test_tag1"] = Tag(name="test tag 1") data["test_tag2"] = Tag(name="test tag 2") data["kwargs"] = { "name": "a repository", "code": "R1", "description": "this is for testing purposes", "tags": [data["test_tag1"], data["test_tag2"]], "linux_path": "/mnt/M/Projects", "macos_path": "/Volumes/M/Projects", "windows_path": "M:/Projects", } repo = Repository(**data["kwargs"]) data["test_repo"] = repo DBSession.add(data["test_repo"]) DBSession.commit() yield data data["patcher"].restore() def test_code_mixin_as_super(setup_repository_db_tests): """CodeMixin is one of the supers of the Repository class.""" data = setup_repository_db_tests repo = Repository(**data["kwargs"]) assert isinstance(repo, CodeMixin) def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Repository class.""" assert Repository.__auto_name__ is False @pytest.mark.parametrize("test_value", [123123, 123.1231, [], {}]) def test_linux_path_argument_accepts_only_strings( test_value, setup_repository_db_tests ): """linux_path argument accepts only string values.""" data = setup_repository_db_tests data["kwargs"]["linux_path"] = test_value with pytest.raises(TypeError): Repository(**data["kwargs"]) @pytest.mark.parametrize("test_value", [123123, 123.1231, [], {}]) def test_linux_path_attribute_accepts_only_strings( test_value, setup_repository_db_tests ): """linux_path attribute accepts only string values.""" data = setup_repository_db_tests with pytest.raises(TypeError): data["test_repo"].linux_path = test_value def test_linux_path_attribute_is_working_as_expected(setup_repository_db_tests): """linux_path attribute is working as expected.""" data = setup_repository_db_tests test_value = "~/newRepoPath/Projects/" data["test_repo"].linux_path = test_value assert data["test_repo"].linux_path == test_value def test_linux_path_attribute_finishes_with_a_slash(setup_repository_db_tests): """linux_path attr is finished with a slash even it is not supplied by default.""" data = setup_repository_db_tests test_value = "/mnt/T" expected_value = "/mnt/T/" data["test_repo"].linux_path = test_value assert data["test_repo"].linux_path == expected_value @pytest.mark.parametrize("test_value", [123123, 123.1231, [], {}]) def test_windows_path_argument_accepts_only_strings( test_value, setup_repository_db_tests ): """windows_path argument accepts only string values.""" data = setup_repository_db_tests data["kwargs"]["windows_path"] = test_value with pytest.raises(TypeError): Repository(**data["kwargs"]) def test_windows_path_attribute_accepts_only_strings(setup_repository_db_tests): """windows_path attribute accepts only string values.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].windows_path = 123123 assert str(cm.value) == ( "Repository.windows_path should be an instance of string, not int: '123123'" ) def test_windows_path_attribute_is_working_as_expected(setup_repository_db_tests): """windows_path attribute is working as expected.""" data = setup_repository_db_tests test_value = "~/newRepoPath/Projects/" data["test_repo"].windows_path = test_value assert data["test_repo"].windows_path == test_value def test_windows_path_attribute_finishes_with_a_slash(setup_repository_db_tests): """windows_path attr is finished with a slash even it is not supplied by default.""" data = setup_repository_db_tests test_value = "T:" expected_value = "T:/" data["test_repo"].windows_path = test_value assert data["test_repo"].windows_path == expected_value def test_macos_path_argument_accepts_only_strings(setup_repository_db_tests): """macos_path argument accepts only string values.""" data = setup_repository_db_tests data["kwargs"]["macos_path"] = 123123 with pytest.raises(TypeError) as cm: Repository(**data["kwargs"]) assert str(cm.value) == ( "Repository.macos_path should be an instance of string, not int: '123123'" ) def test_macos_path_attribute_accepts_only_strings(setup_repository_db_tests): """macos_path attribute accepts only string values.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].macos_path = 123123 assert str(cm.value) == ( "Repository.macos_path should be an instance of string, not int: '123123'" ) def test_macos_path_attribute_is_working_as_expected(setup_repository_db_tests): """macos_path attribute is working as expected.""" data = setup_repository_db_tests test_value = "~/newRepoPath/Projects/" data["test_repo"].macos_path = test_value assert data["test_repo"].macos_path == test_value def test_macos_path_attribute_finishes_with_a_slash(setup_repository_db_tests): """macos_path attr is finished with a slash even it is not supplied by default.""" data = setup_repository_db_tests test_value = "/Volumes/T" expected_value = "/Volumes/T/" data["test_repo"].macos_path = test_value assert data["test_repo"].macos_path == expected_value def test_path_returns_properly_for_windows(setup_repository_db_tests): """path returns the correct value for the os.""" data = setup_repository_db_tests data["patcher"].patch("Windows") assert data["test_repo"].path == data["test_repo"].windows_path def test_path_returns_properly_for_linux(setup_repository_db_tests): """path returns the correct value for the os.""" data = setup_repository_db_tests data["patcher"].patch("Linux") assert data["test_repo"].path == data["test_repo"].linux_path def test_path_returns_properly_for_macos(setup_repository_db_tests): """path returns the correct value for the os.""" data = setup_repository_db_tests data["patcher"].patch("Darwin") assert data["test_repo"].path == data["test_repo"].macos_path def test_path_attribute_sets_correct_path_for_windows(setup_repository_db_tests): """path property sets the correct attribute in windows.""" data = setup_repository_db_tests data["patcher"].patch("Windows") test_value = "S:/Projects/" assert data["test_repo"].path != test_value assert data["test_repo"].windows_path != test_value data["test_repo"].path = test_value assert data["test_repo"].windows_path == test_value assert data["test_repo"].path == test_value def test_path_attribute_sets_correct_path_for_linux(setup_repository_db_tests): """path property sets the correct attribute in linux.""" data = setup_repository_db_tests data["patcher"].patch("Linux") test_value = "/mnt/S/Projects/" assert data["test_repo"].path != test_value assert data["test_repo"].linux_path != test_value data["test_repo"].path = test_value assert data["test_repo"].linux_path == test_value assert data["test_repo"].path == test_value def test_path_attribute_sets_correct_path_for_macos(setup_repository_db_tests): """path property sets the correct attribute in macos.""" data = setup_repository_db_tests data["patcher"].patch("Darwin") test_value = "/Volumes/S/Projects/" assert data["test_repo"].path != test_value assert data["test_repo"].macos_path != test_value data["test_repo"].path = test_value assert data["test_repo"].macos_path == test_value assert data["test_repo"].path == test_value def test_equality(setup_repository_db_tests): """equality of two repositories.""" data = setup_repository_db_tests repo1 = Repository(**data["kwargs"]) repo2 = Repository(**data["kwargs"]) data["kwargs"].update( { "name": "a repository", "description": "this is the commercial repository", "linux_path": "/mnt/commercialServer/Projects", "macos_path": "/Volumes/commercialServer/Projects", "windows_path": "Z:\\Projects", } ) repo3 = Repository(**data["kwargs"]) assert repo1 == repo2 assert not repo1 == repo3 def test_inequality(setup_repository_db_tests): """inequality of two repositories.""" data = setup_repository_db_tests repo1 = Repository(**data["kwargs"]) repo2 = Repository(**data["kwargs"]) data["kwargs"].update( { "name": "a repository", "description": "this is the commercial repository", "linux_path": "/mnt/commercialServer/Projects", "macos_path": "/Volumes/commercialServer/Projects", "windows_path": "Z:\\Projects", } ) repo3 = Repository(**data["kwargs"]) assert not repo1 != repo2 assert repo1 != repo3 def test_plural_class_name(setup_repository_db_tests): """plural name of Repository class.""" data = setup_repository_db_tests assert data["test_repo"].plural_class_name == "Repositories" def test_linux_path_argument_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the linux_path argument.""" data = setup_repository_db_tests data["kwargs"]["linux_path"] = r"\mnt\M\Projects" new_repo = Repository(**data["kwargs"]) assert "\\" not in new_repo.linux_path assert new_repo.linux_path == "/mnt/M/Projects/" def test_linux_path_attribute_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the linux_path attribute.""" data = setup_repository_db_tests data["test_repo"].linux_path = r"\mnt\M\Projects" assert "\\" not in data["test_repo"].linux_path assert data["test_repo"].linux_path == "/mnt/M/Projects/" def test_macos_path_argument_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the macos_path argument.""" data = setup_repository_db_tests data["kwargs"]["macos_path"] = r"\Volumes\M\Projects" new_repo = Repository(**data["kwargs"]) assert "\\" not in new_repo.linux_path assert new_repo.macos_path == "/Volumes/M/Projects/" def test_macos_path_attribute_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the macos_path attribute.""" data = setup_repository_db_tests data["test_repo"].macos_path = r"\Volumes\M\Projects" assert "\\" not in data["test_repo"].macos_path assert data["test_repo"].macos_path == "/Volumes/M/Projects/" def test_windows_path_argument_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the windows_path arg.""" data = setup_repository_db_tests data["kwargs"]["windows_path"] = r"M:\Projects" new_repo = Repository(**data["kwargs"]) assert "\\" not in new_repo.linux_path assert new_repo.windows_path == "M:/Projects/" def test_windows_path_attribute_backward_slashes_are_converted_to_forward_slashes( setup_repository_db_tests, ): """backward slashes are converted to forward slashes in the windows_path attr.""" data = setup_repository_db_tests data["test_repo"].windows_path = r"M:\Projects" assert "\\" not in data["test_repo"].windows_path assert data["test_repo"].windows_path == "M:/Projects/" def test_windows_path_with_more_than_one_slashes_converted_to_single_slash_1( setup_repository_db_tests, ): """windows_path is set with more than one slashes is converted to single slash.""" data = setup_repository_db_tests data["test_repo"].windows_path = r"M://" assert data["test_repo"].windows_path == "M:/" def test_windows_path_with_more_than_one_slashes_converted_to_single_slash_2( setup_repository_db_tests, ): """windows_path is set with more than one slashes is converted to single slash.""" data = setup_repository_db_tests data["test_repo"].windows_path = r"M://////////" assert data["test_repo"].windows_path == "M:/" def test_to_linux_path_returns_the_linux_version_of_the_given_windows_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_linux_path(test_windows_path) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_linux_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_linux_path(test_linux_path) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_macos_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given macos path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_linux_path(test_macos_path) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_reverse_windows_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given reverse windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].to_linux_path(test_windows_path_reverse) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_reverse_linux_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given reverse linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].to_linux_path(test_linux_path_reverse) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_reverse_macos_path( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given reverse macos path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert data["test_repo"].to_linux_path(test_macos_path_reverse) == test_linux_path def test_to_linux_path_returns_the_linux_version_of_the_given_path_with_env_vars( setup_repository_db_tests, ): """to_linux_path returns the linux version of the given path contains env vars.""" data = setup_repository_db_tests data["test_repo"].id = 1 data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" os.environ["REPOR1"] = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_path_with_env_var = "$REPOR1/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].to_linux_path(test_path_with_env_var) == test_linux_path def test_to_linux_path_raises_type_error_if_path_is_none(setup_repository_db_tests): """to_linux_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_linux_path(None) assert str(cm.value) == ( "path should be a string containing a file path, not NoneType: 'None'" ) def test_to_linux_path_raises_type_error_if_path_is_not_a_string( setup_repository_db_tests, ): """to_linux_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_linux_path(123) assert str(cm.value) == ( "path should be a string containing a file path, not int: '123'" ) def test_to_windows_path_returns_the_windows_version_of_the_given_windows_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].to_windows_path(test_windows_path) == test_windows_path def test_to_windows_path_returns_the_windows_version_of_the_given_linux_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].windows_path = "T:/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].to_windows_path(test_linux_path) == test_windows_path def test_to_windows_path_returns_the_windows_version_of_the_given_macos_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given macos path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].to_windows_path(test_macos_path) == test_windows_path def test_to_windows_path_returns_the_windows_version_of_the_given_reverse_windows_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given reverse windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert ( data["test_repo"].to_windows_path(test_windows_path_reverse) == test_windows_path ) def test_to_windows_path_returns_the_windows_version_of_the_given_reverse_linux_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given reverse linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert ( data["test_repo"].to_windows_path(test_linux_path_reverse) == test_windows_path ) def test_to_windows_path_returns_the_windows_version_of_the_given_reverse_macos_path( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given reverse macos path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert ( data["test_repo"].to_windows_path(test_macos_path_reverse) == test_windows_path ) def test_to_windows_path_returns_the_windows_version_of_the_given_path_with_env_vars( setup_repository_db_tests, ): """to_windows_path returns the windows version of the given path which env vars.""" data = setup_repository_db_tests data["test_repo"].id = 1 data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" os.environ["REPOR1"] = data["test_repo"].linux_path test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_path_with_env_var = "$REPOR1/Sero/Task1/Task2/Some_file.ma" assert ( data["test_repo"].to_windows_path(test_path_with_env_var) == test_windows_path ) def test_to_windows_path_raises_type_error_if_path_is_none(setup_repository_db_tests): """to_windows_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_windows_path(None) assert str(cm.value) == ( "path should be a string containing a file path, not NoneType: 'None'" ) def test_to_windows_path_raises_type_error_if_path_is_not_a_string( setup_repository_db_tests, ): """to_windows_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_windows_path(123) assert str(cm.value) == ( "path should be a string containing a file path, not int: '123'" ) def test_to_macos_path_returns_the_macos_version_of_the_given_windows_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_macos_path(test_windows_path) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_linux_path( setup_repository_db_tests, ): """to_macos_path returns the macOS version of the given linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_macos_path(test_linux_path) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_macos_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given macos path.""" data = setup_repository_db_tests data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_macos_path(test_macos_path) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_reverse_windows_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given reverse windows path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].to_macos_path(test_windows_path_reverse) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_reverse_linux_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given reverse linux path.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].to_macos_path(test_linux_path_reverse) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_reverse_macos_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given reverse macos path.""" data = setup_repository_db_tests data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert data["test_repo"].to_macos_path(test_macos_path_reverse) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_path( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given path.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert data["test_repo"].to_macos_path(test_windows_path) == test_macos_path assert data["test_repo"].to_macos_path(test_linux_path) == test_macos_path assert data["test_repo"].to_macos_path(test_macos_path) == test_macos_path assert data["test_repo"].to_macos_path(test_windows_path_reverse) == test_macos_path assert data["test_repo"].to_macos_path(test_linux_path_reverse) == test_macos_path assert data["test_repo"].to_macos_path(test_macos_path_reverse) == test_macos_path def test_to_macos_path_returns_the_macos_version_of_the_given_path_with_env_vars( setup_repository_db_tests, ): """to_macos_path returns the macos version of the given path which contains env vars.""" data = setup_repository_db_tests data["test_repo"].id = 1 data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" os.environ["REPOR1"] = data["test_repo"].windows_path test_windows_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_path_with_env_var = "$REPOR1/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].to_macos_path(test_path_with_env_var) == test_windows_path def test_to_macos_path_raises_type_error_if_path_is_none(setup_repository_db_tests): """to_macos_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_macos_path(None) assert str(cm.value) == ( "path should be a string containing a file path, not NoneType: 'None'" ) def test_to_macos_path_raises_type_error_if_path_is_not_a_string( setup_repository_db_tests, ): """to_macos_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_macos_path(123) assert str(cm.value) == ( "path should be a string containing a file path, not int: '123'" ) def test_to_native_path_returns_the_native_version_of_the_given_linux_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given linux path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_native_path(test_linux_path) == test_linux_path def test_to_native_path_returns_the_native_version_of_the_given_windows_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given windows path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" assert ( data["test_repo"].to_native_path(test_windows_path) == "/mnt/T/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" ) def test_to_native_path_returns_the_native_version_of_the_given_macos_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given macos path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].to_native_path(test_macos_path) == test_linux_path def test_to_native_path_returns_the_native_version_of_the_given_reverse_windows_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given reverse windows path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert ( data["test_repo"].to_native_path(test_windows_path_reverse) == test_linux_path ) def test_to_native_path_returns_the_native_version_of_the_given_reverse_linux_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given reverse linux path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].to_native_path(test_linux_path_reverse) == test_linux_path def test_to_native_path_returns_the_native_version_of_the_given_reverse_macos_path( setup_repository_db_tests, ): """to_native_path returns the native version of the given reverse macos path.""" data = setup_repository_db_tests data["patcher"].patch("Linux") data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert data["test_repo"].to_native_path(test_macos_path_reverse) == test_linux_path def test_to_native_path_raises_type_error_if_path_is_none(setup_repository_db_tests): """to_native_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_native_path(None) assert str(cm.value) == ( "path should be a string containing a file path, not NoneType: 'None'" ) def test_to_native_path_raises_type_error_if_path_is_not_a_string( setup_repository_db_tests, ): """to_native_path raises TypeError if path is None.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"].to_native_path(123) assert str(cm.value) == ( "path should be a string containing a file path, not int: '123'" ) def test_is_in_repo_returns_true_if_the_given_linux_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if linux path is in this repo or False otherwise.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].is_in_repo(test_linux_path) def test_is_in_repo_returns_true_if_the_given_linux_reverse_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if linux path with reverse slashes is in this repo.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_linux_path_reverse = ( "\\mnt\\T\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].is_in_repo(test_linux_path_reverse) def test_is_in_repo_returns_false_if_the_given_linux_path_is_not_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns False if linux path is not in this repo or False otherwise.""" data = setup_repository_db_tests data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" test_not_in_path_linux_path = ( "/mnt/T/Other_Projects/Sero/Task1/" "Task2/Some_file.ma" ) assert data["test_repo"].is_in_repo(test_not_in_path_linux_path) is False def test_is_in_repo_returns_true_if_the_given_windows_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if windows path is in this repo or False otherwise.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" assert data["test_repo"].is_in_repo(test_windows_path) def test_is_in_repo_returns_true_if_the_given_windows_reverse_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if windows path is in this repo or False otherwise.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path_reverse = ( "T:\\Stalker_Projects\\Sero\\Task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].is_in_repo(test_windows_path_reverse) def test_is_in_repo_is_case_insensitive_under_windows(setup_repository_db_tests): """is_in_repo is case-insensitive under windows.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_windows_path_reverse = ( "t:\\stalKer_ProjectS\\sErO\\task1\\" "Task2\\Some_file.ma" ) assert data["test_repo"].is_in_repo(test_windows_path_reverse) def test_is_in_repo_returns_false_if_the_given_windows_path_is_not_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns False if windows path is not in this repo.""" data = setup_repository_db_tests data["test_repo"].windows_path = "T:/Stalker_Projects" test_not_in_path_windows_path = "T:/Other_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].is_in_repo(test_not_in_path_windows_path) is False def test_is_in_repo_returns_true_if_the_given_macos_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if the given macos path is in this repo.""" data = setup_repository_db_tests data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/" "Some_file.ma" assert data["test_repo"].is_in_repo(test_macos_path) def test_is_in_repo_returns_true_if_the_given_macos_reverse_path_is_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns True if the macos reverse path is in this repo.""" data = setup_repository_db_tests data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_macos_path_reverse = ( "\\Volumes\\T\\Stalker_Projects\\Sero\\" "Task1\\Task2\\Some_file.ma" ) assert data["test_repo"].is_in_repo(test_macos_path_reverse) def test_is_in_repo_returns_false_if_the_given_macos_path_is_not_in_this_repo( setup_repository_db_tests, ): """is_in_repo returns False if macos path is not in this repo.""" data = setup_repository_db_tests data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" test_not_in_path_macos_path = ( "/Volumes/T/Other_Projects/Sero/Task1/" "Task2/Some_file.ma" ) assert not data["test_repo"].is_in_repo(test_not_in_path_macos_path) def test_make_relative_converts_the_given_linux_path_to_relative_to_repo_root( setup_repository_db_tests, ): """make_relative() will convert the Linux path to repository root relative path.""" data = setup_repository_db_tests # a Linux Path linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" result = data["test_repo"].make_relative(linux_path) assert result == "Sero/Task1/Task2/Some_file.ma" def test_make_relative_converts_the_given_macos_path_to_relative_to_repo_root( setup_repository_db_tests, ): """make_relative() will convert macos path to repository root relative path.""" data = setup_repository_db_tests # a macos Path macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" result = data["test_repo"].make_relative(macos_path) assert result == "Sero/Task1/Task2/Some_file.ma" def test_make_relative_converts_the_given_windows_path_to_relative_to_repo_root( setup_repository_db_tests, ): """make_relative() will convert Windows path to repository root relative path.""" data = setup_repository_db_tests # a Windows Path windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].macos_path = "T:/Stalker_Projects" result = data["test_repo"].make_relative(windows_path) assert result == "Sero/Task1/Task2/Some_file.ma" def test_make_relative_converts_the_given_path_with_env_variable_to_native_path( setup_repository_db_tests, ): """make_relative() converts path with env vars to repository root relative path.""" data = setup_repository_db_tests # so we should have the env var to be configured # now create a path with env var path = "$REPO{}/Sero/Task1/Task2/Some_file.ma".format(data["test_repo"].code) result = data["test_repo"].make_relative(path) assert result == "Sero/Task1/Task2/Some_file.ma" def test_to_os_independent_path_is_working_as_expected(setup_repository_db_tests): """to_os_independent_path() is working as expected.""" data = setup_repository_db_tests DBSession.add(data["test_repo"]) DBSession.commit() relative_part = "some/path/to/a/file.ma" test_path = "{}/{}".format(data["test_repo"].path, relative_part) assert Repository.to_os_independent_path(test_path) == "$REPO{}/{}".format( data["test_repo"].code, relative_part, ) def test_to_os_independent_path_converts_the_given_linux_path_to_universal( setup_repository_db_tests, ): """to_os_independent_path() converts Linux path to an OS independent path.""" data = setup_repository_db_tests # a Linux Path linux_path = "/mnt/T/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].linux_path = "/mnt/T/Stalker_Projects" data["test_repo"].windows_path = "T:/Stalker_Projects" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" result = data["test_repo"].to_os_independent_path(linux_path) assert result == ( "$REPO{}/Sero/Task1/Task2/Some_file.ma".format(data["test_repo"].code) ) def test_to_os_independent_path_converts_the_given_macos_path_to_universal( setup_repository_db_tests, ): """to_os_independent_path() converts macos path to an os independent path.""" data = setup_repository_db_tests # an macOS Path macos_path = "/Volumes/T/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].macos_path = "/Volumes/T/Stalker_Projects" result = data["test_repo"].to_os_independent_path(macos_path) assert result == ( "$REPO{}/Sero/Task1/Task2/Some_file.ma".format(data["test_repo"].code) ) def test_to_os_independent_path_converts_the_given_windows_path_to_universal( setup_repository_db_tests, ): """to_os_independent_path() converts Windows path to an os independent path.""" data = setup_repository_db_tests # a Windows Path windows_path = "T:/Stalker_Projects/Sero/Task1/Task2/Some_file.ma" data["test_repo"].macos_path = "T:/Stalker_Projects" result = data["test_repo"].to_os_independent_path(windows_path) assert result == "$REPO{}/Sero/Task1/Task2/Some_file.ma".format( data["test_repo"].code ) def test_to_os_independent_path_not_change_the_path_with_env_variable( setup_repository_db_tests, ): """to_os_independent_path() do not change the given path with env var.""" data = setup_repository_db_tests # so we should have the env var to be configured # now create a path with env var path = "$REPO{}/Sero/Task1/Task2/Some_file.ma".format(data["test_repo"].code) result = data["test_repo"].to_os_independent_path(path) assert result == "$REPO{}/Sero/Task1/Task2/Some_file.ma".format( data["test_repo"].code ) def test_to_os_independent_path_cannot_convert_the_given_path_with_old_env_variable_new_env_variable( setup_repository_db_tests, ): """to_os_independent_path cannot convert path with old env var to new env var.""" data = setup_repository_db_tests # so we should have the env var to be configured # now create a path with env var path = "$REPO{}/Sero/Task1/Task2/Some_file.ma".format(data["test_repo"].id) result = data["test_repo"].to_os_independent_path(path) assert result != "$REPO{}/Sero/Task1/Task2/Some_file.ma".format( data["test_repo"].code ) def test_to_os_independent_path_repo_cannot_be_found(setup_repository_db_tests): """to_os_independent_path() repo cannot be found returns the path back.""" data = setup_repository_db_tests path = "/not/on/a/particular/repo/file.ma" result = data["test_repo"].to_os_independent_path(path) assert result == path def test_find_repo_is_working_as_expected(setup_repository_db_tests): """find_repo() is working as expected.""" data = setup_repository_db_tests DBSession.add(data["test_repo"]) DBSession.commit() # add some other repositories new_repo1 = Repository( name="New Repository", code="NR", linux_path="/mnt/T/Projects", macos_path="/Volumes/T/Projects", windows_path="T:/Projects", ) DBSession.add(new_repo1) DBSession.commit() test_path = "{}/some/path/to/a/file.ma".format(data["test_repo"].path) assert Repository.find_repo(test_path) == data["test_repo"] test_path = "{}/some/path/to/a/file.ma".format(new_repo1.windows_path) assert Repository.find_repo(test_path) == new_repo1 def test_find_repo_is_case_insensitive_under_windows( setup_repository_db_tests, monkeypatch ): """find_repo() is case-insensitive under windows.""" def patched_platform_system(): """Patch the platform.system to always return Windows.""" return "Windows" monkeypatch.setattr( "stalker.models.repository.platform.system", patched_platform_system ) data = setup_repository_db_tests DBSession.add(data["test_repo"]) DBSession.commit() # add some other repositories new_repo1 = Repository( name="New Repository", code="NR", linux_path="/mnt/T/Projects", macos_path="/Volumes/T/Projects", windows_path="T:/Projects", ) DBSession.add(new_repo1) DBSession.commit() test_path = "{}/some/path/to/a/file.ma".format(data["test_repo"].path.lower()) assert Repository.find_repo(test_path) == data["test_repo"] test_path = "{}/some/path/to/a/file.ma".format(new_repo1.windows_path.lower()) assert Repository.find_repo(test_path) == new_repo1 def test_find_repo_is_working_as_expected_with_reverse_slashes( setup_repository_db_tests, ): """find_repo class works as expected with paths that contains reverse slashes.""" data = setup_repository_db_tests DBSession.add(data["test_repo"]) DBSession.commit() # add some other repositories new_repo1 = Repository( name="New Repository", code="NR", linux_path="/mnt/T/Projects", macos_path="/Volumes/T/Projects", windows_path="T:/Projects", ) DBSession.add(new_repo1) DBSession.commit() test_path = "{}\\some\\path\\to\\a\\file.ma".format(data["test_repo"].path) test_path.replace("/", "\\") assert Repository.find_repo(test_path) == data["test_repo"] test_path = "{}\\some\\path\\to\\a\\file.ma".format(new_repo1.windows_path.lower()) test_path.replace("/", "\\") assert Repository.find_repo(test_path) == new_repo1 def test_find_repo_is_working_as_expected_with_env_vars(setup_repository_db_tests): """find_repo is working as expected with paths containing env vars.""" data = setup_repository_db_tests DBSession.add(data["test_repo"]) DBSession.commit() # add some other repositories new_repo1 = Repository( name="New Repository", code="NR", linux_path="/mnt/T/Projects", macos_path="/Volumes/T/Projects", windows_path="T:/Projects", ) DBSession.add(new_repo1) DBSession.commit() # Test with env var test_path = "$REPO{}/some/path/to/a/file.ma".format(data["test_repo"].code) assert Repository.find_repo(test_path) == data["test_repo"] test_path = f"$REPO{new_repo1.code}/some/path/to/a/file.ma" assert Repository.find_repo(test_path) == new_repo1 def test_find_repo_returns_none_if_a_repo_cannot_be_found(setup_repository_db_tests): """find_repo() returns None if a repo cannot be found.""" data = setup_repository_db_tests result = data["test_repo"].find_repo("not a repo path") assert result is None def test_env_var_property_is_working_as_expected(setup_repository_db_tests): """env_var property is working as expected.""" data = setup_repository_db_tests assert data["test_repo"].env_var == "REPOR1" def test_creating_and_committing_a_new_repository_instance_will_create_env_var( setup_repository_db_tests, ): """environment variable is created if a new repository is created.""" repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/T", macos_path="/Volumes/T", windows_path="T:/", ) DBSession.add(repo) DBSession.commit() assert defaults.repo_env_var_template.format(code=repo.code) in os.environ def test_updating_a_repository_will_update_repo_path(setup_repository_db_tests): """environment variable is updated if the repository path is updated.""" repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/T", macos_path="/Volumes/T", windows_path="T:/", ) DBSession.add(repo) DBSession.commit() assert defaults.repo_env_var_template.format(code=repo.code) in os.environ # now update the repository test_value = "/mnt/S/" repo.path = test_value # expect the environment variable is also updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == test_value ) def test_updating_windows_path_only_update_repo_path_if_on_windows( setup_repository_db_tests, ): """updating the windows path will only update the path if the system is windows.""" data = setup_repository_db_tests data["patcher"].patch("Linux") repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/T", macos_path="/Volumes/T", windows_path="T:/", ) DBSession.add(repo) DBSession.commit() assert defaults.repo_env_var_template.format(code=repo.code) in os.environ # now update the repository test_value = "S:/" repo.windows_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] != test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.linux_path ) # make it windows data["patcher"].patch("Windows") # now update the repository test_value = "S:/" repo.windows_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.windows_path ) def test_updating_macos_path_only_update_repo_path_if_on_macos( setup_repository_db_tests, ): """updating the macos path will only update the path if the system is macos.""" data = setup_repository_db_tests data["patcher"].patch("Windows") repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/T", macos_path="/Volumes/T", windows_path="T:/", ) DBSession.add(repo) DBSession.commit() assert defaults.repo_env_var_template.format(code=repo.code) in os.environ # now update the repository test_value = "/Volumes/S/" repo.macos_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] != test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.windows_path ) # make it macos data["patcher"].patch("Darwin") # now update the repository test_value = "/Volumes/S/" repo.macos_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.macos_path ) def test_updating_linux_path_only_update_repo_path_if_on_linux( setup_repository_db_tests, ): """updating the linux path will only update the path if the system is linux.""" data = setup_repository_db_tests data["patcher"].patch("Darwin") repo = Repository( name="Test Repo", code="TR", linux_path="/mnt/T", macos_path="/Volumes/T", windows_path="T:/", ) DBSession.add(repo) DBSession.commit() assert defaults.repo_env_var_template.format(code=repo.code) in os.environ # now update the repository test_value = "/mnt/S/" repo.linux_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] != test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.macos_path ) # make it linux data["patcher"].patch("Linux") # now update the repository test_value = "/mnt/S/" repo.linux_path = test_value # expect the environment variable not updated assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == test_value ) assert ( os.environ[defaults.repo_env_var_template.format(code=repo.code)] == repo.linux_path ) def test_to_path_path_is_none(setup_repository_db_tests): """_to_path() path is None raises TypeError.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"]._to_path(None, "C:/") assert str(cm.value) == ( "path should be a string containing a file path, not NoneType: 'None'" ) def test_to_path_path_is_not_a_str(setup_repository_db_tests): """_to_path() path is not a str raises TypeError.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"]._to_path(1234, "C:/") assert str(cm.value) == ( "path should be a string containing a file path, not int: '1234'" ) def test_to_path_path_is_not_starting_with_a_repo_path_returns_the_path( setup_repository_db_tests, ): """_to_path() path is not starting with a repo path returns the path.""" data = setup_repository_db_tests test_value = "not_starting_with_any_repo_path" result = data["test_repo"]._to_path(test_value, "C:/") assert result == test_value def test_to_path_replace_with_is_none(setup_repository_db_tests): """_to_path() replace_with is None raises TypeError.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"]._to_path("some_path", None) assert str(cm.value) == ( "replace_with should be a string containing a file path, not NoneType: 'None'" ) def test_to_path_replace_with_is_not_a_str(setup_repository_db_tests): """_to_path() replace_with is not a str raises TypeError.""" data = setup_repository_db_tests with pytest.raises(TypeError) as cm: data["test_repo"]._to_path("some_path", 1234) assert str(cm.value) == ( "replace_with should be a string containing a file path, not int: '1234'" ) def test__hash__is_working_as_expected(setup_repository_db_tests): """__hash__ is working as expected.""" data = setup_repository_db_tests result = hash(data["test_repo"]) assert isinstance(result, int) assert result == data["test_repo"].__hash__() ================================================ FILE: tests/models/test_review.py ================================================ # -*- coding: utf-8 -*- """Tests related to Review class.""" import datetime import sys import pytest import pytz from stalker import Project, Repository, Review, Status, Structure, Task, User, Version from stalker.db.session import DBSession from stalker.models.enum import TimeUnit @pytest.fixture(scope="function") def setup_review_db_test(setup_postgresql_db): """Set up the tests for stalker.models.review.Review class with a DB.""" data = dict() data["user1"] = User( name="Test User 1", login="test_user1", email="test1@user.com", password="secret", ) DBSession.add(data["user1"]) data["user2"] = User( name="Test User 2", login="test_user2", email="test2@user.com", password="secret", ) DBSession.add(data["user2"]) data["user3"] = User( name="Test User 2", login="test_user3", email="test3@user.com", password="secret", ) DBSession.add(data["user3"]) # Review Statuses with DBSession.no_autoflush: data["status_new"] = Status.query.filter_by(code="NEW").first() data["status_rrev"] = Status.query.filter_by(code="RREV").first() data["status_app"] = Status.query.filter_by(code="APP").first() # Task Statuses data["status_wfd"] = Status.query.filter_by(code="WFD").first() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_prev"] = Status.query.filter_by(code="PREV").first() data["status_hrev"] = Status.query.filter_by(code="HREV").first() data["status_drev"] = Status.query.filter_by(code="DREV").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() data["repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) DBSession.add(data["repo"]) data["structure"] = Structure(name="Test Project Structure") DBSession.add(data["structure"]) data["project"] = Project(name="Test Project", code="TP", repository=data["repo"]) DBSession.add(data["project"]) data["task1"] = Task( name="Test Task 1", project=data["project"], resources=[data["user1"]], responsible=[data["user2"]], ) DBSession.add(data["task1"]) data["task2"] = Task( name="Test Task 2", project=data["project"], responsible=[data["user1"]] ) DBSession.add(data["task2"]) data["task3"] = Task( name="Test Task 3", parent=data["task2"], resources=[data["user1"]] ) DBSession.add(data["task3"]) data["task4"] = Task( name="Test Task 4", project=data["project"], resources=[data["user1"]], depends_on=[data["task3"]], responsible=[data["user2"]], schedule_timing=2, schedule_unit=TimeUnit.Hour, ) DBSession.add(data["task4"]) data["task5"] = Task( name="Test Task 5", project=data["project"], resources=[data["user2"]], depends_on=[data["task3"]], responsible=[data["user2"]], schedule_timing=2, schedule_unit=TimeUnit.Hour, ) DBSession.add(data["task5"]) data["task6"] = Task( name="Test Task 6", project=data["project"], resources=[data["user3"]], depends_on=[data["task3"]], responsible=[data["user2"]], schedule_timing=2, schedule_unit=TimeUnit.Hour, ) DBSession.add(data["task6"]) data["kwargs"] = {"task": data["task1"], "reviewer": data["user1"]} # add everything to the db DBSession.commit() return data def test_task_argument_is_not_a_task_instance(setup_review_db_test): """TypeError is raised if the task argument value is not a Task instance.""" data = setup_review_db_test data["kwargs"]["task"] = "not a Task instance" with pytest.raises(TypeError) as cm: Review(**data["kwargs"]) assert ( str(cm.value) == "Review.task should be an instance of stalker.models.task.Task, " "not str: 'not a Task instance'" ) def test_task_argument_is_not_a_leaf_task(setup_review_db_test): """ValueError is raised if the task given in task argument is not a leaf task.""" data = setup_review_db_test task1 = Task(name="Task1", project=data["project"]) task2 = Task(name="Task2", parent=task1) data["kwargs"]["task"] = task1 with pytest.raises(ValueError) as cm: Review(**data["kwargs"]) assert ( str(cm.value) == "It is only possible to create a review for a leaf tasks, and " " is not a leaf task." ) def test_task_argument_can_be_skipped_if_version_is_given(setup_review_db_test): """task argument can be skipped if the version arg is given.""" data = setup_review_db_test task1 = Task(name="Task1", project=data["project"]) DBSession.save(task1) version = Version(task=task1) DBSession.save(version) data["kwargs"]["version"] = version data["kwargs"].pop("task") review = Review(**data["kwargs"]) assert review.task == task1 def test_task_argument_is_working_as_expected(setup_review_db_test): """task argument value is passed to the task argument.""" data = setup_review_db_test now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) reviews = data["task1"].request_review() assert reviews[0].task == data["task1"] def test_auto_name_is_true(): """review instances are named automatically.""" assert Review.__auto_name__ is True def test_status_is_new_for_a_newly_created_review_instance(setup_review_db_test): """status is NEW for a newly created review instance.""" data = setup_review_db_test review = Review(**data["kwargs"]) assert review.status == data["status_new"] def test_review_number_attribute_is_a_read_only_attribute(setup_review_db_test): """review_number attribute is a read only attribute.""" data = setup_review_db_test review = Review(**data["kwargs"]) with pytest.raises(AttributeError) as cm: review.review_number = 2 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Review' object has no setter", 12: "property of 'Review' object has no setter", }.get( sys.version_info.minor, "property '_review_number_getter' of 'Review' object has no setter", ) assert str(cm.value) == error_message def test_review_number_attribute_is_initialized_to_the_task_review_number_plus_1( setup_review_db_test, ): """review_number attribute is initialized with task.review_number + 1.""" data = setup_review_db_test review = Review(**data["kwargs"]) assert review.review_number == 1 def test_review_number_for_multiple_responsible_task_is_equal_to_each_other( setup_review_db_test, ): """Review.review_number for a task with multiple responsible equal to each other.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) reviews = data["task1"].request_review() expected_review_number = data["task1"].review_number + 1 assert len(reviews) == 3 assert reviews[0].review_number == expected_review_number assert reviews[1].review_number == expected_review_number assert reviews[2].review_number == expected_review_number def test_reviewer_argument_is_skipped(setup_review_db_test): """TypeError is raised if the reviewer argument is skipped.""" data = setup_review_db_test data["kwargs"].pop("reviewer") with pytest.raises(TypeError) as cm: Review(**data["kwargs"]) assert ( str(cm.value) == "Review.reviewer should be set to a stalker.models.auth.User " "instance, not NoneType: 'None'" ) def test_reviewer_argument_is_none(setup_review_db_test): """TypeError is raised if the reviewer argument is None.""" data = setup_review_db_test data["kwargs"]["reviewer"] = None with pytest.raises(TypeError) as cm: Review(**data["kwargs"]) assert ( str(cm.value) == "Review.reviewer should be set to a stalker.models.auth.User " "instance, not NoneType: 'None'" ) def test_reviewer_attribute_is_set_to_none(setup_review_db_test): """TypeError is raised if the reviewer attribute is set to None.""" data = setup_review_db_test review = Review(**data["kwargs"]) with pytest.raises(TypeError) as cm: review.reviewer = None assert ( str(cm.value) == "Review.reviewer should be set to a stalker.models.auth.User " "instance, not NoneType: 'None'" ) def test_reviewer_argument_is_not_a_user_instance(setup_review_db_test): """TypeError is raised if the reviewer argument is not a User instance.""" data = setup_review_db_test data["kwargs"]["reviewer"] = "not a user instance" with pytest.raises(TypeError) as cm: Review(**data["kwargs"]) assert ( str(cm.value) == "Review.reviewer should be set to a stalker.models.auth.User " "instance, not str: 'not a user instance'" ) def test_reviewer_attribute_is_not_a_user_instance(setup_review_db_test): """TypeError is raised if the reviewer attr is not a User instance.""" data = setup_review_db_test review = Review(**data["kwargs"]) with pytest.raises(TypeError) as cm: review.reviewer = "not a user" assert ( str(cm.value) == "Review.reviewer should be set to a stalker.models.auth.User " "instance, not str: 'not a user'" ) def test_reviewer_argument_is_not_in_task_responsible_list(setup_review_db_test): """A user not listed in Task.responsible can be reviewer.""" data = setup_review_db_test data["task1"].responsible = [data["user1"]] data["kwargs"]["reviewer"] = data["user2"] review = Review(**data["kwargs"]) assert review.reviewer == data["user2"] def test_reviewer_attribute_is_not_in_task_responsible_list(setup_review_db_test): """A user not listed in Task.responsible can be reviewer.""" data = setup_review_db_test data["task1"].responsible = [data["user1"]] data["kwargs"]["reviewer"] = data["user1"] review = Review(**data["kwargs"]) review.reviewer = data["user2"] assert review.reviewer == data["user2"] def test_reviewer_argument_is_working_as_expected(setup_review_db_test): """reviewer argument value is correctly passed to reviewer attribute.""" data = setup_review_db_test data["task1"].responsible = [data["user1"]] data["kwargs"]["reviewer"] = data["user1"] review = Review(**data["kwargs"]) assert review.reviewer == data["user1"] def test_reviewer_attribute_is_working_as_expected(setup_review_db_test): """reviewer attribute is working as expected.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] data["kwargs"]["reviewer"] = data["user1"] review = Review(**data["kwargs"]) review.reviewer = data["user2"] assert review.reviewer == data["user2"] # TODO: Add tests for the same user is being the reviewer for all reviews at the same # level with same task. def test_approve_method_updates_task_status_correctly_for_a_single_responsible_task( setup_review_db_test, ): """approve() updates status correctly for a task with only one responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"]] data["kwargs"]["reviewer"] = data["user1"] assert data["task1"].status != data["status_cmpl"] review = Review(**data["kwargs"]) review.approve() assert data["task1"].status == data["status_cmpl"] def test_approve_method_updates_task_status_correctly_for_a_multi_responsible_task_if_all_approve( setup_review_db_test, ): """approve() updates status correctly for a task with multiple responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) reviews = data["task1"].request_review() review1 = reviews[0] review2 = reviews[1] review1.approve() # still pending review assert data["task1"].status == data["status_prev"] # first reviewer review2.approve() assert data["task1"].status == data["status_cmpl"] def test_approve_method_updates_task_parent_status(setup_review_db_test): """approve() updates the task parent status.""" data = setup_review_db_test data["task3"].status = data["status_rts"] now = datetime.datetime.now(pytz.utc) td = datetime.timedelta data["task3"].create_time_log( resource=data["task3"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["task3"].request_review() assert data["task3"].status == data["status_prev"] review1 = reviews[0] review1.approve() assert data["task3"].status == data["status_cmpl"] assert data["task2"].status == data["status_cmpl"] def test_approve_method_updates_task_dependent_statuses(setup_review_db_test): """approve() updates the task dependent statuses.""" data = setup_review_db_test data["task3"].status = data["status_rts"] now = datetime.datetime.now(pytz.utc) td = datetime.timedelta data["task3"].create_time_log( resource=data["task3"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["task3"].request_review() assert data["task3"].status == data["status_prev"] review1 = reviews[0] review1.approve() assert data["task3"].status == data["status_cmpl"] assert data["task4"].status == data["status_rts"] assert data["task5"].status == data["status_rts"] assert data["task6"].status == data["status_rts"] # create time logs for task4 to make it wip data["task4"].create_time_log( resource=data["task4"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) assert data["task4"].status == data["status_wip"] # now request revision to task3 data["task3"].request_revision(reviewer=data["task3"].responsible[0]) # check statuses of task4 and task4 assert data["task4"].status == data["status_drev"] assert data["task5"].status == data["status_wfd"] assert data["task6"].status == data["status_wfd"] # now approve task3 reviews = data["task3"].review_set() for rev in reviews: rev.approve() # check the task statuses again assert data["task4"].status == data["status_hrev"] assert data["task5"].status == data["status_rts"] assert data["task5"].status == data["status_rts"] def test_approve_method_updates_task_dependent_timings(setup_review_db_test): """approve updates the task dependent timings for DREV tasks with no effort left.""" data = setup_review_db_test data["task3"].status = data["status_rts"] now = datetime.datetime.now(pytz.utc) td = datetime.timedelta tlog = data["task3"].create_time_log( resource=data["task3"].resources[0], start=now, end=now + td(hours=1) ) DBSession.add(tlog) reviews = data["task3"].request_review() DBSession.add_all(reviews) assert data["task3"].status == data["status_prev"] review1 = reviews[0] review1.approve() assert data["task3"].status == data["status_cmpl"] assert data["task4"].status == data["status_rts"] assert data["task5"].status == data["status_rts"] assert data["task6"].status == data["status_rts"] # create time logs for task4 and task5 to make them wip tlog = data["task4"].create_time_log( resource=data["task4"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) DBSession.add(tlog) tlog = data["task5"].create_time_log( resource=data["task5"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) DBSession.add(tlog) # no time log for task6 assert data["task4"].status == data["status_wip"] assert data["task5"].status == data["status_wip"] assert data["task6"].status == data["status_rts"] # now request revision to task3 review = data["task3"].request_revision(reviewer=data["task3"].responsible[0]) DBSession.add(review) # check statuses of task4 and task4 assert data["task4"].status == data["status_drev"] assert data["task5"].status == data["status_drev"] assert data["task6"].status == data["status_wfd"] # TODO: add a new dependent task with schedule_model is not 'effort' # enter a new time log for task4 to complete its allowed time tlog = data["task4"].create_time_log( resource=data["task4"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) DBSession.save(tlog) # the task should have not effort left assert data["task4"].schedule_seconds == data["task4"].total_logged_seconds # task5 should have an extra time assert data["task5"].schedule_seconds == data["task5"].total_logged_seconds + 3600 # task6 should be intact assert data["task6"].total_logged_seconds == 0 # now approve task3 reviews = data["task3"].review_set() for rev in reviews: rev.approve() DBSession.commit() # check the task statuses again assert data["task4"].status == data["status_hrev"] assert data["task5"].status == data["status_hrev"] assert data["task6"].status == data["status_rts"] # and check if task4 is expanded by the timing resolution assert data["task4"].schedule_seconds == data["task4"].total_logged_seconds + 3600 # and task5 still has 1 hours assert data["task4"].schedule_seconds == data["task4"].total_logged_seconds + 3600 # and task6 intact assert data["task6"].total_logged_seconds == 0 def test_approve_method_updates_task_timings(setup_review_db_test): """approve method will also update the task timings.""" data = setup_review_db_test data["task3"].status = data["status_rts"] now = datetime.datetime.now(pytz.utc) td = datetime.timedelta data["task3"].schedule_timing = 2 data["task3"].schedule_unit = TimeUnit.Hour data["task3"].create_time_log( resource=data["task3"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["task3"].request_review() assert data["task3"].status == data["status_prev"] assert data["task3"].total_logged_seconds != data["task3"].schedule_seconds review1 = reviews[0] review1.approve() assert data["task3"].status == data["status_cmpl"] assert data["task3"].total_logged_seconds == data["task3"].schedule_seconds def test_approve_method_updates_task_status_correctly_for_a_multi_responsible_task_if_one_approve( setup_review_db_test, ): """Review.approve() updates the task status for a task with multiple responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] now = datetime.datetime.now(pytz.utc) td = datetime.timedelta data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["task1"].request_review() review1 = reviews[0] review2 = reviews[1] review1.request_revision() # one request review should be enough to set the status to hrev, # note that this is another tests duty to check assert data["task1"].status == data["status_prev"] # first reviewer review2.approve() assert data["task1"].status == data["status_hrev"] def test_request_revision_method_updates_task_status_correctly_for_a_single_responsible_task( setup_review_db_test, ): """request_revision updates status to HREV for a Task with only one responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) reviews = data["task1"].request_review() review = reviews[0] review.request_revision() assert data["task1"].status == data["status_hrev"] def test_request_revision_method_updates_task_status_correctly_for_a_multi_responsible_task_if_one_request_revision( setup_review_db_test, ): """request_revision updates status for a Task with multiple responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) # first reviewer requests a revision reviews = data["task1"].request_review() review1 = reviews[0] review2 = reviews[1] review1.approve() assert data["task1"].status == data["status_prev"] review2.request_revision() assert data["task1"].status == data["status_hrev"] def test_request_revision_method_updates_task_status_correctly_for_a_multi_responsible_task_if_all_request_revision( setup_review_db_test, ): """request_revision updates status for a Task with multiple responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["task1"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) # first reviewer requests a revision reviews = data["task1"].request_review() review1 = reviews[0] review2 = reviews[1] review1.request_revision() assert data["task1"].status == data["status_prev"] # first reviewer review2.request_revision() assert data["task1"].status == data["status_hrev"] @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_method_updates_task_timing_correctly_for_a_multi_responsible_task_if_all_request_revision( setup_review_db_test, schedule_unit ): """request_revision updates task timing for a Task with multiple responsible.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] data["task1"].schedule_timing = 3 data["task1"].schedule_unit = schedule_unit assert data["task1"].status == data["status_rts"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # create 1 hour time log tlog1 = data["task1"].create_time_log( resource=data["user1"], start=now, end=now + td(hours=1) ) DBSession.add(tlog1) DBSession.commit() # first reviewer requests a revision reviews = data["task1"].request_review() DBSession.add_all(reviews) assert len(reviews) == 2 review1 = reviews[0] review2 = reviews[1] review1.request_revision( schedule_timing=2, schedule_unit=schedule_unit, description="do some 2 hours extra work", ) assert data["task1"].status == data["status_prev"] # first reviewer review2.request_revision( schedule_timing=5, schedule_unit=schedule_unit, description="do some 5 hours extra work", ) assert data["task1"].status == data["status_hrev"] # check the timing values assert data["task1"].schedule_timing == 8 assert data["task1"].schedule_unit == TimeUnit.Hour @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_method_updates_task_timing_correctly_for_a_multi_responsible_task_with_exactly_the_same_amount_of_schedule_timing_as_the_given_revision_timing( setup_review_db_test, schedule_unit ): """request_revision updates the task timing for a Task with multiple responsible. And has the same amount of schedule timing left with the given revision without expanding the task more then the total amount of revision requested. """ data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] data["task1"].schedule_timing = 8 data["task1"].schedule_unit = schedule_unit assert data["task1"].status == data["status_rts"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # create 1 hour time log data["task1"].create_time_log( resource=data["user1"], start=now, end=now + td(hours=1) ) # we should have 7 hours left # first reviewer requests a revision reviews = data["task1"].request_review() assert len(reviews) == 2 review1 = reviews[0] review2 = reviews[1] review1.request_revision( schedule_timing=2, schedule_unit=schedule_unit, description="do some 2 hours extra work", ) assert data["task1"].status == data["status_prev"] # first reviewer review2.request_revision( schedule_timing=5, schedule_unit=schedule_unit, description="do some 5 hours extra work", ) assert data["task1"].status == data["status_hrev"] # check the timing values assert data["task1"].schedule_timing == 8 assert data["task1"].schedule_unit == TimeUnit.Hour @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_method_updates_task_timing_correctly_for_a_multi_responsible_task_with_more_schedule_timing_then_given_revision_timing( setup_review_db_test, schedule_unit ): """request_revision updates the task timing for a Task with multiple responsible. And still has more schedule timing then the given revision without expanding the task more then the total amount of revision requested. """ data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"]] data["task1"].schedule_timing = 100 data["task1"].schedule_unit = schedule_unit assert data["task1"].status == data["status_rts"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # create 1 hour time log data["task1"].create_time_log( resource=data["user1"], start=now, end=now + td(hours=1) ) # we should have 8 hours left # first reviewer requests a revision reviews = data["task1"].request_review() assert len(reviews) == 2 review1 = reviews[0] review2 = reviews[1] review1.request_revision( schedule_timing=2, schedule_unit=schedule_unit, description="do some 2 hours extra work", ) assert data["task1"].status == data["status_prev"] # first reviewer review2.request_revision( schedule_timing=5, schedule_unit=schedule_unit, description="do some 5 hours extra work", ) assert data["task1"].status == data["status_hrev"] # check the timing values assert data["task1"].schedule_timing == 100 assert data["task1"].schedule_unit == TimeUnit.Hour def test_review_set_property_return_all_the_revision_instances_with_same_review_number( setup_review_db_test, ): """review_set returns all the Reviews of the task with the same review_number.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] reviews = data["task1"].request_review() review1 = reviews[0] review2 = reviews[1] review3 = reviews[2] assert review1.review_number == 1 assert review2.review_number == 1 assert review3.review_number == 1 review1.approve() review2.approve() review3.approve() review4 = data["task1"].request_revision(reviewer=data["user1"]) data["task1"].status = data["status_wip"] assert review4.review_number == 2 # enter new time log to turn it into WIP data["task1"].create_time_log( resource=data["user1"], start=now + datetime.timedelta(hours=1), end=now + datetime.timedelta(hours=2), ) review_set2 = data["task1"].request_review() review5 = review_set2[0] review6 = review_set2[1] review7 = review_set2[2] assert review5.review_number == 3 assert review6.review_number == 3 assert review7.review_number == 3 def test_review__init__version_arg_is_skipped(setup_review_db_test): """Review.__init__() version arg can be skipped.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] review = Review(task=data["task1"], reviewer=data["task1"].responsible[0]) assert isinstance(review, Review) def test_review__init__version_arg_is_none(setup_review_db_test): """Review.__init__() version arg can be None.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] review = Review( task=data["task1"], version=None, reviewer=data["task1"].responsible[0] ) assert isinstance(review, Review) def test_review__init__version_arg_is_not_a_version_instance(setup_review_db_test): """Review.__init__() version arg is not a Version instance.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] with pytest.raises(TypeError) as cm: _ = Review( task=data["task1"], version="not a version", reviewer=data["task1"].responsible[0], ) assert str(cm.value) == ( "Review.version should be a Version instance, " "not str: 'not a version'" ) def test_review__init__version_arg_is_not_related_to_the_given_task( setup_review_db_test, ): """Review.__init__() raises ValueError if the version is not matching the task.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] version = Version(task=data["task2"]) with pytest.raises(ValueError) as cm: _ = Review( task=data["task1"], version=version, reviewer=data["task1"].responsible[0] ) assert str(cm.value) == ( "Review.version should be a Version instance " f"related to this Task: {version}" ) def test_review___init__accepts_a_version_with_version_argument(setup_review_db_test): """Review.__init__() accepts a Version instance with version argument.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] version = Version(task=data["task1"]) review = Review( task=data["task1"], version=version, reviewer=data["task1"].responsible[0] ) assert isinstance(review, Review) def test_review___init__version_arg_value_passed_to_version_attr(setup_review_db_test): """Review.__init__() version arg value is passed to the version attr.""" data = setup_review_db_test data["task1"].responsible = [data["user1"], data["user2"], data["user3"]] now = datetime.datetime.now(pytz.utc) data["task1"].create_time_log( resource=data["user1"], start=now, end=now + datetime.timedelta(hours=1) ) data["task1"].status = data["status_wip"] version = Version(task=data["task1"]) review = Review( task=data["task1"], version=version, reviewer=data["task1"].responsible[0] ) assert review.version == version ================================================ FILE: tests/models/test_role.py ================================================ # -*- coding: utf-8 -*- """Tests for the Role class.""" from stalker import Role def test_role_class_generic(): """creation of a Role instance.""" r = Role(name="Lead") assert isinstance(r, Role) assert r.name == "Lead" ================================================ FILE: tests/models/test_scene.py ================================================ # -*- coding: utf-8 -*- """Tests for the Scene class.""" import pytest from stalker import ( Entity, Project, Repository, Scene, Status, StatusList, Task, Type, User, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_scene_db_tests(setup_postgresql_db): """Set up the Scene tests with a DB.""" data = dict() # create a test project, user and a couple of shots data["project_type"] = Type( name="Test Project Type", code="test", target_entity_type="Project", ) # create a repository data["repository_type"] = Type( name="Test Type", code="test", target_entity_type="Repository" ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["repository_type"], ) # create projects data["test_project"] = Project( name="Test Project 1", code="tp1", type=data["project_type"], repository=data["test_repository"], ) data["test_project2"] = Project( name="Test Project 2", code="tp2", type=data["project_type"], repository=data["test_repository"], ) # the parameters data["kwargs"] = { "name": "Test Scene", "code": "tsce", "description": "A test scene", "project": data["test_project"], } # the test sequence data["test_scene"] = Scene(**data["kwargs"]) DBSession.add(data["test_scene"]) DBSession.commit() return data def test_scene_is_deriving_from_task(): """Scene is deriving from Task class.""" assert Task in Scene.__mro__ def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Scene class.""" assert Scene.__auto_name__ is False def test_shots_attribute_defaults_to_empty_list(setup_scene_db_tests): """shots attribute defaults to an empty list.""" data = setup_scene_db_tests new_scene = Scene(**data["kwargs"]) assert new_scene.shots == [] def test_shots_attribute_is_set_to_none(setup_scene_db_tests): """TypeError is raised if the shots attribute is set to None.""" data = setup_scene_db_tests with pytest.raises(TypeError) as cm: data["test_scene"].shots = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_shots_attribute_is_set_to_other_than_a_list(setup_scene_db_tests): """TypeError is raised if the shots attr is not a list.""" data = setup_scene_db_tests test_value = [1, 1.2, "a string"] with pytest.raises(TypeError) as cm: data["test_scene"].shots = test_value assert str(cm.value) == ( "Scene.shots should only contain instances of " "stalker.models.shot.Shot, not int: '1'" ) def test_shots_attribute_is_a_list_of_other_objects(setup_scene_db_tests): """TypeError raised if the shots argument is a list of other type of objects.""" data = setup_scene_db_tests test_value = [1, 1.2, "a string"] with pytest.raises(TypeError) as cm: data["test_scene"].shots = test_value assert str(cm.value) == ( "Scene.shots should only contain instances of " "stalker.models.shot.Shot, not int: '1'" ) def test_shots_attribute_elements_tried_to_be_set_to_non_shot_object( setup_scene_db_tests, ): """TypeError raised if shots list appended a not Shot instance.""" data = setup_scene_db_tests with pytest.raises(TypeError) as cm: data["test_scene"].shots.append("a string") assert str(cm.value) == ( "Scene.shots should only contain instances of " "stalker.models.shot.Shot, not str: 'a string'" ) def test_equality(setup_scene_db_tests): """equality of scene instances.""" data = setup_scene_db_tests new_seq1 = Scene(**data["kwargs"]) new_seq2 = Scene(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["name"] = "a different scene" new_seq3 = Scene(**data["kwargs"]) assert new_seq1 == new_seq2 assert not new_seq1 == new_seq3 assert not new_seq1 == new_entity def test_inequality(setup_scene_db_tests): """inequality of scene instances.""" data = setup_scene_db_tests new_seq1 = Scene(**data["kwargs"]) new_seq2 = Scene(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["name"] = "a different scene" new_seq3 = Scene(**data["kwargs"]) assert not new_seq1 != new_seq2 assert new_seq1 != new_seq3 assert new_seq1 != new_entity def test_project_mixin_initialization(setup_scene_db_tests): """ProjectMixin part is initialized correctly.""" data = setup_scene_db_tests project_type = Type(name="Commercial", code="comm", target_entity_type="Project") new_project = Project( name="Test Project", code="tp", type=project_type, repository=data["test_repository"], ) data["kwargs"]["project"] = new_project new_scene = Scene(**data["kwargs"]) assert new_scene.project == new_project def test___strictly_typed___is_false(): """__strictly_typed__ class attribute is False for Scene class.""" assert Scene.__strictly_typed__ is False def test__hash__is_working_as_expected(setup_scene_db_tests): """__hash__ is working as expected.""" data = setup_scene_db_tests result = hash(data["test_scene"]) assert isinstance(result, int) assert result == data["test_scene"].__hash__() def test_can_be_used_in_a_task_hierarchy(setup_scene_db_tests): """Scene can be used in a Task hierarchy.""" data = setup_scene_db_tests task1 = Task(name="Parent Task", project=data["test_project"]) data["test_scene"].parent = task1 assert data["test_scene"] in task1.children def test_scenes_can_use_task_status_list(): """It is possible to use TaskStatus lists with Shots.""" # users test_user1 = User( name="User1", login="user1", password="12345", email="user1@user1.com" ) # statuses status_wip = Status(code="WIP", name="Work In Progress") status_cmpl = Status(code="CMPL", name="Complete") # Just create a StatusList for Tasks task_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Task" ) project_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Project" ) # types commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) # project project1 = Project( name="Test Project1", code="tp1", type=commercial_project_type, status_list=project_status_list, ) # sequence test_scene = Scene( name="Test Scene", code="tsce", project=project1, status_list=task_status_list, responsible=[test_user1], ) assert test_scene.status_list == task_status_list ================================================ FILE: tests/models/test_schedule_constraint.py ================================================ # -*- coding: utf-8 -*- """ScheduleConstraint related tests are here.""" from enum import IntEnum import sys import pytest from stalker.models.enum import ScheduleConstraint, ScheduleConstraintDecorator @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 a ScheduleConstraint enum value or an int or a " "str, 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 a ScheduleConstraint enum value or 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_to_constraint_is_working_properly(constraint_name, constraint): """ScheduleConstraint can parse schedule constraint names.""" assert ScheduleConstraint.to_constraint(constraint_name) == constraint def test_cache_ok_is_true_in_type_decorator(): """ScheduleConstraintDecorator.cache_ok is True.""" assert ScheduleConstraintDecorator.cache_ok is True ================================================ FILE: tests/models/test_schedule_model.py ================================================ # -*- coding: utf-8 -*- """ScheduleModel related tests are here.""" from enum import Enum import sys import pytest from stalker.models.enum import ScheduleModel, ScheduleModelDecorator @pytest.mark.parametrize( "model", [ ScheduleModel.Effort, ScheduleModel.Duration, ScheduleModel.Length, ], ) def test_it_is_an_enum(model): """ScheduleModel is an Enum.""" assert isinstance(model, Enum) @pytest.mark.parametrize( "model,expected_value", [ [ScheduleModel.Effort, "effort"], [ScheduleModel.Duration, "duration"], [ScheduleModel.Length, "length"], ], ) def test_enum_values(model, expected_value): """Test enum values.""" assert model.value == expected_value @pytest.mark.parametrize( "model,expected_name", [ [ScheduleModel.Effort, "Effort"], [ScheduleModel.Duration, "Duration"], [ScheduleModel.Length, "Length"], ], ) def test_enum_names(model, expected_name): """Test enum names.""" assert model.name == expected_name @pytest.mark.parametrize( "model,expected_value", [ [ScheduleModel.Effort, "effort"], [ScheduleModel.Duration, "duration"], [ScheduleModel.Length, "length"], ], ) def test_enum_as_str(model, expected_value): """Test enum names.""" assert str(model) == expected_value def test_to_model_model_is_skipped(): """ScheduleModel.to_model() model is skipped.""" with pytest.raises(TypeError) as cm: _ = ScheduleModel.to_model() py_error_message = { 8: "to_model() missing 1 required positional argument: 'model'", 9: "to_model() missing 1 required positional argument: 'model'", 10: "ScheduleModel.to_model() missing 1 required positional argument: 'model'", 11: "ScheduleModel.to_model() missing 1 required positional argument: 'model'", 12: "ScheduleModel.to_model() missing 1 required positional argument: 'model'", 13: "ScheduleModel.to_model() missing 1 required positional argument: 'model'", }[sys.version_info.minor] assert str(cm.value) == py_error_message def test_to_model_model_is_none(): """ScheduleModel.to_model() model is None.""" with pytest.raises(TypeError) as cm: _ = ScheduleModel.to_model(None) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not NoneType: 'None'" ) def test_to_model_model_is_not_a_str(): """ScheduleModel.to_model() model is not a str.""" with pytest.raises(TypeError) as cm: _ = ScheduleModel.to_model(12334.123) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not float: '12334.123'" ) def test_to_model_model_is_not_a_valid_str(): """ScheduleModel.to_model() model is not a valid str.""" with pytest.raises(ValueError) as cm: _ = ScheduleModel.to_model("not a valid value") assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not 'not a valid value'" ) @pytest.mark.parametrize( "model_name,model", [ # Effort ["Effort", ScheduleModel.Effort], ["effort", ScheduleModel.Effort], ["EFFORT", ScheduleModel.Effort], ["EfFoRt", ScheduleModel.Effort], # Duration ["Duration", ScheduleModel.Duration], ["duration", ScheduleModel.Duration], ["DURATION", ScheduleModel.Duration], ["DuRaTiOn", ScheduleModel.Duration], ["dUrAtIoN", ScheduleModel.Duration], # Length ["Length", ScheduleModel.Length], ["length", ScheduleModel.Length], ["LENGTH", ScheduleModel.Length], ["LeNgTh", ScheduleModel.Length], ["lEnGtH", ScheduleModel.Length], ], ) def test_to_model_is_working_properly(model_name, model): """ScheduleModel can parse schedule model names.""" assert ScheduleModel.to_model(model_name) == model def test_cache_ok_is_true_in_type_decorator(): """ScheduleModelDecorator.cache_ok is True.""" assert ScheduleModelDecorator.cache_ok is True ================================================ FILE: tests/models/test_schedulers.py ================================================ # -*- coding: utf-8 -*- """Tests for the SchedulerBase class.""" import pytest from stalker import SchedulerBase, Studio @pytest.fixture(scope="function") def setup_scheduler_base_tests(): """Set up the tests for stalker.models.scheduler.SchedulerBase class.""" data = dict() data["test_studio"] = Studio(name="Test Studio") data["kwargs"] = {"studio": data["test_studio"]} data["test_scheduler_base"] = SchedulerBase(**data["kwargs"]) return data def test_studio_argument_is_skipped(setup_scheduler_base_tests): """studio attribute None if the studio argument is skipped.""" data = setup_scheduler_base_tests data["kwargs"].pop("studio") new_scheduler_base = SchedulerBase(**data["kwargs"]) assert new_scheduler_base.studio is None def test_studio_argument_is_none(setup_scheduler_base_tests): """studio attribute None if the studio argument is None.""" data = setup_scheduler_base_tests data["kwargs"]["studio"] = None new_scheduler_base = SchedulerBase(**data["kwargs"]) assert new_scheduler_base.studio is None def test_studio_attribute_is_none(setup_scheduler_base_tests): """studio argument can be set to None.""" data = setup_scheduler_base_tests data["test_scheduler_base"].studio = None assert data["test_scheduler_base"].studio is None def test_studio_argument_is_not_a_studio_instance(setup_scheduler_base_tests): """TypeError raised if the studio argument is not Studio instance.""" data = setup_scheduler_base_tests data["kwargs"]["studio"] = "not a studio instance" with pytest.raises(TypeError) as cm: SchedulerBase(**data["kwargs"]) assert ( str(cm.value) == "SchedulerBase.studio should be an instance of " "stalker.models.studio.Studio, not str: 'not a studio instance'" ) def test_studio_attribute_is_not_a_studio_instance(setup_scheduler_base_tests): """TypeError raised if the studio attr is not a Studio instance.""" data = setup_scheduler_base_tests with pytest.raises(TypeError) as cm: data["test_scheduler_base"].studio = "not a studio instance" assert ( str(cm.value) == "SchedulerBase.studio should be an instance of " "stalker.models.studio.Studio, not str: 'not a studio instance'" ) def test_studio_argument_is_working_as_expected(setup_scheduler_base_tests): """studio argument value is correctly passed to the studio attribute.""" data = setup_scheduler_base_tests assert data["test_scheduler_base"].studio == data["kwargs"]["studio"] def test_studio_attribute_is_working_as_expected(setup_scheduler_base_tests): """studio attribute is working as expected.""" data = setup_scheduler_base_tests new_studio = Studio(name="Test Studio 2") data["test_scheduler_base"].studio = new_studio assert data["test_scheduler_base"].studio == new_studio def test_schedule_method_will_raise_not_implemented_error(): """schedule() method will raise a NotImplementedError.""" base = SchedulerBase() with pytest.raises(NotImplementedError) as cm: base.schedule() assert str(cm.value) == "" ================================================ FILE: tests/models/test_sequence.py ================================================ # -*- coding: utf-8 -*- """Tests for the Sequence class.""" import pytest from stalker import ( Entity, File, Project, Repository, Sequence, Status, StatusList, Task, Type, User, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_sequence_db_tests(setup_postgresql_db): """Set up the tests for the Sequence class with a DB.""" data = dict() # create a test project, user and a couple of shots data["project_type"] = Type( name="Test Project Type", code="test", target_entity_type="Project", ) DBSession.add(data["project_type"]) # create a repository data["repository_type"] = Type( name="Test Type", code="test", target_entity_type="Repository" ) DBSession.add(data["repository_type"]) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["repository_type"], ) DBSession.add(data["test_repository"]) # create projects data["test_project"] = Project( name="Test Project 1", code="tp1", type=data["project_type"], repository=data["test_repository"], ) DBSession.add(data["test_project"]) data["test_project2"] = Project( name="Test Project 2", code="tp2", type=data["project_type"], repository=data["test_repository"], ) DBSession.add(data["test_project2"]) # the parameters data["kwargs"] = { "name": "Test Sequence", "code": "tseq", "description": "A test sequence", "project": data["test_project"], } # the test sequence data["test_sequence"] = Sequence(**data["kwargs"]) DBSession.commit() return data def test___auto_name__class_attribute_is_set_to_false(setup_sequence_db_tests): """__auto_name__ class attribute is set to False for Sequence class.""" assert Sequence.__auto_name__ is False def test_plural_class_name(setup_sequence_db_tests): """plural name of Sequence class.""" data = setup_sequence_db_tests assert data["test_sequence"].plural_class_name == "Sequences" def test___strictly_typed___is_False(): """__strictly_typed__ class attribute is False for Sequence class.""" assert Sequence.__strictly_typed__ is False def test_shots_attribute_defaults_to_empty_list(setup_sequence_db_tests): """shots attribute defaults to an empty list.""" data = setup_sequence_db_tests new_sequence = Sequence(**data["kwargs"]) assert new_sequence.shots == [] def test_shots_attribute_is_set_none(setup_sequence_db_tests): """TypeError raised if the shots attribute set to None.""" data = setup_sequence_db_tests with pytest.raises(TypeError) as cm: data["test_sequence"].shots = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_shots_attribute_is_set_to_other_than_a_list(setup_sequence_db_tests): """TypeError raised if the shots attr is set to something other than a list.""" data = setup_sequence_db_tests test_value = "a string" with pytest.raises(TypeError) as cm: data["test_sequence"].shots = test_value assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_shots_attribute_is_a_list_of_other_objects(setup_sequence_db_tests): """TypeError raised if the shots argument is a list of other type of objects.""" data = setup_sequence_db_tests test_value = [1, 1.2, "a string"] with pytest.raises(TypeError) as cm: data["test_sequence"].shots = test_value assert str(cm.value) == ( "Sequence.shots should only contain instances of " "stalker.models.shot.Shot, not int: '1'" ) def test_shots_attribute_elements_tried_to_be_set_to_non_Shot_object( setup_sequence_db_tests, ): """TypeError raised if the shots attr appended not a Shot instance.""" data = setup_sequence_db_tests test_value = "a string" with pytest.raises(TypeError) as cm: data["test_sequence"].shots.append(test_value) assert str(cm.value) == ( "Sequence.shots should only contain instances of " "stalker.models.shot.Shot, not str: 'a string'" ) def test_equality(setup_sequence_db_tests): """equality of sequences.""" data = setup_sequence_db_tests new_seq1 = Sequence(**data["kwargs"]) new_seq2 = Sequence(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["name"] = "a different sequence" new_seq3 = Sequence(**data["kwargs"]) assert new_seq1 == new_seq2 assert not new_seq1 == new_seq3 assert not new_seq1 == new_entity def test_inequality(setup_sequence_db_tests): """inequality of sequences.""" data = setup_sequence_db_tests new_seq1 = Sequence(**data["kwargs"]) new_seq2 = Sequence(**data["kwargs"]) new_entity = Entity(**data["kwargs"]) data["kwargs"]["name"] = "a different sequence" new_seq3 = Sequence(**data["kwargs"]) assert not new_seq1 != new_seq2 assert new_seq1 != new_seq3 assert new_seq1 != new_entity def test_reference_mixin_initialization(setup_sequence_db_tests): """ReferenceMixin part is initialized correctly.""" data = setup_sequence_db_tests file_type_1 = Type(name="Image", code="image", target_entity_type="File") file1 = File( name="Artwork 1", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="a.jpg", type=file_type_1, ) file2 = File( name="Artwork 2", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="b.jbg", type=file_type_1, ) references = [file1, file2] data["kwargs"]["references"] = references new_sequence = Sequence(**data["kwargs"]) assert new_sequence.references == references def test_initialization_of_task_part(setup_sequence_db_tests): """Task part is initialized correctly.""" data = setup_sequence_db_tests project_type = Type(name="Commercial", code="comm", target_entity_type="Project") new_project = Project( name="Commercial", code="comm", type=project_type, repository=data["test_repository"], ) data["kwargs"]["project"] = new_project new_sequence = Sequence(**data["kwargs"]) task1 = Task( name="Modeling", status=0, project=new_project, parent=new_sequence, ) task2 = Task( name="Lighting", status=0, project=new_project, parent=new_sequence, ) tasks = [task1, task2] assert sorted(new_sequence.tasks, key=lambda x: x.name) == sorted( tasks, key=lambda x: x.name ) def test_project_mixin_initialization(setup_sequence_db_tests): """ProjectMixin part is initialized correctly.""" data = setup_sequence_db_tests project_type = Type(name="Commercial", code="comm", target_entity_type="Project") new_project = Project( name="Test Project", code="tp", type=project_type, repository=data["test_repository"], ) data["kwargs"]["project"] = new_project new_sequence = Sequence(**data["kwargs"]) assert new_sequence.project == new_project def test__hash__is_working_as_expected(setup_sequence_db_tests): """__hash__ is working as expected.""" data = setup_sequence_db_tests result = hash(data["test_sequence"]) assert isinstance(result, int) assert result == data["test_sequence"].__hash__() def test_sequences_can_use_task_status_list(): """It is possible to use TaskStatus lists with Shots.""" # users test_user1 = User( name="User1", login="user1", password="12345", email="user1@user1.com" ) # statuses status_wip = Status(code="WIP", name="Work In Progress") status_cmpl = Status(code="CMPL", name="Complete") # Just create a StatusList for Tasks task_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Task" ) project_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Project" ) # types commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) # project project1 = Project( name="Test Project1", code="tp1", type=commercial_project_type, status_list=project_status_list, ) # sequence test_seq1 = Sequence( name="Test Sequence", code="tseq", project=project1, status_list=task_status_list, responsible=[test_user1], ) assert test_seq1.status_list == task_status_list ================================================ FILE: tests/models/test_shot.py ================================================ # -*- coding: utf-8 -*- """Tests for the Shot class.""" import sys import pytest from stalker import ( Asset, Entity, ImageFormat, File, Project, Repository, Scene, Sequence, Shot, Status, StatusList, Task, Type, User, ) from stalker.db.session import DBSession @pytest.fixture(scope="function") def setup_shot_db_tests(setup_postgresql_db): """Set up the tests for the Shot class with a DB.""" data = dict() data["database_settings"] = setup_postgresql_db # statuses # types data["test_commercial_project_type"] = Type( name="Commercial Project", code="comm", target_entity_type="Project", ) DBSession.add(data["test_commercial_project_type"]) data["test_character_asset_type"] = Type( name="Character", code="char", target_entity_type="Asset", ) DBSession.add(data["test_character_asset_type"]) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository" ) DBSession.add(data["test_repository_type"]) # repository data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], ) DBSession.add(data["test_repository"]) # image format data["test_image_format1"] = ImageFormat( name="Test Image Format 1", width=1920, height=1080, pixel_aspect=1.0 ) DBSession.add(data["test_image_format1"]) data["test_image_format2"] = ImageFormat( name="Test Image Format 2", width=1280, height=720, pixel_aspect=1.0 ) DBSession.add(data["test_image_format2"]) # project and sequences data["test_project1"] = Project( name="Test Project1", code="tp1", type=data["test_commercial_project_type"], repository=data["test_repository"], image_format=data["test_image_format1"], ) DBSession.add(data["test_project1"]) DBSession.commit() data["test_project2"] = Project( name="Test Project2", code="tp2", type=data["test_commercial_project_type"], repository=data["test_repository"], image_format=data["test_image_format1"], ) DBSession.add(data["test_project2"]) DBSession.commit() data["test_sequence1"] = Sequence( name="Test Seq1", code="ts1", project=data["test_project1"], ) DBSession.add(data["test_sequence1"]) DBSession.commit() data["test_sequence2"] = Sequence( name="Test Seq2", code="ts2", project=data["test_project1"], ) DBSession.add(data["test_sequence2"]) DBSession.commit() data["test_sequence3"] = Sequence( name="Test Seq3", code="ts3", project=data["test_project1"], ) DBSession.add(data["test_sequence3"]) DBSession.commit() data["test_scene1"] = Scene( name="Test Sce1", code="tsc1", project=data["test_project1"], ) DBSession.add(data["test_scene1"]) DBSession.commit() data["test_scene2"] = Scene( name="Test Sce2", code="tsc2", project=data["test_project1"], ) DBSession.add(data["test_scene2"]) DBSession.commit() data["test_scene3"] = Scene( name="Test Sce3", code="tsc3", project=data["test_project1"] ) DBSession.add(data["test_scene3"]) DBSession.commit() data["test_asset1"] = Asset( name="Test Asset1", code="ta1", project=data["test_project1"], type=data["test_character_asset_type"], ) DBSession.add(data["test_asset1"]) DBSession.commit() data["test_asset2"] = Asset( name="Test Asset2", code="ta2", project=data["test_project1"], type=data["test_character_asset_type"], ) DBSession.add(data["test_asset2"]) DBSession.commit() data["test_asset3"] = Asset( name="Test Asset3", code="ta3", project=data["test_project1"], type=data["test_character_asset_type"], ) DBSession.add(data["test_asset3"]) DBSession.commit() data["kwargs"] = dict( name="SH123", code="SH123", description="This is a test Shot", project=data["test_project1"], sequence=data["test_sequence1"], scene=data["test_scene1"], cut_in=112, cut_out=149, source_in=120, source_out=140, record_in=85485, status=0, image_format=data["test_image_format2"], ) # create a mock shot object data["test_shot"] = Shot(**data["kwargs"]) DBSession.add(data["test_shot"]) DBSession.commit() return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for Shot class.""" assert Shot.__auto_name__ is True def test___hash___value_is_correctly_calculated(setup_shot_db_tests): """__hash__ value is correctly calculated.""" data = setup_shot_db_tests assert data["test_shot"].__hash__() == hash( "{}:{}:{}".format( data["test_shot"].id, data["test_shot"].name, data["test_shot"].entity_type ) ) def test_project_argument_is_skipped(setup_shot_db_tests): """TypeError raised if the project argument is skipped.""" data = setup_shot_db_tests data["kwargs"].pop("project") with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert ( str(cm.value) == "Shot.project should be an instance of " "stalker.models.project.Project, not NoneType: 'None'.\n\nOr please supply " "a stalker.models.task.Task with the parent argument, so " "Stalker can use the project of the supplied parent task" ) def test_project_argument_is_None(setup_shot_db_tests): """TypeError raised if the project argument is None.""" data = setup_shot_db_tests data["kwargs"]["project"] = None with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert ( str(cm.value) == "Shot.project should be an instance of " "stalker.models.project.Project, not NoneType: 'None'.\n\nOr please supply " "a stalker.models.task.Task with the parent argument, so " "Stalker can use the project of the supplied parent task" ) @pytest.mark.parametrize("test_value", [1, 1.2, "project", ["a", "project"]]) def test_project_argument_is_not_project_instance(test_value, setup_shot_db_tests): """TypeError raised if the given project argument is not a Project instance.""" data = setup_shot_db_tests data["kwargs"]["project"] = test_value with pytest.raises(TypeError) as cm: Shot(data["kwargs"]) assert str(cm.value) == ( "Shot.project should be an instance of stalker.models.project.Project, not " "NoneType: 'None'.\n\nOr please supply a stalker.models.task.Task with the parent " "argument, so Stalker can use the project of the supplied parent task" ) def test_project_already_has_a_shot_with_the_same_code(setup_shot_db_tests): """ValueError raised if project argument already has a shot with the same code.""" data = setup_shot_db_tests # let's try to assign the shot to the same sequence2 which has another # shot with the same code assert data["kwargs"]["code"] == data["test_shot"].code with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "There is a Shot with the same code: SH123" # this should not raise a ValueError data["kwargs"]["code"] = "DifferentCode" new_shot2 = Shot(**data["kwargs"]) assert isinstance(new_shot2, Shot) def test_code_attribute_is_set_to_the_same_value(setup_shot_db_tests): """ValueError will NOT be raised if the shot.code is set to the same value.""" data = setup_shot_db_tests data["test_shot"].code = data["test_shot"].code def test_project_attribute_is_read_only(setup_shot_db_tests): """project attribute is read only.""" data = setup_shot_db_tests with pytest.raises(AttributeError) as cm: data["test_shot"].project = data["test_project2"] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Shot' object has no setter", 12: "property of 'Shot' object has no setter", }.get( sys.version_info.minor, "property '_project_getter' of 'Shot' object has no setter", ) assert str(cm.value) == error_message def test_project_contains_shots(setup_shot_db_tests): """shot is added to the Project.shots list.""" data = setup_shot_db_tests assert data["test_shot"] in data["test_shot"].project.shots def test_project_argument_is_working_as_expected(setup_shot_db_tests): """project argument is working as expected.""" data = setup_shot_db_tests assert data["test_shot"].project == data["kwargs"]["project"] def test_sequence_argument_is_skipped(setup_shot_db_tests): """sequence attribute a None if the sequence argument is skipped.""" data = setup_shot_db_tests data["kwargs"].pop("sequence") data["kwargs"]["code"] = "DifferentCode" new_shot = Shot(**data["kwargs"]) assert new_shot.sequence == None def test_sequence_argument_is_none(setup_shot_db_tests): """sequence attribute is None if the sequence argument is set to None.""" data = setup_shot_db_tests data["kwargs"]["sequence"] = None data["kwargs"]["code"] = "NewCode" new_shot = Shot(**data["kwargs"]) assert new_shot.sequence is None def test_sequence_attribute_is_set_to_none(setup_shot_db_tests): """No TypeError raised if the sequence attribute is set to None.""" data = setup_shot_db_tests data["test_shot"].sequence = None def test_sequence_argument_is_not_a_list(setup_shot_db_tests): """TypeError raised if the sequence argument is not a Sequence.""" data = setup_shot_db_tests data["kwargs"]["sequence"] = "not a sequence" data["kwargs"]["code"] = "NewCode" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.sequence should be a stalker.models.sequence.Sequence instance, " "not str: 'not a sequence'" ) def test_sequence_attribute_is_not_a_sequence(setup_shot_db_tests): """TypeError raised if the sequence attribute is not a Sequence.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].sequence = "not a sequence" assert str(cm.value) == ( "Shot.sequence should be a stalker.models.sequence.Sequence instance, " "not str: 'not a sequence'" ) def test_sequence_argument_is_a_list_of_sequence_instances(setup_shot_db_tests): """TypeError raised if the sequence argument is a list of Sequences.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" seq1 = Sequence( name="seq1", code="seq1", project=data["test_project1"], ) seq2 = Sequence( name="seq2", code="seq2", project=data["test_project1"], ) seq3 = Sequence( name="seq3", code="seq3", project=data["test_project1"], ) seqs = [seq1, seq2, seq3] data["kwargs"]["sequence"] = seqs with pytest.raises(TypeError) as cm: _ = Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.sequence should be a stalker.models.sequence.Sequence instance, " "not list: '[, , ]'" ) def test_sequence_attribute_is_a_list_of_Sequence_instances(setup_shot_db_tests): """TypeError raised if the sequence attr is a list of Sequence instances.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" seq1 = Sequence( name="seq1", code="seq1", project=data["test_project1"], ) seq2 = Sequence( name="seq2", code="seq2", project=data["test_project1"], ) seq3 = Sequence( name="seq3", code="seq3", project=data["test_project1"], ) new_shot = Shot(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_shot.sequence = [seq1, seq2, seq3] assert str(cm.value) == ( "Shot.sequence should be a stalker.models.sequence.Sequence instance, " "not list: '[, , ]'" ) def test_sequence_argument_is_working_as_expected(setup_shot_db_tests): """sequence attribute is working as expected.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" seq1 = Sequence( name="seq1", code="seq1", project=data["test_project1"], ) seq2 = Sequence( name="seq2", code="seq2", project=data["test_project1"], ) seq3 = Sequence( name="seq3", code="seq3", project=data["test_project1"], ) data["kwargs"]["sequence"] = seq2 new_shot = Shot(**data["kwargs"]) assert new_shot.sequence == seq2 def test_sequence_attribute_is_working_as_expected(setup_shot_db_tests): """sequence attribute is working as expected.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" seq1 = Sequence( name="seq1", code="seq1", project=data["test_project1"], ) seq2 = Sequence( name="seq2", code="seq2", project=data["test_project1"], ) seq3 = Sequence( name="seq3", code="seq3", project=data["test_project1"], ) new_shot = Shot(**data["kwargs"]) new_shot.sequence = seq2 assert new_shot.sequence == seq2 def test_scene_argument_is_skipped(setup_shot_db_tests): """scene attribute is None if the scene argument is skipped.""" data = setup_shot_db_tests data["kwargs"].pop("scene") data["kwargs"]["code"] = "DifferentCode" new_shot = Shot(**data["kwargs"]) assert new_shot.scene is None def test_scene_argument_is_None(setup_shot_db_tests): """scene attribute is None if the scene argument is set to None.""" data = setup_shot_db_tests data["kwargs"]["scene"] = None data["kwargs"]["code"] = "NewCode" new_shot = Shot(**data["kwargs"]) assert new_shot.scene is None def test_scene_attribute_is_set_to_None(setup_shot_db_tests): """TypeError is not raised if the scene attribute is set to None.""" data = setup_shot_db_tests assert data["test_shot"].scene is not None # no error should be raised data["test_shot"].scene = None assert data["test_shot"].scene is None def test_scene_argument_is_not_a_scene(setup_shot_db_tests): """TypeError raised if the scene argument is not a scene.""" data = setup_shot_db_tests data["kwargs"]["scene"] = "not a scene" data["kwargs"]["code"] = "NewCode" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.scene should be a stalker.models.scene.Scene instance, not str: " "'not a scene'" ) def test_scene_attribute_is_not_a_scene(setup_shot_db_tests): """TypeError raised if the scene attribute is not a Scene.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].scene = "not a scene" assert str(cm.value) == ( "Shot.scene should be a stalker.models.scene.Scene instance, not str: " "'not a scene'" ) def test_scene_argument_is_a_list_of_scene_instances(setup_shot_db_tests): """TypeError raised if the scene argument is a list of Scene instances.""" data = setup_shot_db_tests data["kwargs"]["scene"] = [ Scene(name="sce1", code="sce1", project=data["test_project1"]), Scene(name="sce2", code="sce2", project=data["test_project1"]), Scene(name="sce3", code="sce3", project=data["test_project1"]), ] data["kwargs"]["code"] = "NewShot" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.scene should be a stalker.models.scene.Scene instance, " "not list: '[, , ]'" ) def test_scene_attribute_is_a_list_of_Scene_instances(setup_shot_db_tests): """TypeError raised if the scene attribute is not a list of Scene instances.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].scene = [ Scene(name="sce1", code="sce1", project=data["test_project1"]), Scene(name="sce2", code="sce2", project=data["test_project1"]), Scene(name="sce3", code="sce3", project=data["test_project1"]), ] assert str(cm.value) == ( "Shot.scene should be a stalker.models.scene.Scene instance, " "not list: '[, , ]'" ) def test_scene_argument_is_working_as_expected(setup_shot_db_tests): """scene argument value is passed to scene attribute as expected.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" sce1 = Scene(name="sce1", code="sce1", project=data["test_project1"]) sce2 = Scene(name="sce2", code="sce2", project=data["test_project1"]) sce3 = Scene(name="sce3", code="sce3", project=data["test_project1"]) DBSession.add_all([sce1, sce2, sce3]) data["kwargs"]["scene"] = sce1 new_shot = Shot(**data["kwargs"]) DBSession.add(new_shot) assert new_shot.scene == sce1 def test_scene_attribute_is_working_as_expected(setup_shot_db_tests): """scene attribute is working as expected.""" data = setup_shot_db_tests data["kwargs"]["code"] = "NewShot" sce1 = Scene(name="sce1", code="sce1", project=data["test_project1"]) sce2 = Scene(name="sce2", code="sce2", project=data["test_project1"]) sce3 = Scene(name="sce3", code="sce3", project=data["test_project1"]) DBSession.add_all([sce1, sce2, sce3]) data["kwargs"]["scene"] = sce1 new_shot = Shot(**data["kwargs"]) DBSession.add(new_shot) assert new_shot.scene != sce2 new_shot.scene = sce2 assert new_shot.scene == sce2 def test_cut_in_argument_is_skipped(setup_shot_db_tests): """cut_in arg skipped the cut_in arg is calculated from cut_out arg.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"].pop("cut_in") data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None new_shot = Shot(**data["kwargs"]) assert new_shot.cut_out == data["kwargs"]["cut_out"] assert new_shot.cut_in == new_shot.cut_out def test_cut_in_argument_is_none(setup_shot_db_tests): """cut_in attr is calculated from the cut_out attr if the cut_in arg is None.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_in"] = None data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None shot = Shot(**data["kwargs"]) assert shot.cut_out == data["kwargs"]["cut_out"] assert shot.cut_in == shot.cut_out def test_cut_in_attribute_is_set_to_none(setup_shot_db_tests): """TypeError raised if the cut_in attribute is set to None.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].cut_in = None assert str(cm.value) == "Shot.cut_in should be an int, not NoneType: 'None'" def test_cut_in_argument_is_not_integer(setup_shot_db_tests): """TypeError raised if the cut_in argument is not an instance of int.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_in"] = "a string" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.cut_in should be an int, not str: 'a string'" def test_cut_in_attribute_is_not_integer(setup_shot_db_tests): """TypeError raised if the cut_in attr not an integer.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].cut_in = "a string" assert str(cm.value) == "Shot.cut_in should be an int, not str: 'a string'" def test_cut_in_argument_is_bigger_than_cut_out_argument(setup_shot_db_tests): """cut_out offset if the cut_in arg value is bigger than the cut_out arg value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_in"] = data["kwargs"]["cut_out"] + 10 data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None shot = Shot(**data["kwargs"]) assert shot.cut_in == 149 assert shot.cut_out == 149 def test_cut_in_attribute_is_bigger_than_cut_out_attribute(setup_shot_db_tests): """the cut_out attr offset if the cut_in is set bigger than cut_out.""" data = setup_shot_db_tests data["test_shot"].cut_in = data["test_shot"].cut_out + 10 assert data["test_shot"].cut_in == 159 assert data["test_shot"].cut_out == data["test_shot"].cut_in def test_cut_out_argument_is_skipped(setup_shot_db_tests): """cut_out attr calculated from cut_in arg value if the cut_out arg is skipped.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"].pop("cut_out") data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None new_shot = Shot(**data["kwargs"]) assert new_shot.cut_in == data["kwargs"]["cut_in"] assert new_shot.cut_out == new_shot.cut_in def test_cut_out_argument_is_set_to_none(setup_shot_db_tests): """cut_out arg is set to None the cut_out attr calculated from cut_in arg value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_out"] = None data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None shot = Shot(**data["kwargs"]) assert shot.cut_in == data["kwargs"]["cut_in"] assert shot.cut_out == shot.cut_in def test_cut_out_attribute_is_set_to_none(setup_shot_db_tests): """TypeError raised if the cut_out attribute is set to None.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].cut_out = None assert str(cm.value) == "Shot.cut_out should be an int, not NoneType: 'None'" def test_cut_out_argument_is_not_integer(setup_shot_db_tests): """TypeError raised if the cut_out argument is not an integer.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_out"] = "a string" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.cut_out should be an int, not str: 'a string'" def test_cut_out_attribute_is_not_integer(setup_shot_db_tests): """TypeError raised if the cut_out attr is set to a value other than an integer.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].cut_out = "a string" assert str(cm.value) == "Shot.cut_out should be an int, not str: 'a string'" def test_cut_out_argument_is_smaller_than_cut_in_argument(setup_shot_db_tests): """cut_out attr is updated if the cut_out arg is smaller than cut_in arg.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["cut_out"] = data["kwargs"]["cut_in"] - 10 data["kwargs"]["source_in"] = None data["kwargs"]["source_out"] = None shot = Shot(**data["kwargs"]) assert shot.cut_in == 102 assert shot.cut_out == 102 def test_cut_out_attribute_is_smaller_than_cut_in_attribute(setup_shot_db_tests): """cut_out attribute is updated if it is smaller than cut_in attribute.""" data = setup_shot_db_tests data["test_shot"].cut_out = data["test_shot"].cut_in - 10 assert data["test_shot"].cut_in == 102 assert data["test_shot"].cut_out == 102 def test_cut_duration_attribute_is_not_instance_of_int(setup_shot_db_tests): """TypeError raised if the cut_duration attr is set to a value other than an int.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].cut_duration = "a string" assert str(cm.value) == ( "Shot.cut_duration should be a positive integer value, not str: 'a string'" ) def test_cut_duration_attribute_will_be_updated_if_cut_in_attribute_changed( setup_shot_db_tests, ): """cut_duration attribute updated if the cut_in attribute changed.""" data = setup_shot_db_tests data["test_shot"].cut_in = 1 assert ( data["test_shot"].cut_duration == data["test_shot"].cut_out - data["test_shot"].cut_in + 1 ) def test_cut_duration_attribute_will_be_updated_if_cut_out_attribute_changed( setup_shot_db_tests, ): """cut_duration attribute updated if the cut_out attribute changed.""" data = setup_shot_db_tests data["test_shot"].cut_out = 1000 assert ( data["test_shot"].cut_duration == data["test_shot"].cut_out - data["test_shot"].cut_in + 1 ) def test_cut_duration_attribute_changes_cut_out_attribute(setup_shot_db_tests): """changes in cut_duration attribute will also affect cut_out value.""" data = setup_shot_db_tests first_cut_out = data["test_shot"].cut_out data["test_shot"].cut_duration = 245 assert data["test_shot"].cut_out != first_cut_out assert ( data["test_shot"].cut_out == data["test_shot"].cut_in + data["test_shot"].cut_duration - 1 ) def test_cut_duration_attribute_is_zero(setup_shot_db_tests): """ValueError raised if the cut_duration attribute is set to zero.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].cut_duration = 0 assert str(cm.value) == ( "Shot.cut_duration cannot be set to zero or a negative value" ) def test_cut_duration_attribute_is_negative(setup_shot_db_tests): """ValueError raised if the cut_duration attribute is set to a negative value.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].cut_duration = -100 assert str(cm.value) == ( "Shot.cut_duration cannot be set to zero or a negative value" ) def test_source_in_argument_is_skipped(setup_shot_db_tests): """source_in arg is skipped the source_in arg equal to cut_in attr value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"].pop("source_in") shot = Shot(**data["kwargs"]) assert shot.source_in == shot.cut_in def test_source_in_argument_is_none(setup_shot_db_tests): """source_in attr equal to the cut_in attr if the source_in arg is None.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = None shot = Shot(**data["kwargs"]) assert shot.source_in == shot.cut_in def test_source_in_attribute_is_set_to_none(setup_shot_db_tests): """TypeError raised if the source_in attribute is set to None.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].source_in = None assert str(cm.value) == "Shot.source_in should be an int, not NoneType: 'None'" def test_source_in_argument_is_not_integer(setup_shot_db_tests): """TypeError raised if the source_in argument is not an instance of int.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = "a string" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.source_in should be an int, not str: 'a string'" def test_source_in_attribute_is_not_integer(setup_shot_db_tests): """TypeError raised if the source_in attr is set to a value other than an int.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].source_in = "a string" assert str(cm.value) == "Shot.source_in should be an int, not str: 'a string'" def test_source_in_argument_is_bigger_than_source_out_argument(setup_shot_db_tests): """ValueError raised if the source_in arg is bigger than source_out arg value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_out"] = data["kwargs"]["cut_out"] - 10 data["kwargs"]["source_in"] = data["kwargs"]["source_out"] + 5 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_out cannot be smaller than Shot.source_in, source_in: 144 where " "as source_out: 139" ) def test_source_in_attribute_is_bigger_than_source_out_attribute(setup_shot_db_tests): """ValueError raised if the source_in attr is set to bigger than source out.""" data = setup_shot_db_tests # give it a little bit of room, to be sure that the ValueError is not # due to the cut_out data["test_shot"].source_out -= 5 with pytest.raises(ValueError) as cm: data["test_shot"].source_in = data["test_shot"].source_out + 1 assert str(cm.value) == ( "Shot.source_in cannot be bigger than Shot.source_out, " "source_in: 136 where as source_out: 135" ) def test_source_in_argument_is_smaller_than_cut_in(setup_shot_db_tests): """ValueError raised if the source_in arg is smaller than cut_in attr value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = data["kwargs"]["cut_in"] - 10 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_in cannot be smaller than Shot.cut_in, cut_in: " "112 where as source_in: 102" ) def test_source_in_argument_is_bigger_than_cut_out(setup_shot_db_tests): """ValueError raised if the source_in arg is bigger than cut_out attr value.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = data["kwargs"]["cut_out"] + 10 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_in cannot be bigger than Shot.cut_out, cut_out: " "149 where as source_in: 159" ) def test_source_out_argument_is_skipped(setup_shot_db_tests): """source_out attr equal to cut_out arg value if the source_out arg is skipped.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"].pop("source_out") new_shot = Shot(**data["kwargs"]) assert new_shot.source_out == new_shot.cut_out def test_source_out_argument_is_none(setup_shot_db_tests): """source_out attr value equal to cut_out if the source_out arg value is None.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_out"] = None shot = Shot(**data["kwargs"]) assert shot.source_out == shot.cut_out def test_source_out_attribute_is_set_to_none(setup_shot_db_tests): """TypeError raised if the source_out attribute is set to None.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].source_out = None assert str(cm.value) == "Shot.source_out should be an int, not NoneType: 'None'" def test_source_out_argument_is_not_integer(setup_shot_db_tests): """TypeError raised if the source_out argument is not an integer.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_out"] = "a string" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.source_out should be an int, not str: 'a string'" def test_source_out_attribute_is_not_integer(setup_shot_db_tests): """TypeError raised if the source_out attr is set to a value other than an int.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].source_out = "a string" assert str(cm.value) == "Shot.source_out should be an int, not str: 'a string'" def test_source_out_argument_is_smaller_than_source_in_argument(setup_shot_db_tests): """ValueError raised if the source_out arg is smaller than the source_in attr.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = data["kwargs"]["cut_in"] + 15 data["kwargs"]["source_out"] = data["kwargs"]["source_in"] - 10 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_out cannot be smaller than Shot.source_in, " "source_in: 127 where as source_out: 117" ) def test_source_out_attribute_is_smaller_than_source_in_attribute(setup_shot_db_tests): """ValueError raised if the source_out attr is set to smaller than source_in.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].source_out = data["test_shot"].source_in - 2 assert str(cm.value) == ( "Shot.source_out cannot be smaller than Shot.source_in, " "source_in: 120 where as source_out: 118" ) def test_source_out_argument_is_smaller_than_cut_in_argument(setup_shot_db_tests): """ValueError raised if the source_out arg is smaller than the cut_in attr.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = data["kwargs"]["cut_in"] + 15 data["kwargs"]["source_out"] = data["kwargs"]["cut_in"] - 10 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_out cannot be smaller than Shot.cut_in, " "cut_in: 112 where as source_out: 102" ) def test_source_out_attribute_is_smaller_than_cut_in_attribute(setup_shot_db_tests): """ValueError raised if the source_out attr is set to smaller than cut_in.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].source_out = data["test_shot"].cut_in - 2 assert str(cm.value) == ( "Shot.source_out cannot be smaller than Shot.cut_in, " "cut_in: 112 where as source_out: 110" ) def test_source_out_argument_is_bigger_than_cut_out_argument(setup_shot_db_tests): """ValueError raised if the source_out arg is bigger than the cut_out attr.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" data["kwargs"]["source_in"] = data["kwargs"]["cut_in"] + 2 data["kwargs"]["source_out"] = data["kwargs"]["cut_out"] + 20 with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.source_out cannot be bigger than Shot.cut_out, " "cut_out: 149 where as source_out: 169" ) def test_source_out_attribute_is_smaller_than_cut_out_attribute(setup_shot_db_tests): """ValueError raised if the source_out attr is set to bigger than cut_out.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].source_out = data["test_shot"].cut_out + 2 assert str(cm.value) == ( "Shot.source_out cannot be bigger than Shot.cut_out, " "cut_out: 149 where as source_out: 151" ) def test_image_format_argument_is_skipped(setup_shot_db_tests): """image_format is copied from the Project if the image_format arg is skipped.""" data = setup_shot_db_tests data["kwargs"].pop("image_format") data["kwargs"]["code"] = "TestShot" new_shot = Shot(**data["kwargs"]) assert new_shot.image_format == data["kwargs"]["project"].image_format def test_image_format_argument_is_none(setup_shot_db_tests): """image format is copied from the Project if the image_format arg is None.""" data = setup_shot_db_tests data["kwargs"]["image_format"] = None data["kwargs"]["code"] = "newShot" new_shot = Shot(**data["kwargs"]) assert new_shot.image_format == data["kwargs"]["project"].image_format def test_image_format_attribute_is_none(setup_shot_db_tests): """image format is copied from the Project if the image_format attr is None.""" data = setup_shot_db_tests # test start conditions assert data["test_shot"].image_format != data["test_shot"].project.image_format data["test_shot"].image_format = None assert data["test_shot"].image_format == data["test_shot"].project.image_format def test_image_format_argument_is_not_a_image_format_instance_and_not_none( setup_shot_db_tests, ): """TypeError raised if the image_format arg is not a ImageFormat and not None.""" data = setup_shot_db_tests data["kwargs"]["code"] = "new_shot" data["kwargs"]["image_format"] = "not an image format instance" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.image_format should be an instance of " "stalker.models.format.ImageFormat, not str: 'not an image format instance'" ) def test_image_format_attribute_is_not_a_ImageFormat_instance_and_not_none( setup_shot_db_tests, ): """TypeError raised if the image_format attr is not a ImageFormat and not None.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].image_format = "not an image f" assert str(cm.value) == ( "Shot.image_format should be an instance of " "stalker.models.format.ImageFormat, not str: 'not an image f'" ) def test_image_format_argument_is_working_as_expected(setup_shot_db_tests): """image_format argument value is passed to the image_format attribute correctly.""" data = setup_shot_db_tests assert data["kwargs"]["image_format"] == data["test_shot"].image_format def test_image_format_attribute_is_working_as_expected(setup_shot_db_tests): """image_format attribute is working as expected.""" data = setup_shot_db_tests assert data["test_shot"].image_format != data["test_image_format1"] data["test_shot"].image_format = data["test_image_format1"] assert data["test_shot"].image_format == data["test_image_format1"] def test_equality(setup_shot_db_tests): """equality of shot objects.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" new_shot1 = Shot(**data["kwargs"]) data["kwargs"]["project"] = data["test_project2"] new_shot2 = Shot(**data["kwargs"]) # an entity with the same parameters # just set the name to the code too data["kwargs"]["name"] = data["kwargs"]["code"] new_entity = Entity(**data["kwargs"]) # another shot with different code data["kwargs"]["code"] = "SHAnotherShot" new_shot3 = Shot(**data["kwargs"]) assert not new_shot1 == new_shot2 assert not new_shot1 == new_entity assert not new_shot1 == new_shot3 def test_inequality(setup_shot_db_tests): """inequality of shot objects.""" data = setup_shot_db_tests data["kwargs"]["code"] = "SH123A" new_shot1 = Shot(**data["kwargs"]) data["kwargs"]["project"] = data["test_project2"] new_shot2 = Shot(**data["kwargs"]) # an entity with the same parameters # just set the name to the code too data["kwargs"]["name"] = data["kwargs"]["code"] new_entity = Entity(**data["kwargs"]) # another shot with different code data["kwargs"]["code"] = "SHAnotherShot" new_shot3 = Shot(**data["kwargs"]) assert new_shot1 != new_shot2 assert new_shot1 != new_entity assert new_shot1 != new_shot3 def test_ReferenceMixin_initialization(setup_shot_db_tests): """ReferenceMixin part is initialized correctly.""" data = setup_shot_db_tests file_type_1 = Type(name="Image", code="image", target_entity_type="File") file1 = File( name="Artwork 1", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="a.jpg", type=file_type_1, ) file2 = File( name="Artwork 2", full_path="/mnt/M/JOBs/TEST_PROJECT", filename="b.jbg", type=file_type_1, ) references = [file1, file2] data["kwargs"]["code"] = "SH12314" data["kwargs"]["references"] = references new_shot = Shot(**data["kwargs"]) assert new_shot.references == references def test_TaskMixin_initialization(setup_shot_db_tests): """TaskMixin part is initialized correctly.""" data = setup_shot_db_tests project_status_list = StatusList.query.filter( StatusList.target_entity_type == "Project" ).first() project_type = Type(name="Commercial", code="comm", target_entity_type="Project") new_project = Project( name="Commercial1", code="comm1", status_list=project_status_list, type=project_type, repository=data["test_repository"], ) DBSession.add(new_project) DBSession.commit() data["kwargs"]["project"] = new_project data["kwargs"]["code"] = "SH12314" new_shot = Shot(**data["kwargs"]) task1 = Task( name="Modeling", status=0, project=new_project, parent=new_shot, ) task2 = Task( name="Lighting", status=0, project=new_project, parent=new_shot, ) tasks = [task1, task2] assert sorted(new_shot.tasks, key=lambda x: x.name) == sorted( tasks, key=lambda x: x.name ) def test__repr__(setup_shot_db_tests): """representation of Shot.""" data = setup_shot_db_tests assert data["test_shot"].__repr__() == "".format( data["test_shot"].code, data["test_shot"].code, ) def test_plural_class_name(setup_shot_db_tests): """plural name of Shot class.""" data = setup_shot_db_tests assert data["test_shot"].plural_class_name == "Shots" def test___strictly_typed___is_false(): """__strictly_typed__ class attribute is False for Shot class.""" assert Shot.__strictly_typed__ is False def test_cut_duration_initialization_bug_with_cut_in(setup_shot_db_tests): """_cut_duration attribute is initialized correctly for a Shot restored from DB.""" data = setup_shot_db_tests # retrieve the shot back from DB test_shot_db = Shot.query.filter_by(name=data["kwargs"]["name"]).first() # trying to change the cut_in and cut_out values should not raise any # errors test_shot_db.cut_in = 1 DBSession.add(test_shot_db) DBSession.commit() def test_cut_duration_initialization_bug_with_cut_out(setup_shot_db_tests): """_cut_duration attribute is initialized correctly for a Shot restored from DB.""" data = setup_shot_db_tests # reconnect to the database # retrieve the shot back from DB test_shot_db = Shot.query.filter_by(name=data["kwargs"]["name"]).first() # trying to change the cut_in and cut_out values should not raise any # errors test_shot_db.cut_out = 100 DBSession.add(test_shot_db) DBSession.commit() def test_cut_values_are_set_correctly(setup_shot_db_tests): """cut_in attribute is set correctly in db.""" data = setup_shot_db_tests data["test_shot"].cut_in = 100 assert data["test_shot"].cut_in == 100 data["test_shot"].cut_out = 153 assert data["test_shot"].cut_in == 100 assert data["test_shot"].cut_out == 153 DBSession.add(data["test_shot"]) DBSession.commit() # retrieve the shot back from DB test_shot_db = Shot.query.filter_by(name=data["kwargs"]["name"]).first() assert test_shot_db.cut_in == 100 assert test_shot_db.cut_out == 153 def test_fps_argument_is_skipped(setup_shot_db_tests): """default value used if fps is skipped.""" data = setup_shot_db_tests if "fps" in data["kwargs"]: data["kwargs"].pop("fps") data["kwargs"]["code"] = "SHnew" new_shot = Shot(**data["kwargs"]) assert new_shot.fps == data["test_project1"].fps def test_fps_attribute_is_set_to_None(setup_shot_db_tests): """Project.fps used if the fps argument is None.""" data = setup_shot_db_tests data["kwargs"]["fps"] = None data["kwargs"]["code"] = "SHnew" new_shot = Shot(**data["kwargs"]) assert new_shot.fps == data["test_project1"].fps @pytest.mark.parametrize("test_value", [["a", "list"], {"a": "list"}]) def test_fps_argument_is_given_as_non_float_or_integer(test_value, setup_shot_db_tests): """TypeError raised if the fps arg not float or int.""" data = setup_shot_db_tests data["kwargs"]["fps"] = test_value data["kwargs"]["code"] = "SH%i" with pytest.raises(TypeError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == ( "Shot.fps should be a positive float or int, not {}: '{}'".format( test_value.__class__.__name__, test_value ) ) @pytest.mark.parametrize("test_value", [["a", "list"], {"a": "list"}]) def test_fps_attribute_is_given_as_non_float_or_integer( test_value, setup_shot_db_tests ): """TypeError raised if the fps attr is not a float or int.""" data = setup_shot_db_tests with pytest.raises(TypeError) as cm: data["test_shot"].fps = test_value assert str(cm.value) == ( "Shot.fps should be a positive float or int, not {}: '{}'".format( test_value.__class__.__name__, test_value ) ) def test_fps_attribute_float_conversion(setup_shot_db_tests): """fps attr is converted to float if the fps argument is given as an int.""" data = setup_shot_db_tests test_value = 1 data["kwargs"]["fps"] = test_value data["kwargs"]["code"] = "SHnew" new_shot = Shot(**data["kwargs"]) assert isinstance(new_shot.fps, float) assert new_shot.fps == float(test_value) def test_fps_attribute_float_conversion_2(setup_shot_db_tests): """fps attribute is converted to float if it is set to an int value.""" data = setup_shot_db_tests test_value = 1 data["test_shot"].fps = test_value assert isinstance(data["test_shot"].fps, float) assert data["test_shot"].fps == float(test_value) def test_fps_argument_is_zero(setup_shot_db_tests): """ValueError raised if the fps is 0.""" data = setup_shot_db_tests data["kwargs"]["fps"] = 0 data["kwargs"]["code"] = "SHnew" with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.fps should be a positive float or int, not 0.0" def test_fps_attribute_is_set_to_zero(setup_shot_db_tests): """value error raised if the fps attribute is set to zero.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].fps = 0 assert str(cm.value) == "Shot.fps should be a positive float or int, not 0.0" def test_fps_argument_is_negative(setup_shot_db_tests): """ValueError raised if the fps argument is negative.""" data = setup_shot_db_tests data["kwargs"]["fps"] = -1.0 data["kwargs"]["code"] = "SHrandom" with pytest.raises(ValueError) as cm: Shot(**data["kwargs"]) assert str(cm.value) == "Shot.fps should be a positive float or int, not -1.0" def test_fps_attribute_is_negative(setup_shot_db_tests): """ValueError raised if the fps attribute is set to a negative value.""" data = setup_shot_db_tests with pytest.raises(ValueError) as cm: data["test_shot"].fps = -1 assert str(cm.value) == "Shot.fps should be a positive float or int, not -1.0" def test_fps_changes_with_project(setup_shot_db_tests): """fps reflects the project.fps unless it is set to a value.""" data = setup_shot_db_tests new_shot = Shot(name="New Shot", code="ns", project=data["test_project1"]) assert new_shot.fps == data["test_project1"].fps data["test_project1"].fps = 335 assert new_shot.fps == 335 new_shot.fps = 12 assert new_shot.fps == 12 data["test_project1"].fps = 24 assert new_shot.fps == 12 def test__check_code_availability_code_is_none(setup_shot_db_tests): """__check_code_availability() returns True if the code is None.""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) result = data["test_shot"]._check_code_availability(None, data["test_project1"]) assert result is True def test__check_code_availability_code_is_not_str(setup_shot_db_tests): """__check_code_availability() raises TypeError if code is not a str.""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) with pytest.raises(TypeError) as cm: _ = data["test_shot"]._check_code_availability(1234, data["test_project1"]) assert str(cm.value) == ( "code should be a string containing a shot code, not int: '1234'" ) def test__check_code_availability_project_is_none(setup_shot_db_tests): """__check_code_availability() returns True if project is None""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) result = data["test_shot"]._check_code_availability("SH123", None) assert result is True def test__check_code_availability_project_is_not_a_project_instance( setup_shot_db_tests, ): """__check_code_availability() raises TypeError if the Project is not a Project instance.""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) with pytest.raises(TypeError) as cm: _ = data["test_shot"]._check_code_availability("SH123", 1234) assert str(cm.value) == ("project should be a Project instance, not int: '1234'") def test_check_code_availability_fallbacks_to_python_if_db_is_not_available(): """__check_code_availability() fallbacks to Python if DB is not available.""" data = dict() # statuses rts = Status(name="Read To Start", code="RTS") wip = Status(name="Work In Progress", code="WIP") cmpl = Status(name="Completed", code="CMPL") project_status_list = StatusList( name="Project Statuses", statuses=[rts, wip, cmpl], target_entity_type="Project" ) shot_status_list = StatusList( name="Shot Status List", statuses=[rts, wip, cmpl], target_entity_type="Shot" ) # types data["test_commercial_project_type"] = Type( name="Commercial Project", code="comm", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository" ) # repository data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], ) # image format data["test_image_format1"] = ImageFormat( name="Test Image Format 1", width=1920, height=1080, pixel_aspect=1.0 ) # project and sequences data["test_project1"] = Project( name="Test Project1", code="tp1", type=data["test_commercial_project_type"], repository=data["test_repository"], image_format=data["test_image_format1"], status_list=project_status_list, ) data["kwargs"] = dict( name="SH123", code="SH123", description="This is a test Shot", project=data["test_project1"], status=0, status_list=shot_status_list, ) # create a mock shot object data["test_shot"] = Shot(**data["kwargs"]) assert Shot._check_code_availability("SH123", data["test_project1"]) is False def test__init_on_load__works_as_expected(setup_shot_db_tests): """__init_on_load__() works as expected.""" data = setup_shot_db_tests assert data["test_shot"] in DBSession DBSession.commit() DBSession.flush() connection = DBSession.connection() connection.close() del data["test_shot"] from stalker.db.setup import setup setup(data["database_settings"]["config"]) # the following should call Shot.__init_on_load__() shot = Shot.query.filter(Shot.name == "SH123").first() assert isinstance(shot, Shot) def test_template_variables_include_scene_for_shots(setup_shot_db_tests): """_template_variables include scene for shots.""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) template_variables = data["test_shot"]._template_variables() assert "scene" in template_variables assert data["test_shot"].scene is not None assert template_variables["scene"] == data["test_shot"].scene def test_template_variables_include_sequence_for_shots(setup_shot_db_tests): """_template_variables include sequence for shots.""" data = setup_shot_db_tests assert isinstance(data["test_shot"], Shot) template_variables = data["test_shot"]._template_variables() assert "sequence" in template_variables assert data["test_shot"].sequence is not None assert template_variables["sequence"] == data["test_shot"].sequence def test_shots_can_use_task_status_list(): """It is possible to use TaskStatus lists with Shots.""" # users test_user1 = User( name="User1", login="user1", password="12345", email="user1@user1.com" ) # statuses status_wip = Status(code="WIP", name="Work In Progress") status_cmpl = Status(code="CMPL", name="Complete") # Just create a StatusList for Tasks task_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Task" ) project_status_list = StatusList( statuses=[status_wip, status_cmpl], target_entity_type="Project" ) # types commercial_project_type = Type( name="Commercial Project", code="commproj", target_entity_type="Project", ) # project project1 = Project( name="Test Project1", code="tp1", type=commercial_project_type, status_list=project_status_list, ) # shots test_shot1 = Shot( code="TestSH001", project=project1, status_list=task_status_list, responsible=[test_user1], ) assert test_shot1.status_list == task_status_list ================================================ FILE: tests/models/test_simple_entity.py ================================================ # -*- coding: utf-8 -*- """Tests for the SimpleEntity class.""" import json import datetime import sys import pytest import pytz import stalker from stalker import ( Department, File, Project, Repository, SimpleEntity, Structure, Type, User, ) from stalker.db.session import DBSession # create a new class deriving from the SimpleEntity class NewClass(SimpleEntity): __strictly_typed__ = True @pytest.fixture(scope="function") def setup_simple_entity_tests(): """Set up some proper values for SimpleEntity tests.""" # create a user data = dict() data["test_user"] = User( name="Test User", login="testuser", email="test@user.com", password="test", generic_text=json.dumps({"Phone number": "123"}, sort_keys=True), ) data["date_created"] = datetime.datetime(2010, 10, 21, 3, 8, 0, tzinfo=pytz.utc) data["date_updated"] = data["date_created"] data["kwargs"] = { "name": "Test Entity", "code": "TstEnt", "description": "This is a test entity, and this is a proper description for it", "created_by": data["test_user"], "updated_by": data["test_user"], "date_created": data["date_created"], "date_updated": data["date_updated"], "generic_text": json.dumps({"Phone number": "123"}, sort_keys=True), } # create a proper SimpleEntity to use it later in the tests data["test_simple_entity"] = SimpleEntity(**data["kwargs"]) data["test_type"] = Type( name="Test Type", code="test", target_entity_type="SimpleEntity" ) return data def test___auto_name__attr_is_true(): """__auto_name__ class attr is set to True.""" assert SimpleEntity.__auto_name__ is True def test_name_arg_is_none(setup_simple_entity_tests): """name attr automatically generated if the name arg is None.""" data = setup_simple_entity_tests data["kwargs"]["name"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.name is not None def test_name_attr_is_set_to_none(setup_simple_entity_tests): """name attr set to an automatic value if it is set to None.""" data = setup_simple_entity_tests data["test_simple_entity"].name = "" assert data["test_simple_entity"].name is not None def test_name_attr_is_set_to_none_2(setup_simple_entity_tests): """name attr set to an automatic value if it is set to None.""" data = setup_simple_entity_tests assert data["test_simple_entity"].name != "" def test_name_arg_is_empty_string(setup_simple_entity_tests): """name attr set to an automatic value if the name arg is an empty string.""" data = setup_simple_entity_tests data["kwargs"]["name"] = "" new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.name != "" def test_name_attr_is_set_to_empty_string(setup_simple_entity_tests): """name attr set to an automatic value if it is set to an automatic value.""" data = setup_simple_entity_tests data["test_simple_entity"].name = "" assert data["test_simple_entity"].name != "" @pytest.mark.parametrize("test_value", [12132, [1, "name"], {"a": "name"}]) def test_name_arg_is_not_a_string_instance_or_none( test_value, setup_simple_entity_tests ): """TypeError raised if the name arg is not a string or None.""" data = setup_simple_entity_tests data["kwargs"]["name"] = test_value with pytest.raises(TypeError) as _: SimpleEntity(**data["kwargs"]) @pytest.mark.parametrize("test_value", [12132, [1, "name"], {"a": "name"}]) def test_name_attr_is_not_string_or_none(test_value, setup_simple_entity_tests): """TypeError raised if the name attr is not a string or None.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as _: data["test_simple_entity"].name = test_value @pytest.mark.parametrize( "test_value", [ ("testName", "testName"), ("test-Name", "test-Name"), ("1testName", "1testName"), ("_testName", "_testName"), ("2423$+^^+^'%+%%&_testName", "2423$+^^+^'%+%%&_testName"), ("2423$+^^+^'%+%%&_testName_35", "2423$+^^+^'%+%%&_testName_35"), ("2423$ +^^+^ '%+%%&_ testName_ 35", "2423$ +^^+^ '%+%%&_ testName_ 35"), ("SH001", "SH001"), ("46-BJ-3A", "46-BJ-3A"), ("304-sb-0403-0040", "304-sb-0403-0040"), ("Ozgur Yilmaz\n\n\n", "Ozgur Yilmaz"), (" Ozgur Yilmaz ", "Ozgur Yilmaz"), ], ) def test_name_attr_is_formatted_correctly(test_value, setup_simple_entity_tests): """name is formatted correctly""" data = setup_simple_entity_tests # set the new name data["test_simple_entity"].name = test_value[0] assert data["test_simple_entity"].name == test_value[1] @pytest.mark.parametrize( "test_value", [ ("testName", "testName"), ("1testName", "1testName"), ("_testName", "testName"), ("2423$+^^+^'%+%%&_testName", "2423_testName"), ("2423$+^^+^'%+%%&_testName_35", "2423_testName_35"), ("2423$ +^^+^ '%+%%&_ testName_ 35", "2423_testName_35"), ("SH001", "SH001"), ("My name is Ozgur", "My_name_is_Ozgur"), (" this is another name for an asset", "this_is_another_name_for_an_asset"), ("Ozgur Yilmaz\n\n\n", "Ozgur_Yilmaz"), ], ) def test_nice_name_attr_is_formatted_correctly(test_value, setup_simple_entity_tests): """nice name attr is formatted correctly.""" data = setup_simple_entity_tests data["test_simple_entity"].name = test_value[0] assert data["test_simple_entity"].nice_name == test_value[1] def test_nice_name_attr_is_read_only(setup_simple_entity_tests): """nice name attr is read-only.""" data = setup_simple_entity_tests with pytest.raises(AttributeError) as cm: data["test_simple_entity"].nice_name = "a text" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'nice_name'", }.get( sys.version_info.minor, "property 'nice_name' of 'SimpleEntity' object has no setter", ) assert str(cm.value) == error_message def test_description_arg_none(setup_simple_entity_tests): """description property converted to an empty string if description arg is None.""" data = setup_simple_entity_tests data["kwargs"]["description"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.description == "" def test_description_attr_none(setup_simple_entity_tests): """description attr converted to an empty string if it is set to None.""" data = setup_simple_entity_tests data["test_simple_entity"].description = None assert data["test_simple_entity"].description == "" def test_description_arg_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the description arg value is not a string.""" data = setup_simple_entity_tests data["kwargs"]["description"] = {"a": "description"} with pytest.raises(TypeError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ( "SimpleEntity.description should be a string, not dict: '{'a': 'description'}'" ) def test_description_attr_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the description attr value is not a str.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].description = ["a description"] assert str(cm.value) == ( "SimpleEntity.description should be a string, not list: '['a description']'" ) def test_generic_text_arg_none(setup_simple_entity_tests): """generic_text value converted to an empty string if generic_text arg is None.""" data = setup_simple_entity_tests data["kwargs"]["generic_text"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.generic_text == "" def test_generic_text_attr_none(setup_simple_entity_tests): """generic_text attr converted to an empty string if it is set to None.""" data = setup_simple_entity_tests data["test_simple_entity"].generic_text = None assert data["test_simple_entity"].generic_text == "" def test_generic_text_arg_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the generic_text arg value is not a string.""" data = setup_simple_entity_tests data["kwargs"]["generic_text"] = {"a": "generic_text"} with pytest.raises(TypeError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ( "SimpleEntity.generic_text should be a string, " "not dict: '{'a': 'generic_text'}'" ) def test_generic_text_attr_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the generic_text attr is set not to a str.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as _: data["test_simple_entity"].generic_text = ["a generic_text"] def test_equality(setup_simple_entity_tests): """equality of two simple entities.""" # create two simple entities with same parameters and check for equality data = setup_simple_entity_tests se1 = SimpleEntity(**data["kwargs"]) se2 = SimpleEntity(**data["kwargs"]) data["kwargs"]["name"] = "a different simple entity" data["kwargs"]["description"] = "no description" se3 = SimpleEntity(**data["kwargs"]) assert se1 == se2 assert not se1 == se3 def test_inequality(setup_simple_entity_tests): """inequality of two simple entities.""" data = setup_simple_entity_tests # create two simple entities with same parameters and check for # equality se1 = SimpleEntity(**data["kwargs"]) se2 = SimpleEntity(**data["kwargs"]) data["kwargs"]["name"] = "a different simple entity" data["kwargs"]["description"] = "no description" se3 = SimpleEntity(**data["kwargs"]) assert not se1 != se2 assert se1 != se3 def test_created_by_arg_is_not_a_user_instance(setup_simple_entity_tests): """TypeError is raised if created_by arg is not a User instance.""" data = setup_simple_entity_tests # the created_by arg should be an instance of User class, in any # other case it should raise a TypeError test_value = "A User Name" # be sure that the test value is not an instance of User assert not isinstance(test_value, User) # check the value with pytest.raises(TypeError) as cm: data["test_simple_entity"].created_by = test_value assert str(cm.value) == ( "SimpleEntity.created_by should be a stalker.models.auth.User instance, " "not str: 'A User Name'" ) def test_created_by_attr_instance_of_user(setup_simple_entity_tests): """TypeError is raised if created_by attr is set to a value other than a User.""" data = setup_simple_entity_tests # the created_by attr should be an instance of User class, in any # other case it should raise a TypeError test_value = "A User Name" # be sure that the test value is not an instance of User assert not isinstance(test_value, User) # check the value with pytest.raises(TypeError) as cm: data["test_simple_entity"].created_by = test_value assert str(cm.value) == ( "SimpleEntity.created_by should be a stalker.models.auth.User instance, " "not str: 'A User Name'" ) def test_updated_by_arg_instance_of_user(setup_simple_entity_tests): """TypeError is raised if updated_by arg is not a User instance.""" data = setup_simple_entity_tests # the updated_by arg should be an instance of User class, in any # other case it should raise a TypeError test_value = "A User Name" # be sure that the test value is not an instance of User assert not isinstance(test_value, User) # check the value with pytest.raises(TypeError) as cm: data["test_simple_entity"].updated_by = test_value assert str(cm.value) == ( "SimpleEntity.updated_by should be a stalker.models.auth.User instance, " "not str: 'A User Name'" ) def test_updated_by_attr_instance_of_user(setup_simple_entity_tests): """TypeError is raised if update_by attr set to a value other than a User.""" data = setup_simple_entity_tests # the updated_by attr should be an instance of User class, in any # other case it should raise a TypeError test_value = "A User Name" # be sure that the test value is not an instance of User assert not isinstance(test_value, User) # check the value with pytest.raises(TypeError) as cm: data["test_simple_entity"].updated_by = test_value assert str(cm.value) == ( "SimpleEntity.updated_by should be a stalker.models.auth.User instance, " "not str: 'A User Name'" ) def test_updated_by_arg_empty(setup_simple_entity_tests): """updated_by arg is None it is equal to created_by arg.""" data = setup_simple_entity_tests data["kwargs"]["updated_by"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) # now check if they are same assert new_simple_entity.created_by == new_simple_entity.updated_by def test_date_created_arg_accepts_datetime_only(setup_simple_entity_tests): """TypeError raised if the date_created arg is not a datetime instance.""" data = setup_simple_entity_tests # the date_created arg should be an instance of datetime.datetime # try to set something else and expect a TypeError test_value = "a string date time 2010-10-26 etc." # be sure that the test_value is not an instance of datetime.datetime assert not isinstance(test_value, datetime.datetime) with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_created = test_value assert str(cm.value) == ( "SimpleEntity.date_created should be a datetime.datetime instance, " "not str: 'a string date time 2010-10-26 etc.'" ) def test_date_created_attr_accepts_datetime_only(setup_simple_entity_tests): """TypeError raised if the date_created attr is not a datetime instance.""" data = setup_simple_entity_tests # the date_created attr should be an instance of datetime.datetime # try to set something else and expect a TypeError test_value = "a string date time 2010-10-26 etc." # be sure that the test_value is not an instance of datetime.datetime assert not isinstance(test_value, datetime.datetime) with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_created = test_value assert str(cm.value) == ( "SimpleEntity.date_created should be a datetime.datetime instance, " "not str: 'a string date time 2010-10-26 etc.'" ) def test_date_created_attr_being_empty(setup_simple_entity_tests): """TypeError is raised if the date_created attr is set to None.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_created = None assert str(cm.value) == "SimpleEntity.date_created cannot be None" def test_date_updated_arg_accepts_datetime_only(setup_simple_entity_tests): """TypeError raised if the date_updated arg is not a datetime instance.""" data = setup_simple_entity_tests # try to set it to something else and expect a TypeError test_value = "a string date time 2010-10-26 etc." # be sure that the test_value is not an instance of datetime.datetime assert not isinstance(test_value, datetime.datetime) with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_updated = test_value assert str(cm.value) == ( "SimpleEntity.date_updated should be a datetime.datetime instance, " "not str: 'a string date time 2010-10-26 etc.'" ) def test_date_updated_attr_is_set_to_none(setup_simple_entity_tests): """TypeError is raised if the date_updated attr is set to None.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_updated = None assert str(cm.value) == "SimpleEntity.date_updated cannot be None" def test_date_updated_attr_is_not_datetime(setup_simple_entity_tests): """TypeError is raised if the date_updated attr is set to None.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].date_updated = "this is not datetime" assert str(cm.value) == ( "SimpleEntity.date_updated should be a datetime.datetime instance, " "not str: 'this is not datetime'" ) def test_date_updated_attr_is_working_as_expected(setup_simple_entity_tests): """date_updated attr is working as expected.""" data = setup_simple_entity_tests test_value = datetime.datetime.now(pytz.utc) data["test_simple_entity"].date_updated = test_value assert data["test_simple_entity"].date_updated == test_value def test_date_created_is_before_date_updated(setup_simple_entity_tests): """ValueError raised if date_updated is set to a time before date_created.""" data = setup_simple_entity_tests data["kwargs"]["date_created"] = datetime.datetime( 2000, 1, 1, 1, 1, 1, tzinfo=pytz.utc ) data["kwargs"]["date_updated"] = datetime.datetime( 1990, 1, 1, 1, 1, 1, tzinfo=pytz.utc ) # create a new entity with these dates # and expect a ValueError with pytest.raises(ValueError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ( "SimpleEntity.date_updated could not be set to a date before " "SimpleEntity.date_created, try setting the ``date_created`` first." ) def test___repr__(setup_simple_entity_tests): """__repr__ works as expected.""" data = setup_simple_entity_tests assert data["test_simple_entity"].__repr__() == "<{} ({})>".format( data["test_simple_entity"].name, data["test_simple_entity"].entity_type, ) def test_type_arg_is_none(setup_simple_entity_tests): """nothing will happen the type arg is None.""" data = setup_simple_entity_tests data["kwargs"]["type"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) assert isinstance(new_simple_entity, SimpleEntity) def test_type_attr_is_set_to_none(setup_simple_entity_tests): """nothing happened if the type attr is set to None.""" data = setup_simple_entity_tests data["test_simple_entity"].type = None @pytest.mark.parametrize("test_value", [1, 1.2, "a type"]) def test_type_arg_accepts_only_type_instances(test_value, setup_simple_entity_tests): """TypeError raised if type attr is not a Type instance.""" data = setup_simple_entity_tests data["kwargs"]["type"] = test_value with pytest.raises(TypeError): SimpleEntity(**data["kwargs"]) def test_type_arg_accepts_type_instances(setup_simple_entity_tests): """no error raised if the type arg is a Type instance.""" data = setup_simple_entity_tests # test with a proper Type data["kwargs"]["type"] = data["test_type"] # no error is expected new_simple_entity = SimpleEntity(**data["kwargs"]) assert isinstance(new_simple_entity, SimpleEntity) @pytest.mark.parametrize("test_value", [1, 1.2, "a type"]) def test_type_attr_accepts_only_type_instances(test_value, setup_simple_entity_tests): """TypeError raised type attr is not Type instance.""" data = setup_simple_entity_tests with pytest.raises(TypeError): data["test_simple_entity"].type = test_value def test_type_attr_accepts_type_instances(setup_simple_entity_tests): """no error raised if the type attr is set to Type instance.""" data = setup_simple_entity_tests # test with a proper Type data["test_simple_entity"].type = data["test_type"] def test___strictly_typed___class_attr_is_init_as_false(setup_simple_entity_tests): """__strictly_typed__ class attr is initialized as False.""" data = setup_simple_entity_tests assert data["test_simple_entity"].__strictly_typed__ is False def test___strictly_typed___attr_set_to_true_and_no_type_arg(setup_simple_entity_tests): """TypeError raised if __strictly_typed__ is True but no Type arg given.""" data = setup_simple_entity_tests # create a new class deriving from the SimpleEntity assert NewClass.__strictly_typed__ is True # create a new instance and skip the Type attr and expect a # TypeError if "type" in data["kwargs"]: data["kwargs"].pop("type") with pytest.raises(TypeError) as cm: NewClass(**data["kwargs"]) assert str(cm.value) == ( "NewClass.type must be a stalker.models.type.Type instance, " "not NoneType: 'None'" ) def test___strictly_typed___attr_set_to_true_and_type_arg_is_none( setup_simple_entity_tests, ): """TypeError raised if __strictly_typed__ attr is True but Type arg is None.""" data = setup_simple_entity_tests # set it to None and expect a TypeError data["kwargs"]["type"] = None with pytest.raises(TypeError) as cm: NewClass(**data["kwargs"]) assert str(cm.value) == ( "NewClass.type must be a stalker.models.type.Type instance, " "not NoneType: 'None'" ) @pytest.mark.parametrize("test_value", [1, 1.2, ["a", "list"], {"a": "dict"}]) def test___strictly_typed___attr_set_to_true_and_type_arg_is_not_type( test_value, setup_simple_entity_tests, ): """TypeError raised __strictly_typed__ is True but the type arg is not a str.""" data = setup_simple_entity_tests data["kwargs"]["type"] = test_value with pytest.raises(TypeError): NewClass(**data["kwargs"]) def test_stalker_version_attr_is_automatically_set_to_the_current_stalker_version( setup_simple_entity_tests, ): """stalker_version is automatically set for the newly created SimpleEntities.""" data = setup_simple_entity_tests new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.stalker_version == stalker.__version__ # update stalker.__version__ to a test value current_version = stalker.__version__ test_version = "test_version" stalker.__version__ = test_version # test if it is updated assert stalker.__version__ == test_version # create a new SimpleEntity and check if it is following the version new_simple_entity2 = SimpleEntity(**data["kwargs"]) assert new_simple_entity2.stalker_version == test_version # restore the stalker.__version__ stalker.__version__ = current_version def test_thumbnail_arg_is_skipped(setup_simple_entity_tests): """thumbnail attr None if the thumbnail arg is skipped.""" data = setup_simple_entity_tests try: data["kwargs"].pop("thumbnail") except KeyError: pass new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.thumbnail is None def test_thumbnail_arg_is_none(setup_simple_entity_tests): """thumbnail arg can be None.""" data = setup_simple_entity_tests data["kwargs"]["thumbnail"] = None new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.thumbnail is None def test_thumbnail_attr_is_none(setup_simple_entity_tests): """thumbnail attr can be set to None.""" data = setup_simple_entity_tests data["test_simple_entity"].thumbnail = None assert data["test_simple_entity"].thumbnail is None def test_thumbnail_arg_is_not_a_file_instance(setup_simple_entity_tests): """TypeError raised if the thumbnail arg is not a File instance.""" data = setup_simple_entity_tests data["kwargs"]["thumbnail"] = "not a File" with pytest.raises(TypeError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ( "SimpleEntity.thumbnail should be a stalker.models.file.File instance, " "not str: 'not a File'" ) def test_thumbnail_attr_is_not_a_file_instance(setup_simple_entity_tests): """TypeError raised if the thumbnail is not a File instance.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].thumbnail = "not a File" assert str(cm.value) == ( "SimpleEntity.thumbnail should be a stalker.models.file.File instance, " "not str: 'not a File'" ) def test_thumbnail_arg_is_working_as_expected(setup_simple_entity_tests): """thumbnail arg value is passed to the thumbnail attr correctly.""" data = setup_simple_entity_tests thumb = File(full_path="some path") data["kwargs"]["thumbnail"] = thumb new_simple_entity = SimpleEntity(**data["kwargs"]) assert new_simple_entity.thumbnail == thumb def test_thumbnail_attr_is_working_as_expected(setup_simple_entity_tests): """thumbnail attr is working as expected.""" data = setup_simple_entity_tests thumb = File(full_path="some path") assert not data["test_simple_entity"].thumbnail == thumb data["test_simple_entity"].thumbnail = thumb assert data["test_simple_entity"].thumbnail == thumb def test_html_style_arg_is_skipped(setup_simple_entity_tests): """html_style arg is skipped the html_style attr an empty string.""" data = setup_simple_entity_tests if "html_style" in data["kwargs"]: data["kwargs"].pop("html_style") se = SimpleEntity(**data["kwargs"]) assert se.html_style == "" def test_html_style_arg_is_none(setup_simple_entity_tests): """html_style arg is set to None the html_style attr an empty string.""" data = setup_simple_entity_tests data["kwargs"]["html_style"] = None se = SimpleEntity(**data["kwargs"]) assert se.html_style == "" def test_html_style_attr_is_set_to_none(setup_simple_entity_tests): """html_style attr is set to None it an empty string.""" data = setup_simple_entity_tests data["test_simple_entity"].html_style = None assert data["test_simple_entity"].html_style == "" def test_html_style_arg_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the html_style arg is not a string.""" data = setup_simple_entity_tests data["kwargs"]["html_style"] = 123 with pytest.raises(TypeError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ("SimpleEntity.html_style should be a str, not int: '123'") def test_html_style_attr_is_not_set_to_a_string(setup_simple_entity_tests): """TypeError raised if the html_style attr is not set to a string.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].html_style = 34324 assert str(cm.value) == ( "SimpleEntity.html_style should be a str, not int: '34324'" ) def test_html_style_arg_is_working_as_expected(setup_simple_entity_tests): """html_style arg value is correctly passed to the html_style attr.""" data = setup_simple_entity_tests test_value = "width: 100px; color: purple; background-color: black" data["kwargs"]["html_style"] = test_value se = SimpleEntity(**data["kwargs"]) assert se.html_style == test_value def test_html_style_attr_is_working_as_expected(setup_simple_entity_tests): """html_style attr is working as expected.""" data = setup_simple_entity_tests test_value = "width: 100px; color: purple; background-color: black" data["test_simple_entity"].html_style = test_value assert data["test_simple_entity"].html_style == test_value def test_html_class_arg_is_skipped(setup_simple_entity_tests): """html_class arg is skipped the html_class attr an empty string.""" data = setup_simple_entity_tests if "html_class" in data["kwargs"]: data["kwargs"].pop("html_class") se = SimpleEntity(**data["kwargs"]) assert se.html_class == "" def test_html_class_arg_is_none(setup_simple_entity_tests): """html_class arg is set to None the html_class attr an empty string.""" data = setup_simple_entity_tests data["kwargs"]["html_class"] = None se = SimpleEntity(**data["kwargs"]) assert se.html_class == "" def test_html_class_attr_is_set_to_none(setup_simple_entity_tests): """html_class attr is set to None it an empty string.""" data = setup_simple_entity_tests data["test_simple_entity"].html_class = None assert data["test_simple_entity"].html_class == "" def test_html_class_arg_is_not_a_string(setup_simple_entity_tests): """TypeError raised if the html_class arg is not a string.""" data = setup_simple_entity_tests data["kwargs"]["html_class"] = 123 with pytest.raises(TypeError) as cm: SimpleEntity(**data["kwargs"]) assert str(cm.value) == ("SimpleEntity.html_class should be a str, not int: '123'") def test_html_class_attr_is_not_set_to_a_string(setup_simple_entity_tests): """TypeError raised if the html_class attr is not set to a string.""" data = setup_simple_entity_tests with pytest.raises(TypeError) as cm: data["test_simple_entity"].html_class = 34324 assert str(cm.value) == ( "SimpleEntity.html_class should be a str, not int: '34324'" ) def test_html_class_arg_is_working_as_expected(setup_simple_entity_tests): """html_class arg value is correctly passed to the html_class attr.""" data = setup_simple_entity_tests test_value = "purple" data["kwargs"]["html_class"] = test_value se = SimpleEntity(**data["kwargs"]) assert se.html_class == test_value def test_html_class_attr_is_working_as_expected(setup_simple_entity_tests): """html_class attr is working as expected.""" data = setup_simple_entity_tests test_value = "purple" data["test_simple_entity"].html_class = test_value assert data["test_simple_entity"].html_class == test_value def test_to_tjp_will_raise_a_not_implemented_error(setup_simple_entity_tests): """calling to_tjp() method will raise a NotImplementedError.""" data = setup_simple_entity_tests with pytest.raises(NotImplementedError): data["test_simple_entity"].to_tjp() @pytest.fixture(scope="function") def setup_simple_entity_db_tests(setup_postgresql_db): """set up the SimpleEntity tests wit a db.""" data = dict() data["test_user"] = User( name="Test User", login="testuser", email="test@user.com", password="test", generic_text=json.dumps({"Phone number": "123"}, sort_keys=True), ) DBSession.add(data["test_user"]) DBSession.commit() data["date_created"] = datetime.datetime(2010, 10, 21, 3, 8, 0, tzinfo=pytz.utc) data["date_updated"] = data["date_created"] data["kwargs"] = { "name": "Test Entity", "code": "TstEnt", "description": "This is a test entity, and this is a proper \ description for it", "created_by": data["test_user"], "updated_by": data["test_user"], "date_created": data["date_created"], "date_updated": data["date_updated"], "generic_text": json.dumps({"Phone number": "123"}, sort_keys=True), } return data def test_generic_data_attr_can_hold_a_wide_variety_of_object_types( setup_simple_entity_db_tests, ): """generic_data attr can hold any kind of object as a list.""" data = setup_simple_entity_db_tests new_simple_entity = SimpleEntity(**data["kwargs"]) DBSession.add(new_simple_entity) test_user = User( name="email", login="email", email="email@email.com", password="email", ) test_department = Department(name="department1") DBSession.add(test_department) test_repo = Repository( name="Test Repository", code="TR", ) DBSession.add(test_repo) test_struct = Structure(name="Test Project Structure") DBSession.add(test_struct) test_proj = Project( name="Test Project 1", code="tp1", repository=test_repo, structure=test_struct, ) DBSession.add(test_proj) new_simple_entity.generic_data.extend( [test_proj, test_struct, test_repo, test_department, test_user] ) # now check if it is added to the database correctly del new_simple_entity new_simple_entity_db = SimpleEntity.query.filter_by( name=data["kwargs"]["name"] ).first() assert test_proj in new_simple_entity_db.generic_data assert test_struct in new_simple_entity_db.generic_data assert test_repo in new_simple_entity_db.generic_data assert test_department in new_simple_entity_db.generic_data assert test_user in new_simple_entity_db.generic_data ================================================ FILE: tests/models/test_status.py ================================================ # -*- coding: utf-8 -*- """Tests for the Status class.""" import pytest from stalker import Entity, Status @pytest.fixture(scope="function") def setup_status_tests(): """Set up tests for the stalker.models.status.Status class.""" data = dict() data["kwargs"] = { "name": "Complete", "description": "use this if the object is complete", "code": "CMPLT", } # create an entity object with same kwargs for __eq__ and __ne__ tests # (it should return False for __eq__ and True for __ne__ for same # kwargs) data["entity1"] = Entity(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Status class.""" assert Status.__auto_name__ is False def test_equality(setup_status_tests): """equality of two statuses.""" data = setup_status_tests status1 = Status(**data["kwargs"]) status2 = Status(**data["kwargs"]) data["kwargs"]["name"] = "Work In Progress" data["kwargs"]["description"] = "use this if the object is still in progress" data["kwargs"]["code"] = "WIP" status3 = Status(**data["kwargs"]) assert status1 == status2 assert not status1 == status3 assert not status1 == data["entity1"] def test_status_and_string_equality_in_status_name(setup_status_tests): """status can be compared with a string matching the Status.name.""" data = setup_status_tests a_status = Status(**data["kwargs"]) assert a_status == data["kwargs"]["name"] assert a_status == data["kwargs"]["name"].lower() assert a_status == data["kwargs"]["name"].upper() assert a_status != "another name" def test_status_and_string_equality_in_status_code(setup_status_tests): """status can be compared with a string matching the Status.code.""" data = setup_status_tests a_status = Status(**data["kwargs"]) assert a_status == data["kwargs"]["code"] assert a_status == data["kwargs"]["code"].lower() assert a_status == data["kwargs"]["code"].upper() def test_inequality(setup_status_tests): """inequality of two statuses.""" data = setup_status_tests status1 = Status(**data["kwargs"]) status2 = Status(**data["kwargs"]) data["kwargs"]["name"] = "Work In Progress" data["kwargs"]["description"] = "use this if the object is still in progress" data["kwargs"]["code"] = "WIP" status3 = Status(**data["kwargs"]) assert not status1 != status2 assert status1 != status3 assert status1 != data["entity1"] def test_status_and_string_inequality_in_status_name(setup_status_tests): """status can be compared with a string.""" data = setup_status_tests a_status = Status(**data["kwargs"]) assert not a_status != data["kwargs"]["name"] assert not a_status != data["kwargs"]["name"].lower() assert not a_status != data["kwargs"]["name"].upper() assert a_status != "another name" def test_status_and_string_inequality_in_status_code(setup_status_tests): """status can be compared with a string.""" data = setup_status_tests a_status = Status(**data["kwargs"]) assert not a_status != data["kwargs"]["code"] assert not a_status != data["kwargs"]["code"].lower() assert not a_status != data["kwargs"]["code"].upper() def test__hash__is_working_as_expected(setup_status_tests): """__hash__ is working as expected.""" data = setup_status_tests data["test_status"] = Status(**data["kwargs"]) result = hash(data["test_status"]) assert isinstance(result, int) assert result == data["test_status"].__hash__() ================================================ FILE: tests/models/test_status_list.py ================================================ # -*- coding: utf-8 -*- """Tests for the StatusList class.""" import pytest from stalker import Status, StatusList @pytest.fixture(scope="function") def setup_status_list_tests(): """Set up tests for the StatusList class.""" data = dict() data["kwargs"] = { "name": "a status list", "description": "this is a status list for testing purposes", "statuses": [ Status(name="Waiting For Dependency", code="WFD"), Status(name="Ready To Start", code="RTS"), Status(name="Work In Progress", code="WIP"), Status(name="Pending Review", code="PREV"), Status(name="Has Revision", code="HREV"), Status(name="Completed", code="CMPL"), Status(name="On Hold", code="OH"), ], "target_entity_type": "Project", } data["test_status_list"] = StatusList(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for StatusList class.""" assert StatusList.__auto_name__ is True def test_statuses_argument_accepts_statuses_only(setup_status_list_tests): """statuses list argument accepts list of statuses only.""" data = setup_status_list_tests # the statuses argument should be a list of statuses # can be empty? test_value = "a str" # it should only accept lists of statuses data["kwargs"]["statuses"] = test_value with pytest.raises(TypeError) as cm: StatusList(**data["kwargs"]) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_statuses_attribute_accepting_only_statuses(setup_status_list_tests): """status_list attribute accepting Status objects only.""" data = setup_status_list_tests test_value = "1" # check the attribute with pytest.raises(TypeError) as cm: data["test_status_list"].statuses = test_value assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_statuses_argument_elements_being_status_objects(setup_status_list_tests): """status_list elements against not being derived from Status class.""" data = setup_status_list_tests # every element should be an object derived from Status a_fake_status_list = [1, 2, "a string", 4.5] data["kwargs"]["statuses"] = a_fake_status_list with pytest.raises(TypeError) as cm: StatusList(**data["kwargs"]) assert str(cm.value) == ( "All of the elements in StatusList.statuses must be an instance " "of stalker.models.status.Status, not int: '1'" ) def test_statuses_attribute_works_as_expected(setup_status_list_tests): """status_list attribute is working as expected.""" data = setup_status_list_tests new_list_of_statutes = [Status(name="New Status", code="NSTS")] data["test_status_list"].statuses = new_list_of_statutes assert data["test_status_list"].statuses == new_list_of_statutes def test_statuses_attributes_elements_changed_to_none_status_objects( setup_status_list_tests, ): """TypeError raised if an item is set to a none Status instance statuses list.""" data = setup_status_list_tests with pytest.raises(TypeError) as cm: data["test_status_list"].statuses[0] = 0 assert str(cm.value) == ( "All of the elements in StatusList.statuses must be an instance " "of stalker.models.status.Status, not int: '0'" ) def test_equality_operator(setup_status_list_tests): """equality of two status list object.""" data = setup_status_list_tests status_list1 = StatusList(**data["kwargs"]) status_list2 = StatusList(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "SomeOtherClass" status_list3 = StatusList(**data["kwargs"]) data["kwargs"]["statuses"] = [ Status(name="Started", code="STRT"), Status(name="Waiting For Approve", code="WAPPR"), Status(name="Approved", code="APPR"), Status(name="Finished", code="FNSH"), ] status_list4 = StatusList(**data["kwargs"]) assert status_list1 == status_list2 assert not status_list1 == status_list3 assert not status_list1 == status_list4 def test_inequality_operator(setup_status_list_tests): """equality of two status list object.""" data = setup_status_list_tests status_list1 = StatusList(**data["kwargs"]) status_list2 = StatusList(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "SomeOtherClass" status_list3 = StatusList(**data["kwargs"]) data["kwargs"]["statuses"] = [ Status(name="Started", code="STRT"), Status(name="Waiting For Approve", code="WAPPR"), Status(name="Approved", code="APPR"), Status(name="Finished", code="FNSH"), ] status_list4 = StatusList(**data["kwargs"]) assert not status_list1 != status_list2 assert status_list1 != status_list3 assert status_list1 != status_list4 def test_indexing_get(setup_status_list_tests): """indexing of statuses in the statusList, get.""" data = setup_status_list_tests # first try indexing # this shouldn't raise a TypeError status1 = data["test_status_list"][0] # check the equality assert data["test_status_list"].statuses[0] == status1 def test_indexing_get_string_indexes(setup_status_list_tests): """indexing of statuses in the statusList, get with string.""" data = setup_status_list_tests status1 = Status(name="Complete", code="CMPLT") status2 = Status(name="Work in Progress", code="WIP") status3 = Status(name="Pending Review", code="PRev") a_status_list = StatusList( name="Asset Status List", statuses=[status1, status2, status3], target_entity_type="Asset", ) assert a_status_list[0] == a_status_list["complete"] assert a_status_list[1] == a_status_list["wip"] def test_indexing_setitem_validates_the_given_value(setup_status_list_tests): """indexing of statuses in the statusList, set.""" data = setup_status_list_tests # first try indexing # this shouldn't raise a TypeError with pytest.raises(TypeError) as cm: data["test_status_list"][0] = "PRev" assert str(cm.value) == ( "All of the elements in StatusList.statuses must be an instance of " "stalker.models.status.Status, not str: 'PRev'" ) def test_indexing_setitem(setup_status_list_tests): """indexing of statuses in the statusList, set.""" data = setup_status_list_tests # first try indexing # this shouldn't raise a TypeError status = Status(name="Pending Review", code="PRev") data["test_status_list"][0] = status # check the equality assert data["test_status_list"].statuses[0] == status def test_indexing_delitem(setup_status_list_tests): """indexing of statuses in the statusList, del.""" data = setup_status_list_tests # first get the length len_statuses = len(data["test_status_list"].statuses) del data["test_status_list"][-1] assert len(data["test_status_list"].statuses) == len_statuses - 1 def test_indexing_len(setup_status_list_tests): """indexing of statuses in the statusList, len.""" data = setup_status_list_tests # get the len and compare it wiht len(statuses) assert len(data["test_status_list"].statuses) == len(data["test_status_list"]) def test__hash__is_working_as_expected(setup_status_list_tests): """__hash__ is working as expected.""" data = setup_status_list_tests result = hash(data["test_status_list"]) assert isinstance(result, int) assert result == data["test_status_list"].__hash__() ================================================ FILE: tests/models/test_structure.py ================================================ # -*- coding: utf-8 -*- """Tests for the Structure class.""" import pytest from stalker import FilenameTemplate, Structure, Type @pytest.fixture(scope="function") def setup_structure_tests(): """stalker.models.structure.Structure class.""" data = dict() vers_type = Type(name="Version", code="vers", target_entity_type="FilenameTemplate") ref_type = Type(name="Reference", code="ref", target_entity_type="FilenameTemplate") # type templates data["asset_template"] = FilenameTemplate( name="Test Asset Template", target_entity_type="Asset", type=vers_type ) data["shot_template"] = FilenameTemplate( name="Test Shot Template", target_entity_type="Shot", type=vers_type ) data["reference_template"] = FilenameTemplate( name="Test Reference Template", target_entity_type="File", type=ref_type ) data["test_templates"] = [ data["asset_template"], data["shot_template"], data["reference_template"], ] data["test_templates2"] = [data["asset_template"]] data["custom_template"] = "a custom template" data["test_type"] = Type( name="Commercial Structure", code="comm", target_entity_type="Structure", ) # keyword arguments data["kwargs"] = { "name": "Test Structure", "description": "This is a test structure", "templates": data["test_templates"], "custom_template": data["custom_template"], "type": data["test_type"], } data["test_structure"] = Structure(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Structure class.""" assert Structure.__auto_name__ is False def test_custom_template_argument_can_be_skipped(setup_structure_tests): """custom_template argument can be skipped.""" data = setup_structure_tests data["kwargs"].pop("custom_template") new_structure = Structure(**data["kwargs"]) assert new_structure.custom_template == "" def test_custom_template_argument_is_none(setup_structure_tests): """no error raised if the custom_template argument is None.""" data = setup_structure_tests data["kwargs"]["custom_template"] = None new_structure = Structure(**data["kwargs"]) assert new_structure.custom_template == "" def test_custom_template_argument_is_empty_string(setup_structure_tests): """no error raised if the custom_template argument is an empty string.""" data = setup_structure_tests data["kwargs"]["custom_template"] = "" new_structure = Structure(**data["kwargs"]) assert new_structure.custom_template == "" def test_custom_template_argument_is_not_a_string(setup_structure_tests): """TypeError raised if the custom_template argument is not a string.""" data = setup_structure_tests data["kwargs"]["custom_template"] = ["this is not a string"] with pytest.raises(TypeError) as cm: Structure(**data["kwargs"]) assert str(cm.value) == ( "Structure.custom_template should be a string, " "not list: '['this is not a string']'" ) def test_custom_template_attribute_is_not_a_string(setup_structure_tests): """TypeError raised if the custom_template attribute is not a string.""" data = setup_structure_tests with pytest.raises(TypeError) as cm: data["test_structure"].custom_template = ["this is not a string"] assert str(cm.value) == ( "Structure.custom_template should be a string, " "not list: '['this is not a string']'" ) def test_templates_argument_can_be_skipped(setup_structure_tests): """no error raised if the templates argument is skipped.""" data = setup_structure_tests data["kwargs"].pop("templates") new_structure = Structure(**data["kwargs"]) assert isinstance(new_structure, Structure) def test_templates_argument_can_be_none(setup_structure_tests): """no error raised if the templates argument is None.""" data = setup_structure_tests data["kwargs"]["templates"] = None new_structure = Structure(**data["kwargs"]) assert isinstance(new_structure, Structure) def test_templates_attribute_cannot_be_set_to_none(setup_structure_tests): """TypeError raised if the templates attribute is set to None.""" data = setup_structure_tests with pytest.raises(TypeError) as cm: data["test_structure"].templates = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_templates_argument_only_accepts_list(setup_structure_tests): """TypeError raised if the given templates argument is not a list.""" data = setup_structure_tests data["kwargs"]["templates"] = 1 with pytest.raises(TypeError) as cm: Structure(**data["kwargs"]) assert str(cm.value) == "Incompatible collection type: int is not list-like" def test_templates_attribute_only_accepts_list_1(setup_structure_tests): """TypeError raised if the templates attr is set to none list.""" data = setup_structure_tests with pytest.raises(TypeError) as cm: data["test_structure"].templates = 1.121 assert str(cm.value) == "Incompatible collection type: float is not list-like" def test_templates_attribute_is_working_as_expected(setup_structure_tests): """templates attribute is working as expected.""" data = setup_structure_tests # test the correct value data["test_structure"].templates = data["test_templates"] assert data["test_structure"].templates == data["test_templates"] def test_templates_argument_accepts_only_list_of_filename_template_instances( setup_structure_tests, ): """TypeError raised if the templates arg is a list of none FilenameTemplate.""" data = setup_structure_tests test_value = [1, 1.2, "a string"] data["kwargs"]["templates"] = test_value with pytest.raises(TypeError) as cm: Structure(**data["kwargs"]) assert str(cm.value) == ( "Structure.templates should only contain instances of " "stalker.models.template.FilenameTemplate, not int: '1'" ) def test_templates_argument_is_working_as_expected(setup_structure_tests): """templates argument value is correctly passed to the templates attribute.""" data = setup_structure_tests # test the correct value data["kwargs"]["templates"] = data["test_templates"] new_structure = Structure(**data["kwargs"]) assert new_structure.templates == data["test_templates"] def test_templates_attribute_accpets_only_list_of_filename_template_instances( setup_structure_tests, ): """TypeError raised if the templates attr is a list of none FilenameTemplate.""" data = setup_structure_tests test_value = [1, 1.2, "a string"] with pytest.raises(TypeError) as cm: data["test_structure"].templates = test_value assert str(cm.value) == ( "Structure.templates should only contain instances of " "stalker.models.template.FilenameTemplate, not int: '1'" ) def test___strictly_typed___is_false(): """__strictly_typed__ is False.""" assert Structure.__strictly_typed__ is False def test_equality_operator(setup_structure_tests): """equality of two Structure objects.""" data = setup_structure_tests new_structure2 = Structure(**data["kwargs"]) data["kwargs"]["custom_template"] = "a test custom template" new_structure3 = Structure(**data["kwargs"]) data["kwargs"]["custom_template"] = data["test_structure"].custom_template data["kwargs"]["templates"] = data["test_templates2"] new_structure4 = Structure(**data["kwargs"]) assert data["test_structure"] == new_structure2 assert not data["test_structure"] == new_structure3 assert not data["test_structure"] == new_structure4 def test_inequality_operator(setup_structure_tests): """inequality of two Structure objects.""" data = setup_structure_tests new_structure2 = Structure(**data["kwargs"]) data["kwargs"]["custom_template"] = "a test custom template" new_structure3 = Structure(**data["kwargs"]) data["kwargs"]["custom_template"] = data["test_structure"].custom_template data["kwargs"]["templates"] = data["test_templates2"] new_structure4 = Structure(**data["kwargs"]) assert not data["test_structure"] != new_structure2 assert data["test_structure"] != new_structure3 assert data["test_structure"] != new_structure4 def test_plural_class_name(setup_structure_tests): """plural name of Structure class.""" data = setup_structure_tests assert data["test_structure"].plural_class_name == "Structures" def test__hash__is_working_as_expected(setup_structure_tests): """__hash__ is working as expected.""" data = setup_structure_tests result = hash(data["test_structure"]) assert isinstance(result, int) assert result == data["test_structure"].__hash__() ================================================ FILE: tests/models/test_studio.py ================================================ # -*- coding: utf-8 -*- """Tests for the Studio class.""" import datetime import sys from jinja2 import Template import pytest import pytz from stalker import ( Asset, Department, Project, Repository, SchedulerBase, Shot, Status, Studio, Task, TaskJugglerScheduler, Type, User, Vacation, WorkingHours, defaults, ) from stalker.db.session import DBSession from stalker.models.enum import TimeUnit from stalker.models.enum import DependencyTarget class DummyScheduler(SchedulerBase): """This is a dummy scheduler to be used in tests.""" def __init__(self, studio=None, callback=None): SchedulerBase.__init__(self, studio) self.callback = callback def schedule(self): """Call the callback function before finishing.""" if self.callback: self.callback() @pytest.fixture(scope="function") def setup_studio_db_tests(setup_postgresql_db): """Set up the test for stalker.models.studio.Studio class.""" data = dict() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["test_user1"] = User( name="User 1", login="user1", email="user1@users.com", password="password" ) DBSession.add(data["test_user1"]) data["test_user2"] = User( name="User 2", login="user2", email="user2@users.com", password="password" ) DBSession.add(data["test_user2"]) data["test_user3"] = User( name="User 3", login="user3", email="user3@users.com", password="password" ) DBSession.add(data["test_user3"]) data["test_department1"] = Department(name="Test Department 1") DBSession.add(data["test_department1"]) data["test_department2"] = Department(name="Test Department 2") DBSession.add(data["test_department2"]) data["test_repo"] = Repository( name="Test Repository", code="TR", windows_path="T:/", linux_path="/mnt/T/", macos_path="/Volumes/T/", ) DBSession.add(data["test_repo"]) # create a couple of projects data["test_project1"] = Project( name="Test Project 1", code="TP1", repository=data["test_repo"] ) data["test_project1"].status = data["status_wip"] DBSession.add(data["test_project1"]) data["test_project2"] = Project( name="Test Project 2", code="TP2", repository=data["test_repo"] ) data["test_project2"].status = data["status_wip"] DBSession.add(data["test_project2"]) # an inactive project data["test_project3"] = Project( name="Test Project 3", code="TP3", repository=data["test_repo"] ) data["test_project3"].status = data["status_rts"] DBSession.save(data["test_project3"]) # create assets and shots data["test_asset_type"] = Type( name="Character", code="Char", target_entity_type="Asset" ) DBSession.add(data["test_asset_type"]) data["test_asset1"] = Asset( name="Test Asset 1", code="TA1", project=data["test_project1"], type=data["test_asset_type"], ) DBSession.add(data["test_asset1"]) data["test_asset2"] = Asset( name="Test Asset 2", code="TA2", project=data["test_project2"], type=data["test_asset_type"], ) DBSession.add(data["test_asset2"]) # shots # for project 1 data["test_shot1"] = Shot( code="shot1", project=data["test_project1"], ) DBSession.add(data["test_shot1"]) data["test_shot2"] = Shot( code="shot2", project=data["test_project1"], ) DBSession.add(data["test_shot2"]) # for project 2 data["test_shot3"] = Shot( code="shot3", project=data["test_project2"], ) DBSession.add(data["test_shot3"]) data["test_shot4"] = Shot( code="shot4", project=data["test_project2"], ) DBSession.add(data["test_shot4"]) # for project 3 data["test_shot5"] = Shot( code="shot5", project=data["test_project3"], ) DBSession.add(data["test_shot5"]) ######################################################### # tasks for projects data["test_task1"] = Task( name="Project Planing", project=data["test_project1"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task1"]) data["test_task2"] = Task( name="Project Planing", project=data["test_project2"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task2"]) data["test_task3"] = Task( name="Project Planing", project=data["test_project3"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=5, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task3"]) # for shots # Shot 1 data["test_task4"] = Task( name="Match Move", parent=data["test_shot1"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task4"]) data["test_task5"] = Task( name="FX", parent=data["test_shot1"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task4"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task5"]) data["test_task6"] = Task( name="Lighting", parent=data["test_shot1"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task4"], data["test_task5"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task6"]) data["test_task7"] = Task( name="Comp", parent=data["test_shot1"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task6"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task7"]) # Shot 2 data["test_task8"] = Task( name="Match Move", parent=data["test_shot2"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task8"]) data["test_task9"] = Task( name="FX", parent=data["test_shot2"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], depends_on=[data["test_task8"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task9"]) data["test_task10"] = Task( name="Lighting", parent=data["test_shot2"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task8"], data["test_task9"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task10"]) data["test_task11"] = Task( name="Comp", parent=data["test_shot2"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task10"]], schedule_timing=4, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task11"]) # Shot 3 data["test_task12"] = Task( name="Match Move", parent=data["test_shot3"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task12"]) data["test_task13"] = Task( name="FX", parent=data["test_shot3"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], depends_on=[data["test_task12"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task13"]) data["test_task14"] = Task( name="Lighting", parent=data["test_shot3"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], depends_on=[data["test_task12"], data["test_task13"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task14"]) data["test_task15"] = Task( name="Comp", parent=data["test_shot3"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], depends_on=[data["test_task14"]], schedule_timing=4, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task15"]) # Shot 4 data["test_task16"] = Task( name="Match Move", parent=data["test_shot4"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task16"]) data["test_task17"] = Task( name="FX", parent=data["test_shot4"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task16"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task17"]) data["test_task18"] = Task( name="Lighting", parent=data["test_shot4"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task16"], data["test_task17"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task18"]) data["test_task19"] = Task( name="Comp", parent=data["test_shot4"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], depends_on=[data["test_task18"]], schedule_timing=4, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task19"]) # Shot 5 data["test_task20"] = Task( name="Match Move", parent=data["test_shot5"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task20"]) data["test_task21"] = Task( name="FX", parent=data["test_shot5"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], depends_on=[data["test_task20"]], schedule_timing=2, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task21"]) data["test_task22"] = Task( name="Lighting", parent=data["test_shot5"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], depends_on=[data["test_task20"], data["test_task21"]], schedule_timing=3, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task22"]) data["test_task23"] = Task( name="Comp", parent=data["test_shot5"], resources=[data["test_user3"]], alternative_resources=[data["test_user1"], data["test_user2"]], depends_on=[data["test_task22"]], schedule_timing=4, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task23"]) #################################################### # For Assets # Asset 1 data["test_task24"] = Task( name="Design", parent=data["test_asset1"], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task24"]) data["test_task25"] = Task( name="Model", parent=data["test_asset1"], depends_on=[data["test_task24"]], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=15, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task25"]) data["test_task26"] = Task( name="LookDev", parent=data["test_asset1"], depends_on=[data["test_task25"]], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task26"]) data["test_task27"] = Task( name="Rig", parent=data["test_asset1"], depends_on=[data["test_task25"]], resources=[data["test_user1"]], alternative_resources=[data["test_user2"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task27"]) # Asset 2 data["test_task28"] = Task( name="Design", parent=data["test_asset2"], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task28"]) data["test_task29"] = Task( name="Model", parent=data["test_asset2"], depends_on=[data["test_task28"]], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], schedule_timing=15, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task29"]) data["test_task30"] = Task( name="LookDev", parent=data["test_asset2"], depends_on=[data["test_task29"]], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task30"]) data["test_task31"] = Task( name="Rig", parent=data["test_asset2"], depends_on=[data["test_task29"]], resources=[data["test_user2"]], alternative_resources=[data["test_user1"], data["test_user3"]], schedule_timing=10, schedule_unit=TimeUnit.Day, ) DBSession.add(data["test_task31"]) # TODO: Add Milestones data["kwargs"] = dict( name="Studio", daily_working_hours=8, timing_resolution=datetime.timedelta(hours=1), ) data["test_studio"] = Studio(**data["kwargs"]) DBSession.add(data["test_studio"]) DBSession.commit() return data def test_working_hours_arg_is_skipped(setup_studio_db_tests): """default working hours is used if the working_hours arg is skipped.""" data = setup_studio_db_tests data["kwargs"]["name"] = "New Studio" try: data["kwargs"].pop("working_hours") # pop if there are any except KeyError: pass new_studio = Studio(**data["kwargs"]) assert new_studio.working_hours == WorkingHours() def test_working_hours_arg_is_none(setup_studio_db_tests): """WorkingHour with default settings is used if working_hours arg is skipped.""" data = setup_studio_db_tests data["kwargs"]["name"] = "New Studio" data["kwargs"]["working_hours"] = None new_studio = Studio(**data["kwargs"]) assert new_studio.working_hours == WorkingHours() def test_working_hours_attribute_is_none(setup_studio_db_tests): """WorkingHour with default values is used if working_hours attr is set to None.""" data = setup_studio_db_tests data["test_studio"].working_horus = None assert data["test_studio"].working_hours == WorkingHours() def test_working_hours_arg_is_not_a_working_hours_instance(setup_studio_db_tests): """TypeError is raised if the working_hours arg is not a WorkingHours instance.""" data = setup_studio_db_tests data["kwargs"]["working_hours"] = "not a working hours instance" data["kwargs"]["name"] = "New Studio" with pytest.raises(TypeError) as cm: Studio(**data["kwargs"]) assert str(cm.value) == ( "Studio.working_hours should be a stalker.models.studio.WorkingHours instance, " "not str: 'not a working hours instance'" ) def test_working_hours_attribute_is_not_a_working_hours_instance(setup_studio_db_tests): """TypeError is raised if working_hours attr is not a WorkingHours instance.""" data = setup_studio_db_tests with pytest.raises(TypeError) as cm: data["test_studio"].working_hours = "not a working hours instance" assert str(cm.value) == ( "Studio.working_hours should be a stalker.models.studio.WorkingHours instance, " "not str: 'not a working hours instance'" ) def test_working_hours_arg_is_working_as_expected(setup_studio_db_tests): """working_hours arg is passed to the working_hours attr without any problem.""" data = setup_studio_db_tests data["kwargs"]["name"] = "New Studio" wh = WorkingHours(working_hours={"mon": [[60, 900]]}) data["kwargs"]["working_hours"] = wh new_studio = Studio(**data["kwargs"]) assert new_studio.working_hours == wh def test_working_hours_attribute_is_working_as_expected(setup_studio_db_tests): """working_hours attribute is working as expected.""" data = setup_studio_db_tests new_working_hours = WorkingHours( working_hours={ "mon": [[60, 1200]] # they were doing all the jobs in # Monday :)) } ) assert data["test_studio"].working_hours != new_working_hours data["test_studio"].working_hours = new_working_hours assert data["test_studio"].working_hours == new_working_hours def test_tjp_id_attribute_returns_a_plausible_id(setup_studio_db_tests): """tjp_id is returning something meaningful.""" data = setup_studio_db_tests data["test_studio"].id = 432 assert data["test_studio"].tjp_id == "Studio_432" def test_projects_attribute_is_read_only(setup_studio_db_tests): """project attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].projects = [data["test_project1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'projects'", }.get( sys.version_info.minor, "property 'projects' of 'Studio' object has no setter" ) assert str(cm.value) == error_message def test_projects_attribute_is_working_as_expected(setup_studio_db_tests): """projects attribute is working as expected.""" data = setup_studio_db_tests assert sorted(data["test_studio"].projects, key=lambda x: x.name) == sorted( [data["test_project1"], data["test_project2"], data["test_project3"]], key=lambda x: x.name, ) def test_active_projects_attribute_is_read_only(setup_studio_db_tests): """active_projects attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].active_projects = [data["test_project1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'active_projects'", }.get( sys.version_info.minor, "property 'active_projects' of 'Studio' object has no setter", ) assert str(cm.value) == error_message def test_active_projects_attribute_is_working_as_expected(setup_studio_db_tests): """active_projects attribute is working as expected.""" data = setup_studio_db_tests assert sorted(data["test_studio"].active_projects, key=lambda x: x.name) == sorted( [data["test_project1"], data["test_project2"]], key=lambda x: x.name ) def test_inactive_projects_attribute_is_read_only(setup_studio_db_tests): """inactive_projects attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].inactive_projects = [data["test_project1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'inactive_projects'", }.get( sys.version_info.minor, "property 'inactive_projects' of 'Studio' object has no setter", ) assert str(cm.value) == error_message def test_inactive_projects_attribute_is_working_as_expected(setup_studio_db_tests): """inactive_projects attribute is working as expected.""" data = setup_studio_db_tests assert sorted( data["test_studio"].inactive_projects, key=lambda x: x.name ) == sorted([data["test_project3"]], key=lambda x: x.name) def test_departments_attribute_is_read_only(setup_studio_db_tests): """departments attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].departments = [data["test_project1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'departments'", }.get( sys.version_info.minor, "property 'departments' of 'Studio' object has no setter", ) assert str(cm.value) == error_message def test_departments_attribute_is_working_as_expected(setup_studio_db_tests): """departments attribute is working as expected.""" data = setup_studio_db_tests admins_dep = Department.query.filter(Department.name == "admins").first() assert admins_dep is not None assert sorted(data["test_studio"].departments, key=lambda x: x.name) == sorted( [data["test_department1"], data["test_department2"], admins_dep], key=lambda x: x.name, ) def test_users_attribute_is_read_only(setup_studio_db_tests): """users attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].users = [data["test_project1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'users'", }.get(sys.version_info.minor, "property 'users' of 'Studio' object has no setter") assert str(cm.value) == error_message def test_users_attribute_is_working_as_expected(setup_studio_db_tests): """users attribute is working as expected.""" data = setup_studio_db_tests # don't forget the admin admin = User.query.filter_by(name="admin").first() assert admin is not None assert sorted(data["test_studio"].users, key=lambda x: x.name) == sorted( [admin, data["test_user1"], data["test_user2"], data["test_user3"]], key=lambda x: x.name, ) def test_to_tjp_attribute_is_read_only(setup_studio_db_tests): """to_tjp attribute is a read only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].to_tjp = "some text" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'to_tjp'", }.get(sys.version_info.minor, "property 'to_tjp' of 'Studio' object has no setter") assert str(cm.value) == error_message def test_now_arg_is_skipped(setup_studio_db_tests): """now attr uses rounded datetime.now(pytz.utc) value if the now arg is skipped.""" data = setup_studio_db_tests try: data["kwargs"].pop("now") except KeyError: pass new_studio = Studio(**data["kwargs"]) assert new_studio.now == new_studio.round_time(datetime.datetime.now(pytz.utc)) def test_now_arg_is_None(setup_studio_db_tests): """now attr uses rounded datetime.now(pytz.utc) value if the now arg is None.""" data = setup_studio_db_tests data["kwargs"]["now"] = None new_studio = Studio(**data["kwargs"]) assert new_studio.now == new_studio.round_time(datetime.datetime.now(pytz.utc)) def test_now_attribute_is_none(setup_studio_db_tests): """now attr equals rounded value of datetime.now(pytz.utc) if it is set to None.""" data = setup_studio_db_tests data["test_studio"].now = None assert data["test_studio"].now == data["test_studio"].round_time( datetime.datetime.now(pytz.utc) ) def test_now_arg_is_not_a_datetime_instance(setup_studio_db_tests): """TypeError is raised if the now arg is not a datetime.datetime instance.""" data = setup_studio_db_tests data["kwargs"]["now"] = "not a datetime instance" with pytest.raises(TypeError) as cm: Studio(**data["kwargs"]) assert str(cm.value) == ( "Studio.now attribute should be an instance of datetime.datetime, " "not str: 'not a datetime instance'" ) def test_now_attribute_is_set_to_a_value_other_than_datetime_instance( setup_studio_db_tests, ): """TypeError is raised if the now attribute is set to a value other than a datetime.datetime instance """ data = setup_studio_db_tests with pytest.raises(TypeError) as cm: data["test_studio"].now = "not a datetime instance" assert ( str(cm.value) == "Studio.now attribute should be an instance of " "datetime.datetime, not str: 'not a datetime instance'" ) def test_now_arg_is_working_as_expected(setup_studio_db_tests): """now arg value is passed to the now attribute.""" data = setup_studio_db_tests data["kwargs"]["now"] = datetime.datetime(2013, 4, 15, 21, 9, tzinfo=pytz.utc) expected_now = datetime.datetime(2013, 4, 15, 21, 0, tzinfo=pytz.utc) new_studio = Studio(**data["kwargs"]) assert new_studio.now == expected_now def test_now_attribute_is_working_as_expected(setup_studio_db_tests): """now attribute is working as expected.""" data = setup_studio_db_tests data["test_studio"].now = datetime.datetime(2013, 4, 15, 21, 11, tzinfo=pytz.utc) expected_now = datetime.datetime(2013, 4, 15, 21, 0, tzinfo=pytz.utc) assert data["test_studio"].now == expected_now def test_now_attribute_is_working_as_expected_case2(setup_studio_db_tests): """now attribute is working as expected.""" data = setup_studio_db_tests data["test_studio"]._now = None expected_now = Studio.round_time(datetime.datetime.now(pytz.utc)) assert data["test_studio"].now == expected_now def test_to_tjp_attribute_is_working_as_expected(setup_studio_db_tests): """to_tjp attribute is working as expected.""" data = setup_studio_db_tests data["test_studio"].start = datetime.datetime(2013, 4, 15, 17, 40, tzinfo=pytz.utc) data["test_studio"].end = datetime.datetime(2013, 6, 30, 17, 40, tzinfo=pytz.utc) data["test_studio"].working_hours[0] = [[540, 1080]] data["test_studio"].working_hours[1] = [[540, 1080]] data["test_studio"].working_hours[2] = [[540, 1080]] data["test_studio"].working_hours[3] = [[540, 1080]] data["test_studio"].working_hours[4] = [[540, 1080]] data["test_studio"].working_hours[5] = [[540, 720]] data["test_studio"].working_hours[6] = [] expected_tjp_template = Template( """project Studio_{{studio.id}} "Studio_{{studio.id}}" 2013-04-15 - 2013-06-30 { timingresolution 60min now {{ studio.now.strftime('%Y-%m-%d-%H:%M') }} dailyworkinghours 8 weekstartsmonday workinghours mon 09:00 - 18:00 workinghours tue 09:00 - 18:00 workinghours wed 09:00 - 18:00 workinghours thu 09:00 - 18:00 workinghours fri 09:00 - 18:00 workinghours sat 09:00 - 12:00 workinghours sun off timeformat "%Y-%m-%d" scenario plan "Plan" trackingscenario plan } """ ) expected_tjp = expected_tjp_template.render({"studio": data["test_studio"]}) # print('-----------------------------------') # print(expected_tjp) # print('-----------------------------------') # print(data["test_studio"].to_tjp) # print('-----------------------------------') assert data["test_studio"].to_tjp == expected_tjp def test_scheduler_attribute_can_be_set_to_none(setup_studio_db_tests): """scheduler attribute can be set to None.""" data = setup_studio_db_tests data["test_studio"].scheduler = None def test_scheduler_attribute_accepts_scheduler_instances_only(setup_studio_db_tests): """TypeError raised if scheduler attr is set to a value which is not a Scheduler.""" data = setup_studio_db_tests with pytest.raises(TypeError) as cm: data["test_studio"].scheduler = "not a Scheduler instance" assert ( str(cm.value) == "Studio.scheduler should be an instance of " "stalker.models.scheduler.SchedulerBase, not str: 'not a Scheduler instance'" ) def test_scheduler_attribute_is_working_as_expected(setup_studio_db_tests): """scheduler attribute is working as expected.""" data = setup_studio_db_tests tj_s = TaskJugglerScheduler() data["test_studio"].scheduler = tj_s assert data["test_studio"].scheduler == tj_s def test_schedule_will_not_work_without_a_scheduler(setup_studio_db_tests): """RuntimeError is raised if the scheduler attribute is not set to a Scheduler instance and schedule is called """ data = setup_studio_db_tests data["test_studio"].scheduler = None with pytest.raises(RuntimeError) as cm: data["test_studio"].schedule() assert ( str(cm.value) == "There is no scheduler for this Studio, please assign a " "scheduler to the Studio.scheduler attribute, before calling " "Studio.schedule()" ) def test_schedule_will_schedule_the_tasks_with_the_given_scheduler( setup_studio_db_tests, ): """schedule method will schedule the tasks with the given scheduler.""" data = setup_studio_db_tests tj_scheduler = TaskJugglerScheduler(compute_resources=True) data["test_studio"].now = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].start = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].end = datetime.datetime(2013, 7, 30, 0, 0, tzinfo=pytz.utc) # just to be sure that it is not creating any issue on schedule data["test_task25"].task_depends_on[0].dependency_target = DependencyTarget.OnStart data["test_task25"].resources = [data["test_user2"]] data["test_studio"].scheduler = tj_scheduler data["test_studio"].schedule() DBSession.commit() # now check the timings of the tasks are all adjusted # Projects # data["test_project"] assert data["test_project1"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_project1"].computed_end == datetime.datetime( 2013, 6, 24, 16, 0, tzinfo=pytz.utc ) # data["test_asset1"] assert data["test_asset1"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_asset1"].computed_end == datetime.datetime( 2013, 5, 17, 18, 0, tzinfo=pytz.utc ) assert data["test_asset1"].computed_resources == [] # data["test_task24"] assert data["test_task24"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_task24"].computed_end == datetime.datetime( 2013, 4, 26, 17, 0, tzinfo=pytz.utc ) possible_resources = [data["test_user1"], data["test_user2"], data["test_user3"]] assert len(data["test_task24"].computed_resources) == 1 assert data["test_task24"].computed_resources[0] in possible_resources # data["test_task25"] assert data["test_task25"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_task25"].computed_end == datetime.datetime( 2013, 5, 3, 12, 0, tzinfo=pytz.utc ) assert len(data["test_task25"].computed_resources) == 1 assert data["test_task25"].computed_resources[0] in possible_resources # data["test_task26"] assert data["test_task26"].computed_start == datetime.datetime( 2013, 5, 6, 11, 0, tzinfo=pytz.utc ) assert data["test_task26"].computed_end == datetime.datetime( 2013, 5, 17, 10, 0, tzinfo=pytz.utc ) assert len(data["test_task26"].computed_resources) == 1 assert data["test_task26"].computed_resources[0] in possible_resources # data["test_task27"] assert data["test_task27"].computed_start == datetime.datetime( 2013, 5, 7, 10, 0, tzinfo=pytz.utc ) assert data["test_task27"].computed_end == datetime.datetime( 2013, 5, 17, 18, 0, tzinfo=pytz.utc ) assert len(data["test_task27"].computed_resources) == 1 assert data["test_task27"].computed_resources[0] in possible_resources # data["test_shot2"] assert data["test_shot2"].computed_start == datetime.datetime( 2013, 4, 26, 17, 0, tzinfo=pytz.utc ) assert data["test_shot2"].computed_end == datetime.datetime( 2013, 6, 20, 10, 0, tzinfo=pytz.utc ) assert data["test_shot2"].computed_resources == [] # data["test_task8"] assert data["test_task8"].computed_start == datetime.datetime( 2013, 4, 26, 17, 0, tzinfo=pytz.utc ) assert data["test_task8"].computed_end == datetime.datetime( 2013, 4, 30, 15, 0, tzinfo=pytz.utc ) assert len(data["test_task8"].computed_resources) == 1 assert data["test_task8"].computed_resources[0] in possible_resources # data["test_task9"] assert data["test_task9"].computed_start == datetime.datetime( 2013, 5, 30, 17, 0, tzinfo=pytz.utc ) assert data["test_task9"].computed_end == datetime.datetime( 2013, 6, 3, 15, 0, tzinfo=pytz.utc ) assert len(data["test_task9"].computed_resources) == 1 assert data["test_task9"].computed_resources[0] in possible_resources # data["test_task10"] assert data["test_task10"].computed_start == datetime.datetime( 2013, 6, 5, 13, 0, tzinfo=pytz.utc ) assert data["test_task10"].computed_end == datetime.datetime( 2013, 6, 10, 10, 0, tzinfo=pytz.utc ) assert len(data["test_task10"].computed_resources) == 1 assert data["test_task10"].computed_resources[0] in possible_resources # data["test_task11"] assert data["test_task11"].computed_start == datetime.datetime( 2013, 6, 14, 14, 0, tzinfo=pytz.utc ) assert data["test_task11"].computed_end == datetime.datetime( 2013, 6, 20, 10, 0, tzinfo=pytz.utc ) assert len(data["test_task11"].computed_resources) == 1 assert data["test_task11"].computed_resources[0] in possible_resources # data["test_shot1"] assert data["test_shot1"].computed_start == datetime.datetime( 2013, 5, 16, 11, 0, tzinfo=pytz.utc ) assert data["test_shot1"].computed_end == datetime.datetime( 2013, 6, 24, 16, 0, tzinfo=pytz.utc ) assert data["test_shot1"].computed_resources == [] # data["test_task4"] assert data["test_task4"].computed_start == datetime.datetime( 2013, 5, 16, 11, 0, tzinfo=pytz.utc ) assert data["test_task4"].computed_end == datetime.datetime( 2013, 5, 17, 18, 0, tzinfo=pytz.utc ) assert len(data["test_task4"].computed_resources) == 1 assert data["test_task4"].computed_resources[0] in possible_resources # data["test_task5"] assert data["test_task5"].computed_start == datetime.datetime( 2013, 6, 5, 13, 0, tzinfo=pytz.utc ) assert data["test_task5"].computed_end == datetime.datetime( 2013, 6, 7, 11, 0, tzinfo=pytz.utc ) assert len(data["test_task5"].computed_resources) == 1 assert data["test_task5"].computed_resources[0] in possible_resources # data["test_task6"] assert data["test_task6"].computed_start == datetime.datetime( 2013, 6, 11, 17, 0, tzinfo=pytz.utc ) assert data["test_task6"].computed_end == datetime.datetime( 2013, 6, 14, 14, 0, tzinfo=pytz.utc ) assert len(data["test_task6"].computed_resources) == 1 assert data["test_task6"].computed_resources[0] in possible_resources # data["test_task7"] assert data["test_task7"].computed_start == datetime.datetime( 2013, 6, 20, 10, 0, tzinfo=pytz.utc ) assert data["test_task7"].computed_end == datetime.datetime( 2013, 6, 24, 16, 0, tzinfo=pytz.utc ) assert len(data["test_task7"].computed_resources) == 1 assert data["test_task7"].computed_resources[0] in possible_resources # data["test_task1"] assert data["test_task1"].computed_start == datetime.datetime( 2013, 5, 17, 10, 0, tzinfo=pytz.utc ) assert data["test_task1"].computed_end == datetime.datetime( 2013, 5, 29, 18, 0, tzinfo=pytz.utc ) assert len(data["test_task1"].computed_resources) == 1 assert data["test_task1"].computed_resources[0] in possible_resources # data["test_project2"] # assert data["test_project2"].computed_start == \ # datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) # # assert data["test_project2"].computed_end == \ # datetime.datetime(2013, 6, 18, 12, 0, tzinfo=pytz.utc) # # assert data["test_project2"].computed_resources == [] # data["test_asset2"] assert data["test_asset2"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_asset2"].computed_end == datetime.datetime( 2013, 5, 30, 17, 0, tzinfo=pytz.utc ) assert data["test_asset2"].computed_resources == [] # data["test_task28"] assert data["test_task28"].computed_start == datetime.datetime( 2013, 4, 16, 9, 0, tzinfo=pytz.utc ) assert data["test_task28"].computed_end == datetime.datetime( 2013, 4, 26, 17, 0, tzinfo=pytz.utc ) assert len(data["test_task28"].computed_resources) == 1 assert data["test_task28"].computed_resources[0] in possible_resources # data["test_task29"] assert data["test_task29"].computed_start == datetime.datetime( 2013, 4, 26, 17, 0, tzinfo=pytz.utc ) assert data["test_task29"].computed_end == datetime.datetime( 2013, 5, 16, 11, 0, tzinfo=pytz.utc ) assert len(data["test_task29"].computed_resources) == 1 assert data["test_task29"].computed_resources[0] in possible_resources # data["test_task30"] assert data["test_task30"].computed_start == datetime.datetime( 2013, 5, 20, 9, 0, tzinfo=pytz.utc ) assert data["test_task30"].computed_end == datetime.datetime( 2013, 5, 30, 17, 0, tzinfo=pytz.utc ) assert len(data["test_task30"].computed_resources) == 1 assert data["test_task30"].computed_resources[0] in possible_resources # data["test_task31"] assert data["test_task31"].computed_start == datetime.datetime( 2013, 5, 20, 9, 0, tzinfo=pytz.utc ) assert data["test_task31"].computed_end == datetime.datetime( 2013, 5, 30, 17, 0, tzinfo=pytz.utc ) assert len(data["test_task31"].computed_resources) == 1 assert data["test_task31"].computed_resources[0] in possible_resources # data["test_shot3"] assert data["test_shot3"].computed_start == datetime.datetime( 2013, 4, 30, 15, 0, tzinfo=pytz.utc ) assert data["test_shot3"].computed_end == datetime.datetime( 2013, 6, 20, 10, 0, tzinfo=pytz.utc ) assert data["test_shot3"].computed_resources == [] # data["test_task12"] assert data["test_task12"].computed_start == datetime.datetime( 2013, 4, 30, 15, 0, tzinfo=pytz.utc ) assert data["test_task12"].computed_end == datetime.datetime( 2013, 5, 2, 13, 0, tzinfo=pytz.utc ) assert len(data["test_task12"].computed_resources) == 1 assert data["test_task12"].computed_resources[0] in possible_resources # data["test_task13"] assert data["test_task13"].computed_start == datetime.datetime( 2013, 5, 30, 17, 0, tzinfo=pytz.utc ) assert data["test_task13"].computed_end == datetime.datetime( 2013, 6, 3, 15, 0, tzinfo=pytz.utc ) assert len(data["test_task13"].computed_resources) == 1 assert data["test_task13"].computed_resources[0] in possible_resources # data["test_task14"] assert data["test_task14"].computed_start == datetime.datetime( 2013, 6, 7, 11, 0, tzinfo=pytz.utc ) assert data["test_task14"].computed_end == datetime.datetime( 2013, 6, 11, 17, 0, tzinfo=pytz.utc ) assert len(data["test_task14"].computed_resources) == 1 assert data["test_task14"].computed_resources[0] in possible_resources # data["test_task15"] assert data["test_task15"].computed_start == datetime.datetime( 2013, 6, 14, 14, 0, tzinfo=pytz.utc ) assert data["test_task15"].computed_end == datetime.datetime( 2013, 6, 20, 10, 0, tzinfo=pytz.utc ) assert len(data["test_task15"].computed_resources) == 1 assert data["test_task15"].computed_resources[0] in possible_resources # data["test_shot4"] assert data["test_shot4"].computed_start == datetime.datetime( 2013, 5, 2, 13, 0, tzinfo=pytz.utc ) assert data["test_shot4"].computed_end == datetime.datetime( 2013, 6, 24, 16, 0, tzinfo=pytz.utc ) assert data["test_shot4"].computed_resources == [] # data["test_task16"] assert data["test_task16"].computed_start == datetime.datetime( 2013, 5, 2, 13, 0, tzinfo=pytz.utc ) assert data["test_task16"].computed_end == datetime.datetime( 2013, 5, 6, 11, 0, tzinfo=pytz.utc ) assert len(data["test_task16"].computed_resources) == 1 assert data["test_task16"].computed_resources[0] in possible_resources # data["test_task17"] assert data["test_task17"].computed_start == datetime.datetime( 2013, 6, 3, 15, 0, tzinfo=pytz.utc ) assert data["test_task17"].computed_end == datetime.datetime( 2013, 6, 5, 13, 0, tzinfo=pytz.utc ) assert len(data["test_task17"].computed_resources) == 1 assert data["test_task17"].computed_resources[0] in possible_resources # data["test_task18"] assert data["test_task18"].computed_start == datetime.datetime( 2013, 6, 10, 10, 0, tzinfo=pytz.utc ) assert data["test_task18"].computed_end == datetime.datetime( 2013, 6, 12, 16, 0, tzinfo=pytz.utc ) assert len(data["test_task18"].computed_resources) == 1 assert data["test_task18"].computed_resources[0] in possible_resources # data["test_task19"] assert data["test_task19"].computed_start == datetime.datetime( 2013, 6, 19, 11, 0, tzinfo=pytz.utc ) assert data["test_task19"].computed_end == datetime.datetime( 2013, 6, 24, 16, 0, tzinfo=pytz.utc ) assert len(data["test_task19"].computed_resources) == 1 assert data["test_task19"].computed_resources[0] in possible_resources # data["test_task2"] assert data["test_task2"].computed_start == datetime.datetime( 2013, 5, 30, 9, 0, tzinfo=pytz.utc ) assert data["test_task2"].computed_end == datetime.datetime( 2013, 6, 11, 17, 0, tzinfo=pytz.utc ) assert len(data["test_task2"].computed_resources) == 1 assert data["test_task2"].computed_resources[0] in possible_resources def test_schedule_schedules_only_tasks_of_the_given_projects_with_the_given_scheduler( setup_studio_db_tests, ): """schedule method schedules the tasks of the projects with the Scheduler.""" data = setup_studio_db_tests # create a dummy Project to schedule dummy_project = Project( name="Dummy Project", code="DP", repository=data["test_repo"] ) dt1 = Task( name="Dummy Task 1", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user1"]], ) dt2 = Task( name="Dummy Task 2", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user2"]], ) DBSession.add_all([dummy_project, dt1, dt2]) DBSession.commit() tj_scheduler = TaskJugglerScheduler( compute_resources=True, projects=[dummy_project] ) data["test_studio"].now = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].start = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].end = datetime.datetime(2013, 7, 30, 0, 0, tzinfo=pytz.utc) data["test_studio"].scheduler = tj_scheduler data["test_studio"].schedule() DBSession.commit() # now check the timings of the tasks are all adjusted assert dt1.computed_start == datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) assert dt1.computed_end == datetime.datetime(2013, 4, 16, 13, 0, tzinfo=pytz.utc) assert dt2.computed_start == datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) assert dt2.computed_end == datetime.datetime(2013, 4, 16, 13, 0, tzinfo=pytz.utc) # data["test_project"] assert data["test_project1"].computed_start is None assert data["test_project1"].computed_end is None # data["test_asset1"] assert data["test_asset1"].computed_start is None assert data["test_asset1"].computed_end is None assert data["test_asset1"].computed_resources == data["test_asset1"].resources # data["test_task24"] assert data["test_task24"].computed_start is None assert data["test_task24"].computed_end is None assert data["test_task24"].computed_resources == data["test_task24"].resources # data["test_task25"] assert data["test_task25"].computed_start is None assert data["test_task25"].computed_end is None assert data["test_task25"].computed_resources == data["test_task25"].resources # data["test_task26"] assert data["test_task26"].computed_start is None assert data["test_task26"].computed_end is None assert data["test_task26"].computed_resources == data["test_task26"].resources # data["test_task27"] assert data["test_task27"].computed_start is None assert data["test_task27"].computed_end is None assert data["test_task27"].computed_resources == data["test_task27"].resources # data["test_shot2"] assert data["test_shot2"].computed_start is None assert data["test_shot2"].computed_end is None assert data["test_shot2"].computed_resources == data["test_shot2"].resources # data["test_task8"] assert data["test_task8"].computed_start is None assert data["test_task8"].computed_end is None assert data["test_task8"].computed_resources == data["test_task8"].resources # data["test_task9"] assert data["test_task9"].computed_start is None assert data["test_task9"].computed_end is None assert data["test_task9"].computed_resources == data["test_task9"].resources # data["test_task10"] assert data["test_task10"].computed_start is None assert data["test_task10"].computed_end is None assert data["test_task10"].computed_resources == data["test_task10"].resources # data["test_task11"] assert data["test_task11"].computed_start is None assert data["test_task11"].computed_end is None assert data["test_task11"].computed_resources == data["test_task11"].resources # data["test_shot1"] assert data["test_shot1"].computed_start is None assert data["test_shot1"].computed_end is None assert data["test_shot1"].computed_resources == data["test_shot1"].resources # data["test_task4"] assert data["test_task4"].computed_start is None assert data["test_task4"].computed_end is None assert data["test_task4"].computed_resources == data["test_task4"].resources # data["test_task5"] assert data["test_task5"].computed_start is None assert data["test_task5"].computed_end is None assert data["test_task5"].computed_resources == data["test_task5"].resources # data["test_task6"] assert data["test_task6"].computed_start is None assert data["test_task6"].computed_end is None assert data["test_task6"].computed_resources == data["test_task6"].resources # data["test_task7"] assert data["test_task7"].computed_start is None assert data["test_task7"].computed_end is None assert data["test_task7"].computed_resources == data["test_task7"].resources # data["test_task1"] assert data["test_task1"].computed_start is None assert data["test_task1"].computed_end is None assert data["test_task1"].computed_resources == data["test_task1"].resources # data["test_asset2"] assert data["test_asset2"].computed_start is None assert data["test_asset2"].computed_end is None assert data["test_asset2"].computed_resources == data["test_asset2"].resources # data["test_task28"] assert data["test_task28"].computed_start is None assert data["test_task28"].computed_end is None assert data["test_task28"].computed_resources == data["test_task28"].resources # data["test_task29"] assert data["test_task29"].computed_start is None assert data["test_task29"].computed_end is None assert data["test_task29"].computed_resources == data["test_task29"].resources # data["test_task30"] assert data["test_task30"].computed_start is None assert data["test_task30"].computed_end is None assert data["test_task30"].computed_resources == data["test_task30"].resources # data["test_task31"] assert data["test_task31"].computed_start is None assert data["test_task31"].computed_end is None assert data["test_task31"].computed_resources == data["test_task31"].resources # data["test_shot3"] assert data["test_shot3"].computed_start is None assert data["test_shot3"].computed_end is None assert data["test_shot3"].computed_resources == data["test_shot3"].resources # data["test_task12"] assert data["test_task12"].computed_start is None assert data["test_task12"].computed_end is None assert data["test_task12"].computed_resources == data["test_task12"].resources # data["test_task13"] assert data["test_task13"].computed_start is None assert data["test_task13"].computed_end is None assert data["test_task13"].computed_resources == data["test_task13"].resources # data["test_task14"] assert data["test_task14"].computed_start is None assert data["test_task14"].computed_end is None assert data["test_task14"].computed_resources == data["test_task14"].resources # data["test_task15"] assert data["test_task15"].computed_start is None assert data["test_task15"].computed_end is None assert data["test_task15"].computed_resources == data["test_task15"].resources # data["test_shot4"] assert data["test_shot4"].computed_start is None assert data["test_shot4"].computed_end is None assert data["test_shot4"].computed_resources == data["test_shot4"].resources # data["test_task16"] assert data["test_task16"].computed_start is None assert data["test_task16"].computed_end is None assert data["test_task16"].computed_resources == data["test_task16"].resources # data["test_task17"] assert data["test_task17"].computed_start is None assert data["test_task17"].computed_end is None assert data["test_task17"].computed_resources == data["test_task17"].resources # data["test_task18"] assert data["test_task18"].computed_start is None assert data["test_task18"].computed_end is None assert data["test_task18"].computed_resources == data["test_task18"].resources # data["test_task19"] assert data["test_task19"].computed_start is None assert data["test_task19"].computed_end is None assert data["test_task19"].computed_resources == data["test_task19"].resources # data["test_task2"] assert data["test_task2"].computed_start is None assert data["test_task2"].computed_end is None assert data["test_task2"].computed_resources == data["test_task2"].resources def test_is_scheduling_will_be_false_after_scheduling_is_done(setup_studio_db_tests): """is_scheduling attribute is back to False if the scheduling is finished.""" data = setup_studio_db_tests # use a dummy scheduler data["test_studio"].now = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].start = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].end = datetime.datetime(2013, 7, 30, 0, 0, tzinfo=pytz.utc) def callback(): assert data["test_studio"].is_scheduling is True dummy_scheduler = DummyScheduler(callback=callback) data["test_studio"].scheduler = dummy_scheduler assert data["test_studio"].is_scheduling is False # with v0.2.6.9 it is now the users duty to set is_scheduling to True data["test_studio"].is_scheduling = True data["test_studio"].schedule() assert data["test_studio"].is_scheduling is False def test_schedule_will_store_schedule_info_in_database(setup_studio_db_tests): """schedule method will store the schedule info in database.""" data = setup_studio_db_tests tj_scheduler = TaskJugglerScheduler() data["test_studio"].now = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].start = datetime.datetime(2013, 4, 15, 22, 56, tzinfo=pytz.utc) data["test_studio"].end = datetime.datetime(2013, 7, 30, 0, 0, tzinfo=pytz.utc) data["test_studio"].scheduler = tj_scheduler data["test_studio"].schedule(scheduled_by=data["test_user1"]) assert data["test_studio"].last_scheduled_by == data["test_user1"] last_schedule_message = data["test_studio"].last_schedule_message last_scheduled_at = data["test_studio"].last_scheduled_at last_scheduled_by = data["test_studio"].last_scheduled_by assert last_schedule_message is not None assert last_scheduled_at is not None assert last_scheduled_by is not None DBSession.add(data["test_studio"]) DBSession.commit() # delete the studio instance and retrieve it back and check if it has # the info del data["test_studio"] studio = Studio.query.first() assert studio.is_scheduling is False assert datetime.datetime.now( pytz.utc ) - studio.scheduling_started_at < datetime.timedelta(minutes=1) assert studio.last_schedule_message == last_schedule_message assert studio.last_scheduled_at == last_scheduled_at assert studio.last_scheduled_by == last_scheduled_by assert studio.last_scheduled_by_id == data["test_user1"].id assert studio.last_scheduled_by == data["test_user1"] def test_vacation_attribute_is_read_only(setup_studio_db_tests): """vacation attribute is a read-only attribute.""" data = setup_studio_db_tests with pytest.raises(AttributeError) as cm: data["test_studio"].vacations = "some random value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'vacations'", }.get( sys.version_info.minor, "property 'vacations' of 'Studio' object has no setter" ) assert str(cm.value) == error_message def test_vacation_attribute_returns_studio_vacation_instances(setup_studio_db_tests): """vacation attribute is returning the Vacation instances with no user set.""" data = setup_studio_db_tests vacation1 = Vacation( start=datetime.datetime(2013, 8, 2, tzinfo=pytz.utc), end=datetime.datetime(2013, 8, 10, tzinfo=pytz.utc), ) vacation2 = Vacation( start=datetime.datetime(2013, 8, 11, tzinfo=pytz.utc), end=datetime.datetime(2013, 8, 20, tzinfo=pytz.utc), ) vacation3 = Vacation( user=data["test_user1"], start=datetime.datetime(2013, 8, 11, tzinfo=pytz.utc), end=datetime.datetime(2013, 8, 20, tzinfo=pytz.utc), ) DBSession.add_all([vacation1, vacation2, vacation3]) DBSession.commit() assert sorted(data["test_studio"].vacations, key=lambda x: x.name) == sorted( [vacation1, vacation2], key=lambda x: x.name ) def test_timing_resolution_arg_skipped(setup_studio_db_tests): """timing_resolution attr is set to default if timing_resolution arg is skipped.""" data = setup_studio_db_tests try: data["kwargs"].pop("timing_resolution") except KeyError: pass studio = Studio(**data["kwargs"]) assert studio.timing_resolution == defaults.timing_resolution def test_timing_resolution_arg_is_none(setup_studio_db_tests): """timing_resolution attr is set to default if timing_resolution arg is None.""" data = setup_studio_db_tests data["kwargs"]["timing_resolution"] = None studio = Studio(**data["kwargs"]) assert studio.timing_resolution == defaults.timing_resolution def test_timing_resolution_attribute_is_set_to_none(setup_studio_db_tests): """timing_resolution attr is set to the default if it is set to None.""" data = setup_studio_db_tests data["kwargs"]["timing_resolution"] = datetime.timedelta(minutes=5) studio = Studio(**data["kwargs"]) # check start conditions assert studio.timing_resolution == data["kwargs"]["timing_resolution"] studio.timing_resolution = None assert studio.timing_resolution == defaults.timing_resolution def test_timing_resolution_arg_is_not_a_timedelta_instance(setup_studio_db_tests): """TypeError is raised if timing_resolution arg is not datetime.timedelta.""" data = setup_studio_db_tests data["kwargs"]["timing_resolution"] = "not a timedelta instance" with pytest.raises(TypeError) as cm: Studio(**data["kwargs"]) assert str(cm.value) == ( "Studio.timing_resolution should be an instance of datetime.timedelta, " "not str: 'not a timedelta instance'" ) def test_timing_resolution_attribute_is_not_a_timedelta_instance(setup_studio_db_tests): """TypeError raised if timing_resolution attr is not a datetime.timedelta.""" data = setup_studio_db_tests new_foo_obj = Studio(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_foo_obj.timing_resolution = "not a timedelta instance" assert str(cm.value) == ( "Studio.timing_resolution should be an instance of datetime.timedelta, " "not str: 'not a timedelta instance'" ) def test_timing_resolution_arg_is_working_as_expected(setup_studio_db_tests): """timing_resolution arg value is passed to timing_resolution attr correctly.""" data = setup_studio_db_tests data["kwargs"]["timing_resolution"] = datetime.timedelta(minutes=5) studio = Studio(**data["kwargs"]) assert studio.timing_resolution == data["kwargs"]["timing_resolution"] def test_timing_resolution_attribute_is_working_as_expected(setup_studio_db_tests): """timing_resolution attribute is working as expected.""" data = setup_studio_db_tests studio = Studio(**data["kwargs"]) res = studio new_res = datetime.timedelta(hours=1, minutes=30) assert res != new_res studio.timing_resolution = new_res assert studio.timing_resolution == new_res def test_to_unit_is_not_implemented_yet(setup_studio_db_tests): """to_unit() is not implemented yet.""" data = setup_studio_db_tests with pytest.raises(NotImplementedError) as cm: _ = data["test_studio"].to_unit(1, "h", "min") assert str(cm.value) == "this is not implemented yet" ================================================ FILE: tests/models/test_tag.py ================================================ # -*- coding: utf-8 -*- """Tests for the Tag class.""" from stalker import Tag, SimpleEntity def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Tag class.""" assert Tag.__auto_name__ is False def test_tag_init(): """tag inits as expected.""" # this should work without any error tag = Tag(name="a test tag", description="this is a test tag") assert isinstance(tag, Tag) def test_equality(): """equality of two Tags.""" kwargs = dict(name="a test tag", description="this is a test tag") simple_entity = SimpleEntity(**kwargs) a_tag_object1 = Tag(**kwargs) a_tag_object2 = Tag(**kwargs) kwargs["name"] = "a new test Tag" kwargs["description"] = "this is a new test Tag" a_tag_object3 = Tag(**kwargs) assert a_tag_object1 == a_tag_object2 assert not a_tag_object1 == a_tag_object3 assert not a_tag_object1 == simple_entity def test_inequality(): """inequality of two Tags.""" kwargs = dict(name="a test tag", description="this is a test tag") simple_entity = SimpleEntity(**kwargs) a_tag_object1 = Tag(**kwargs) a_tag_object2 = Tag(**kwargs) kwargs["name"] = "a new test Tag" kwargs["description"] = "this is a new test Tag" a_tag_object3 = Tag(**kwargs) assert not a_tag_object1 != a_tag_object2 assert a_tag_object1 != a_tag_object3 assert a_tag_object1 != simple_entity def test_plural_class_name(): """plural name of Tag class.""" kwargs = dict(name="a test tag", description="this is a test tag") test_tag = Tag(**kwargs) assert test_tag.plural_class_name == "Tags" def test__hash__is_working_as_expected(): """__hash__ is working as expected.""" kwargs = dict(name="a test tag", description="this is a test tag") test_tag = Tag(**kwargs) result = hash(test_tag) assert isinstance(result, int) assert result == test_tag.__hash__() ================================================ FILE: tests/models/test_task.py ================================================ # -*- coding: utf-8 -*- """Tests for the Task class.""" import copy import datetime import os import sys import warnings import pytest import pytz import stalker import stalker.db.setup from stalker import ( Entity, FilenameTemplate, Good, Project, Repository, Status, StatusList, Structure, Studio, Task, Ticket, TimeLog, Type, User, defaults, ) from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError from stalker.models.enum import ( DependencyTarget, ScheduleConstraint, ScheduleModel, TimeUnit, ) from stalker.models.mixins import ( DateRangeMixin, ) @pytest.fixture(scope="function") def setup_task_tests(): """tests that doesn't require a database.""" data = dict() defaults.config_values = defaults.default_config_values.copy() defaults["timing_resolution"] = datetime.timedelta(hours=1) assert defaults.daily_working_hours == 9 assert defaults.weekly_working_days == 5 assert defaults.yearly_working_days == 261 data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["task_status_list"] = StatusList( statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Task", ) data["test_project_status_list"] = StatusList( name="Project Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type="Project", ) data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234" ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234" ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234" ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234" ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234" ) data["test_project1"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], status_list=data["test_project_status_list"], repositories=[data["test_repository"]], ) data["test_dependent_task1"] = Task( name="Dependent Task1", project=data["test_project1"], status_list=data["task_status_list"], responsible=[data["test_user1"]], ) data["test_dependent_task2"] = Task( name="Dependent Task2", project=data["test_project1"], status_list=data["task_status_list"], responsible=[data["test_user1"]], ) data["kwargs"] = { "name": "Modeling", "description": "A Modeling Task", "project": data["test_project1"], "priority": 500, "responsible": [data["test_user1"]], "resources": [data["test_user1"], data["test_user2"]], "alternative_resources": [ data["test_user3"], data["test_user4"], data["test_user5"], ], "allocation_strategy": "minloaded", "persistent_allocation": True, "watchers": [data["test_user3"]], "bid_timing": 4, "bid_unit": TimeUnit.Day, "schedule_timing": 1, "schedule_unit": TimeUnit.Day, "start": datetime.datetime(2013, 4, 8, 13, 0, tzinfo=pytz.utc), "end": datetime.datetime(2013, 4, 8, 18, 0, tzinfo=pytz.utc), "depends_on": [data["test_dependent_task1"], data["test_dependent_task2"]], "time_logs": [], "versions": [], "is_milestone": False, "status": 0, "status_list": data["task_status_list"], } yield data defaults.config_values = copy.deepcopy(defaults.default_config_values) defaults["timing_resolution"] = datetime.timedelta(hours=1) def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Task class.""" assert Task.__auto_name__ is False def test_priority_arg_is_skipped_defaults_to_task_priority(setup_task_tests): """priority arg skipped priority attr defaults to task_priority.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("priority") new_task = Task(**kwargs) assert new_task.priority == defaults.task_priority def test_priority_arg_is_given_as_none_defaults_to_task_priority( setup_task_tests, ): """priority arg is None defaults the priority attr to task_priority.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["priority"] = None new_task = Task(**kwargs) assert new_task.priority == defaults.task_priority def test_priority_attribute_is_given_as_none_defaults_to_task_priority( setup_task_tests, ): """priority attr is None defaults the priority attr to task_priority.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.priority = None assert new_task.priority == defaults.task_priority def test_priority_arg_any_given_other_value_then_int_defaults_to_task_priority( setup_task_tests, ): """TypeError raised if the priority arg value is not an int.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["priority"] = "a324" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.priority should be an integer value between 0 and 1000, not str: 'a324'" ) def test_priority_attribute_is_not_an_int(setup_task_tests): """TypeError raised if priority attr not a number.""" data = setup_task_tests test_value = "test_value_324" new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.priority = test_value assert str(cm.value) == ( "Task.priority should be an integer value between 0 and 1000, " "not str: 'test_value_324'" ) def test_priority_arg_is_negative(setup_task_tests): """priority arg is negative value sets the priority attribute to zero.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["priority"] = -1 new_task = Task(**kwargs) assert new_task.priority == 0 def test_priority_attr_is_negative(setup_task_tests): """priority attr is given as a negative value sets the priority attr to zero.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.priority = -1 assert new_task.priority == 0 def test_priority_arg_is_too_big(setup_task_tests): """priority arg is bigger than 1000 clamps the priority attr value to 1000.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["priority"] = 1001 new_task = Task(**kwargs) assert new_task.priority == 1000 def test_priority_attr_is_too_big(setup_task_tests): """priority attr is set to a value bigger than 1000 clamps the value to 1000.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.priority = 1001 assert new_task.priority == 1000 @pytest.mark.parametrize("test_value", [500.1, 334.23]) def test_priority_arg_is_float(test_value, setup_task_tests): """float numbers for priority arg is converted to int.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["priority"] = test_value new_task = Task(**kwargs) assert new_task.priority == int(test_value) @pytest.mark.parametrize("test_value", [500.1, 334.23]) def test_priority_attr_is_float(test_value, setup_task_tests): """float numbers for priority attr is converted to int.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.priority = test_value assert new_task.priority == int(test_value) def test_priority_attr_is_working_as_expected(setup_task_tests): """priority attr is working as expected.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = 234 new_task.priority = test_value assert new_task.priority == test_value def test_resources_arg_is_skipped(setup_task_tests): """resources attr is an empty list if the resources arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("resources") new_task = Task(**kwargs) assert new_task.resources == [] def test_resources_arg_is_none(setup_task_tests): """resources attr is an empty list if the resources arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["resources"] = None new_task = Task(**kwargs) assert new_task.resources == [] def test_resources_attr_is_none(setup_task_tests): """TypeError raised whe the resources attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.resources = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_resources_arg_is_not_list(setup_task_tests): """TypeError raised if the resources arg is not a list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["resources"] = "a resource" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_resources_attr_is_not_list(setup_task_tests): """TypeError raised if the resources attr is set to any other value then a list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.resources = "a resource" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_resources_arg_is_set_to_a_list_of_other_values_then_user( setup_task_tests, ): """TypeError raised if the resources arg is set to a list non User.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["resources"] = ["a", "list", "of", "resources", data["test_user1"]] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.resources should only contain instances of " "stalker.models.auth.User, not str: 'a'" ) def test_resources_attr_is_set_to_a_list_of_other_values_then_user( setup_task_tests, ): """TypeError raised if the resources attr is set to a list of non User.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.resources = ["a", "list", "of", "resources", data["test_user1"]] assert str(cm.value) == ( "Task.resources should only contain instances of " "stalker.models.auth.User, not str: 'a'" ) def test_resources_attr_is_working_as_expected(setup_task_tests): """resources attr is working as expected.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = [data["test_user1"]] new_task.resources = test_value assert new_task.resources == test_value def test_resources_arg_back_references_to_user(setup_task_tests): """User in the resources arg has the current task in their "User.tasks" attr.""" data = setup_task_tests # create a couple of new users new_user1 = User( name="test1", login="test1", email="test1@test.com", password="test1" ) new_user2 = User( name="test2", login="test2", email="test2@test.com", password="test2" ) # assign it to a newly created task kwargs = copy.copy(data["kwargs"]) kwargs["resources"] = [new_user1] new_task = Task(**kwargs) # now check if the user has the task in its tasks list assert new_task in new_user1.tasks # now change the resources list new_task.resources = [new_user2] assert new_task in new_user2.tasks assert new_task not in new_user1.tasks # now append the new resource new_task.resources.append(new_user1) assert new_task in new_user1.tasks # clean up test new_task.resources = [] def test_resources_attr_back_references_to_user(setup_task_tests): """User in the resources arg has the current task in their "User.tasks" attr.""" data = setup_task_tests # create a new user new_user = User( name="Test User", login="test_user", email="testuser@test.com", password="test_pass", ) # assign it to a newly created task # data["kwargs"]["resources"] = [new_user] kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.resources = [new_user] # now check if the user has the task in its tasks list assert new_task in new_user.tasks def test_resources_attr_clears_itself_from_the_previous_users( setup_task_tests, ): """resources attr is update clears itself from the current resources tasks attr.""" data = setup_task_tests # create a couple of new users new_user1 = User( name="Test User1", login="test_user1", email="testuser1@test.com", password="test_pass", ) new_user2 = User( name="Test User2", login="test_user2", email="testuser2@test.com", password="test_pass", ) new_user3 = User( name="Test User3", login="test_user3", email="testuser3@test.com", password="test_pass", ) new_user4 = User( name="Test User4", login="test_user4", email="testuser4@test.com", password="test_pass", ) # now add the 1 and 2 to the resources with the resources arg # assign it to a newly created task kwargs = copy.copy(data["kwargs"]) kwargs["resources"] = [new_user1, new_user2] new_task = Task(**kwargs) # now check if the user has the task in its tasks list assert new_task in new_user1.tasks assert new_task in new_user2.tasks # now update the resources list new_task.resources = [new_user3, new_user4] # now check if the new resources has the task in their tasks attr assert new_task in new_user3.tasks assert new_task in new_user4.tasks # and if it is not in the previous users tasks assert new_task not in new_user1.tasks assert new_task not in new_user2.tasks def test_watchers_arg_is_skipped(setup_task_tests): """watchers attr is an empty list if the watchers arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("watchers") new_task = Task(**kwargs) assert new_task.watchers == [] def test_watchers_arg_is_none(setup_task_tests): """watchers attr is an empty list if the watchers arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["watchers"] = None new_task = Task(**kwargs) assert new_task.watchers == [] def test_watchers_attr_is_none(setup_task_tests): """TypeError raised whe the watchers attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.watchers = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_watchers_arg_is_not_list(setup_task_tests): """TypeError raised if the watchers arg is not a list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["watchers"] = "a resource" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_watchers_attr_is_not_list(setup_task_tests): """TypeError raised if the watchers attr is set to any other value then a list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.watchers = "a resource" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_watchers_arg_is_set_to_a_list_of_other_values_then_user(setup_task_tests): """TypeError raised if the watchers arg is not a list of User instances.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["watchers"] = ["a", "list", "of", "watchers", data["test_user1"]] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.watchers should only contain instances of " "stalker.models.auth.User, not str: 'a'" ) def test_watchers_attr_is_set_to_a_list_of_other_values_then_user( setup_task_tests, ): """TypeError raised if the watchers attr is set to a list of non User objects.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_values = ["a", "list", "of", "watchers", data["test_user1"]] with pytest.raises(TypeError): new_task.watchers = test_values def test_watchers_attr_is_working_as_expected(setup_task_tests): """watchers attr is working as expected.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = [data["test_user1"]] new_task.watchers = test_value assert new_task.watchers == test_value def test_watchers_arg_back_references_to_user(setup_task_tests): """User in the watchers arg has the current task in their "User.watching" attr.""" data = setup_task_tests # create a couple of new users new_user1 = User( name="new_user1", login="new_user1", email="new_user1@test.com", password="new_user1", ) new_user2 = User( name="new_user2", login="new_user2", email="new_user2@test.com", password="new_user2", ) # assign it to a newly created task kwargs = copy.copy(data["kwargs"]) kwargs["watchers"] = [new_user1] new_task = Task(**kwargs) # now check if the user has the task in its tasks list assert new_task in new_user1.watching # now change the watchers list new_task.watchers = [new_user2] assert new_task in new_user2.watching assert new_task not in new_user1.watching # now append the new user new_task.watchers.append(new_user1) assert new_task in new_user1.watching def test_watchers_attr_back_references_to_user(setup_task_tests): """User in the watchers arg has the current task in their "User.watching" attr.""" data = setup_task_tests # create a new user new_user = User( name="new_user", login="new_user", email="new_user@test.com", password="new_user", ) # assign it to a newly created task kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.watchers = [new_user] # now check if the user has the task in its watching list assert new_task in new_user.watching def test_watchers_attr_clears_itself_from_the_previous_users(setup_task_tests): """watchers attr is updated clears itself from the watchers watching attr.""" data = setup_task_tests # create a couple of new users new_user1 = User( name="new_user1", login="new_user1", email="new_user1@test.com", password="new_user1", ) new_user2 = User( name="new_user2", login="new_user2", email="new_user2@test.com", password="new_user2", ) new_user3 = User( name="new_user3", login="new_user3", email="new_user3@test.com", password="new_user3", ) new_user4 = User( name="new_user4", login="new_user4", email="new_user4@test.com", password="new_user4", ) # now add the 1 and 2 to the watchers with the watchers arg # assign it to a newly created task kwargs = copy.copy(data["kwargs"]) kwargs["watchers"] = [new_user1, new_user2] new_task = Task(**kwargs) # now check if the user has the task in its watching list assert new_task in new_user1.watching assert new_task in new_user2.watching # now update the watchers list new_task.watchers = [new_user3, new_user4] # now check if the new watchers has the task in their watching # attr assert new_task in new_user3.watching assert new_task in new_user4.watching # and if it is not in the previous users watching list assert new_task not in new_user1.watching assert new_task not in new_user2.watching def test_depends_arg_is_skipped_depends_attr_is_empty_list(setup_task_tests): """ "depends_on" attr is an empty list if the "depends_on" arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("depends_on") new_task = Task(**kwargs) assert new_task.depends_on == [] def test_depends_arg_is_none_depends_attr_is_empty_list(setup_task_tests): """ "depends_on" attr is an empty list if the "depends_on" arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None new_task = Task(**kwargs) assert new_task.depends_on == [] def test_depends_arg_is_not_a_list(setup_task_tests): """TypeError raised if the "depends_on" arg is not a list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = data["test_dependent_task1"] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == "'Task' object is not iterable" def test_depends_attr_is_not_a_list(setup_task_tests): """TypeError raised if the "depends_on" attr is set to something else then a list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.depends_on = data["test_dependent_task1"] assert str(cm.value) == "'Task' object is not iterable" def test_depends_arg_is_a_list_of_other_objects_than_a_task(setup_task_tests): """AttributeError raised if the "depends_on" arg is a list of non Task objects.""" data = setup_task_tests test_value = ["a", "dependent", "task", 1, 1.2] kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = test_value with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "TaskDependency.depends_on should be and instance of " "stalker.models.task.Task, not str: 'a'" ) def test_depends_attr_is_a_list_of_other_objects_than_a_task(setup_task_tests): """AttributeError raised if the "depends_on" attr is set to a list of non Task.""" data = setup_task_tests test_value = ["a", "dependent", "task", 1, 1.2] kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.depends_on = test_value assert str(cm.value) == ( "TaskDependency.depends_on should be and instance of " "stalker.models.task.Task, not str: 'a'" ) def test_depends_attr_does_not_allow_simple_cyclic_dependencies(setup_task_tests): """CircularDependencyError raised if "depends_on" in circular dependency.""" data = setup_task_tests # create two new tasks A, B # make B dependent to A # and make A dependent to B # and expect a CircularDependencyError kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_b.depends_on = [task_a] with pytest.raises(CircularDependencyError) as cm: task_a.depends_on = [task_b] assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "depends_on" attribute' ) def test_depends_attr_does_not_allow_cyclic_dependencies(setup_task_tests): """CircularDependencyError raised if "depends_on" attr has a circular dependency.""" data = setup_task_tests # create three new tasks A, B, C # make B dependent to A # make C dependent to B # and make A dependent to C # and expect a CircularDependencyError kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None kwargs["name"] = "taskA" task_a = Task(**kwargs) kwargs["name"] = "taskB" task_b = Task(**kwargs) kwargs["name"] = "taskC" task_c = Task(**kwargs) task_b.depends_on = [task_a] task_c.depends_on = [task_b] with pytest.raises(CircularDependencyError) as cm: task_a.depends_on = [task_c] assert ( str(cm.value) == " (Task) and (Task) are in a " 'circular dependency in their "depends_on" attribute' ) def test_depends_attr_does_not_allow_more_deeper_cyclic_dependencies( setup_task_tests, ): """CircularDependencyError raised if depends_on attr has deeper circular dependency.""" data = setup_task_tests # create new tasks A, B, C, D # make B dependent to A # make C dependent to B # make D dependent to C # and make A dependent to D # and expect a CircularDependencyError kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None kwargs["name"] = "taskA" task_a = Task(**kwargs) kwargs["name"] = "taskB" task_b = Task(**kwargs) kwargs["name"] = "taskC" task_c = Task(**kwargs) kwargs["name"] = "taskD" task_d = Task(**kwargs) task_b.depends_on = [task_a] task_c.depends_on = [task_b] task_d.depends_on = [task_c] with pytest.raises(CircularDependencyError) as cm: task_a.depends_on = [task_d] assert ( str(cm.value) == " (Task) and (Task) are in a " 'circular dependency in their "depends_on" attribute' ) def test_depends_arg_cyclic_dependency_bug_2(setup_task_tests): """CircularDependencyError raised in the following case: T1 is parent of T2 T3 depends on T1 T2 depends on T3 """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None kwargs["name"] = "T1" t1 = Task(**kwargs) kwargs["name"] = "T3" t3 = Task(**kwargs) t3.depends_on.append(t1) kwargs["name"] = "T2" kwargs["parent"] = t1 kwargs["depends_on"] = [t3] # the following should generate the CircularDependencyError with pytest.raises(CircularDependencyError) as cm: Task(**kwargs) assert ( str(cm.value) == "One of the parents of is depending on " ) def test_depends_arg_does_not_allow_one_of_the_parents_of_the_task(setup_task_tests): """CircularDependencyError raised if "depends_on" attr has one of the parents.""" data = setup_task_tests # create two new tasks A, B # make A parent to B # and make B dependent to A # and expect a CircularDependencyError kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_c = Task(**kwargs) task_b.parent = task_a task_a.parent = task_c assert task_b in task_a.children assert task_a in task_c.children with pytest.raises(CircularDependencyError) as cm: task_b.depends_on = [task_a] assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "children" attribute' ) with pytest.raises(CircularDependencyError) as cm: task_b.depends_on = [task_c] assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "children" attribute' ) def test_depends_arg_is_working_as_expected(setup_task_tests): """depends_on arg is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) kwargs["depends_on"] = [task_a, task_b] task_c = Task(**kwargs) assert task_a in task_c.depends_on assert task_b in task_c.depends_on assert len(task_c.depends_on) == 2 def test_depends_attr_is_working_as_expected(setup_task_tests): """ "depends_on" attr is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_c = Task(**kwargs) task_a.depends_on = [task_b] task_a.depends_on.append(task_c) assert task_b in task_a.depends_on assert task_c in task_a.depends_on def test_percent_complete_attr_is_read_only(setup_task_tests): """percent_complete attr is a read-only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.percent_complete = 32 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'percent_complete'", }.get( sys.version_info.minor, "property 'percent_complete' of 'Task' object has no setter", ) assert str(cm.value) == error_message def test_percent_complete_attr_is_working_as_expected_for_a_duration_based_leaf_task_1( setup_task_tests, ): """percent_complete attr is working as expected for a duration based leaf task. ######### ^ | now """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.computed_start = now - td(days=2) new_task.computed_end = now - td(days=1) assert new_task.percent_complete == 100 def test_percent_complete_attr_is_working_as_expected_for_a_duration_based_leaf_task_2( setup_task_tests, ): """percent_complete attr is working as expected for a duration based leaf task. ######### ^ | now """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.start = now - td(days=1, hours=1) new_task.end = now - td(hours=1) assert new_task.percent_complete == 100 def test_percent_complete_attr_is_working_as_expected_for_a_duration_based_leaf_task_3( setup_task_tests, ): """percent_complete attr is working as expected for a duration based leaf task. ######### ^ | now """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.start = now - td(hours=12) new_task.end = now + td(hours=12) # it should be somewhere around 50% # due to the timing resolution we cannot know it exactly # and I don't want to patch datetime.datetime.now(pytz.utc) # this is a very simple test assert abs(new_task.percent_complete - 50 < 5) def test_percent_complete_attr_is_working_as_expected_for_a_duration_based_leaf_task_4( setup_task_tests, ): """percent_complete attr is working as expected for a duration based leaf task. ######### ^ | now """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.computed_start = now new_task.computed_end = now + td(days=1) assert new_task.percent_complete < 5 def test_percent_complete_attr_is_working_as_expected_for_a_duration_based_leaf_task_5( setup_task_tests, ): """percent_complete attr is working as expected for a duration based leaf task. ######### ^ | now """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.computed_start = now + td(days=1) new_task.computed_end = now + td(days=2) assert new_task.percent_complete == 0 def test_is_milestone_arg_is_skipped(setup_task_tests): """is_milestone attr is False if the is_milestone arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("is_milestone") new_task = Task(**kwargs) assert new_task.is_milestone is False def test_is_milestone_arg_is_none(setup_task_tests): """is_milestone attr is set to False if the is_milestone arg is given as None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["is_milestone"] = None new_task = Task(**kwargs) assert new_task.is_milestone is False def test_is_milestone_attr_is_none(setup_task_tests): """is_milestone attr is False if set to None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.is_milestone = None assert new_task.is_milestone is False def test_is_milestone_arg_is_not_a_bool(setup_task_tests): """TypeError raised if the is_milestone arg is anything other than a bool.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["name"] = "test" + str(0) kwargs["is_milestone"] = "A string" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.is_milestone should be a bool value (True or False), not str: 'A string'" ) def test_is_milestone_attr_is_not_a_bool(setup_task_tests): """TypeError raised if the is_milestone attr is set not to a bool value.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) test_value = "A string" with pytest.raises(TypeError) as cm: new_task.is_milestone = test_value assert str(cm.value) == ( "Task.is_milestone should be a bool value (True or False), not str: 'A string'" ) def test_is_milestone_arg_makes_the_resources_list_an_empty_list(setup_task_tests): """resources is an empty list if the is_milestone arg is given as True.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["is_milestone"] = True kwargs["resources"] = [data["test_user1"], data["test_user2"]] new_task = Task(**kwargs) assert new_task.resources == [] def test_is_milestone_attr_makes_the_resource_list_an_empty_list(setup_task_tests): """resources is an empty list if the is_milestone attr is given as True.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.resources = [data["test_user1"], data["test_user2"]] new_task.is_milestone = True assert new_task.resources == [] def test_time_logs_attr_is_none(setup_task_tests): """TypeError raised if the time_logs attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.time_logs = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_time_logs_attr_is_not_a_list(setup_task_tests): """TypeError raised if the time_logs attr is not set to a list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.time_logs = 123 assert str(cm.value) == "Incompatible collection type: int is not list-like" def test_time_logs_attr_is_not_a_list_of_timelog_instances(setup_task_tests): """TypeError raised if the time_logs attr is not a list of TimeLog instances.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.time_logs = [1, "1", 1.2, "a time_log"] assert str(cm.value) == ( "Task.time_logs should only contain instances of " "stalker.models.task.TimeLog, not int: '1'" ) @pytest.mark.parametrize( "schedule_timing, schedule_unit, schedule_seconds", [ [10, "h", 10 * 3600], [23, "d", 23 * 9 * 3600], [2, "w", 2 * 45 * 3600], [2.5, "m", 2.5 * 4 * 45 * 3600], [10, TimeUnit.Hour, 10 * 3600], [23, TimeUnit.Day, 23 * 9 * 3600], [2, TimeUnit.Week, 2 * 45 * 3600], [2.5, TimeUnit.Month, 2.5 * 4 * 45 * 3600], # [ # 3.1, # "y", # 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600, # ], ], ) def test_schedule_seconds_is_working_as_expected_for_an_effort_based_task_no_studio( setup_task_tests, schedule_timing, schedule_unit, schedule_seconds ): """schedule_seconds attr is working okay for an effort based task when no studio.""" data = setup_task_tests # no studio, using defaults kwargs = copy.copy(data["kwargs"]) kwargs["schedule_model"] = ScheduleModel.Effort kwargs["schedule_timing"] = schedule_timing kwargs["schedule_unit"] = schedule_unit new_task = Task(**kwargs) assert new_task.schedule_seconds == schedule_seconds @pytest.mark.parametrize( "schedule_timing, schedule_unit, schedule_seconds", [ [10, "h", 10 * 3600], [23, "d", 23 * 8 * 3600], [2, "w", 2 * 40 * 3600], [2.5, "m", 2.5 * 4 * 40 * 3600], [10, TimeUnit.Hour, 10 * 3600], [23, TimeUnit.Day, 23 * 8 * 3600], [2, TimeUnit.Week, 2 * 40 * 3600], [2.5, TimeUnit.Month, 2.5 * 4 * 40 * 3600], # [ # 3.1, # "y", # 3.1 * studio.yearly_working_days * studio.daily_working_hours * 3600, # ], ], ) def test_schedule_seconds_is_working_as_expected_for_an_effort_based_task_with_studio( setup_task_tests, schedule_timing, schedule_unit, schedule_seconds ): """schedule_seconds attr is working okay for an effort based task when no studio.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) # no studio, using defaults defaults["timing_resolution"] = datetime.timedelta(hours=1) _ = Studio( name="Test Studio", daily_working_hours=8, timing_resolution=datetime.timedelta(hours=1), ) kwargs["schedule_model"] = ScheduleModel.Effort kwargs["schedule_timing"] = schedule_timing kwargs["schedule_unit"] = schedule_unit new_task = Task(**kwargs) assert new_task.schedule_seconds == schedule_seconds def test_schedule_seconds_is_working_as_expected_for_a_container_task(setup_task_tests): """schedule_seconds attr is working as expected for a container task.""" assert defaults.daily_working_hours == 9 assert defaults.weekly_working_days == 5 assert defaults.yearly_working_days == 261 data = setup_task_tests # no studio, using defaults kwargs = copy.copy(data["kwargs"]) parent_task = Task(**kwargs) kwargs["schedule_model"] = ScheduleModel.Effort kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) assert new_task.schedule_seconds == 10 * 3600 new_task.parent = parent_task assert parent_task.schedule_seconds == 10 * 3600 kwargs["schedule_timing"] = 23 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) assert new_task.schedule_seconds == 23 * defaults.daily_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 ) kwargs["schedule_timing"] = 2 kwargs["schedule_unit"] = TimeUnit.Week new_task = Task(**kwargs) assert new_task.schedule_seconds == 2 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 ) kwargs["schedule_timing"] = 2.5 kwargs["schedule_unit"] = TimeUnit.Month new_task = Task(**kwargs) assert new_task.schedule_seconds == 2.5 * 4 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 ) kwargs["schedule_timing"] = 3.1 kwargs["schedule_unit"] = TimeUnit.Year new_task = Task(**kwargs) assert new_task.schedule_seconds == pytest.approx( 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) new_task.parent = parent_task assert parent_task.schedule_seconds == pytest.approx( 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 + 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) def test_schedule_seconds_is_working_okay_for_a_container_task_if_the_child_is_updated( setup_task_tests, ): """schedule_seconds attr is working as expected for a container task.""" assert defaults.daily_working_hours == 9 assert defaults.weekly_working_days == 5 assert defaults.yearly_working_days == 261 data = setup_task_tests kwargs = copy.copy(data["kwargs"]) # no studio, using defaults parent_task = Task(**kwargs) kwargs["schedule_model"] = ScheduleModel.Effort kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) assert new_task.schedule_seconds == 10 * 3600 new_task.parent = parent_task assert parent_task.schedule_seconds == 10 * 3600 # update the schedule_timing of the child new_task.schedule_timing = 5 assert new_task.schedule_seconds == 5 * 3600 new_task.parent = parent_task assert parent_task.schedule_seconds == 5 * 3600 # update it back to 10 hours new_task.schedule_timing = 10 assert new_task.schedule_seconds == 10 * 3600 new_task.parent = parent_task assert parent_task.schedule_seconds == 10 * 3600 kwargs["schedule_timing"] = 23 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) assert new_task.schedule_seconds == 23 * defaults.daily_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 ) kwargs["schedule_timing"] = 2 kwargs["schedule_unit"] = TimeUnit.Week new_task = Task(**kwargs) assert new_task.schedule_seconds == 2 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 ) kwargs["schedule_timing"] = 2.5 kwargs["schedule_unit"] = TimeUnit.Month new_task = Task(**kwargs) assert new_task.schedule_seconds == 2.5 * 4 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task assert ( parent_task.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 ) kwargs["schedule_timing"] = 3.1 kwargs["schedule_unit"] = TimeUnit.Year new_task = Task(**kwargs) assert new_task.schedule_seconds == pytest.approx( 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) new_task.parent = parent_task assert parent_task.schedule_seconds == pytest.approx( 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 + 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) def test_schedule_seconds_is_working_okay_for_a_task_if_the_child_is_updated_deeper( setup_task_tests, ): """schedule_seconds attr is working as expected for a container task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) defaults["timing_resolution"] = datetime.timedelta(hours=1) defaults["daily_working_hours"] = 9 # no studio, using defaults parent_task1 = Task(**kwargs) assert parent_task1.schedule_seconds == 9 * 3600 parent_task2 = Task(**kwargs) assert parent_task2.schedule_seconds == 9 * 3600 parent_task2.schedule_timing = 5 assert parent_task2.schedule_seconds == 5 * 9 * 3600 parent_task2.schedule_unit = TimeUnit.Hour assert parent_task2.schedule_seconds == 5 * 3600 parent_task1.parent = parent_task2 assert parent_task2.schedule_seconds == 9 * 3600 # create another child task for parent_task2 child_task = Task(**kwargs) child_task.schedule_timing = 10 child_task.schedule_unit = TimeUnit.Hour assert child_task.schedule_seconds == 10 * 3600 parent_task2.children.append(child_task) assert parent_task2.schedule_seconds, 10 * 3600 + 9 * 3600 kwargs["schedule_model"] = ScheduleModel.Effort kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) assert new_task.schedule_seconds == 10 * 3600 new_task.parent = parent_task1 assert parent_task1.schedule_seconds == 10 * 3600 assert parent_task2.schedule_seconds == 10 * 3600 + 10 * 3600 # update the schedule_timing of the child new_task.schedule_timing = 5 assert new_task.schedule_seconds == 5 * 3600 new_task.parent = parent_task1 assert parent_task1.schedule_seconds == 5 * 3600 assert parent_task2.schedule_seconds == 5 * 3600 + 10 * 3600 # update it back to 10 hours new_task.schedule_timing = 10 assert new_task.schedule_seconds == 10 * 3600 new_task.parent = parent_task1 assert parent_task1.schedule_seconds == 10 * 3600 assert parent_task2.schedule_seconds == 10 * 3600 + 10 * 3600 kwargs["schedule_timing"] = 23 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) assert new_task.schedule_seconds == 23 * defaults.daily_working_hours * 3600 new_task.parent = parent_task1 assert ( parent_task1.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 ) assert ( parent_task2.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 10 * 3600 ) kwargs["schedule_timing"] = 2 kwargs["schedule_unit"] = TimeUnit.Week new_task = Task(**kwargs) assert new_task.schedule_seconds == 2 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task1 assert ( parent_task1.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 2 * defaults.weekly_working_hours * 3600 ) # update it to 1 week new_task.schedule_timing = 1 assert ( parent_task1.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 ) assert ( parent_task2.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 + 10 * 3600 ) kwargs["schedule_timing"] = 2.5 kwargs["schedule_unit"] = TimeUnit.Month new_task = Task(**kwargs) assert new_task.schedule_seconds == 2.5 * 4 * defaults.weekly_working_hours * 3600 new_task.parent = parent_task1 assert ( parent_task1.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 ) assert ( parent_task2.schedule_seconds == 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 + 10 * 3600 ) kwargs["schedule_timing"] = 3.1 kwargs["schedule_unit"] = TimeUnit.Year new_task = Task(**kwargs) assert new_task.schedule_seconds == pytest.approx( 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) new_task.parent = parent_task1 assert parent_task1.schedule_seconds == pytest.approx( 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 + 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 ) assert parent_task2.schedule_seconds == pytest.approx( 10 * 3600 + 23 * defaults.daily_working_hours * 3600 + 1 * defaults.weekly_working_hours * 3600 + 2.5 * 4 * defaults.weekly_working_hours * 3600 + 3.1 * defaults.yearly_working_days * defaults.daily_working_hours * 3600 + 10 * 3600 ) def test_remaining_seconds_attr_is_a_read_only_attr(setup_task_tests): """remaining hours is a read only attr.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(AttributeError) as cm: setattr(new_task, "remaining_seconds", 2342) error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'remaining_seconds'", }.get( sys.version_info.minor, "property 'remaining_seconds' of 'Task' object has no setter", ) assert str(cm.value) == error_message def test_versions_attr_is_none(setup_task_tests): """TypeError raised if the versions attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.versions = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_versions_attr_is_not_a_list(setup_task_tests): """TypeError raised if the versions attr is set to a value other than a list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.versions = 1 assert str(cm.value) == "Incompatible collection type: int is not list-like" def test_versions_attr_is_not_a_list_of_version_instances(setup_task_tests): """TypeError raised if the versions attr is set to a list of non Version objects.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.versions = [1, 1.2, "a version"] assert str(cm.value) == ( "Task.versions should only contain instances of " "stalker.models.version.Version, and not int: '1'" ) def test_equality(setup_task_tests): """equality operator.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) entity1 = Entity(**kwargs) task0 = Task(**kwargs) task1 = Task(**kwargs) task2 = Task(**kwargs) task3 = Task(**kwargs) task4 = Task(**kwargs) task5 = Task(**kwargs) task6 = Task(**kwargs) task1.depends_on = [task2] task2.parent = task3 task3.parent = task4 task5.children = [task6] task6.depends_on = [task2] assert not new_task == entity1 assert new_task == task0 assert not new_task == task1 assert not new_task == task5 assert not task1 == task2 assert not task1 == task3 assert not task1 == task4 assert not task2 == task3 assert not task2 == task4 assert not task3 == task4 assert not task5 == task6 # check task with same names but different projects def test_inequality(setup_task_tests): """inequality operator.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) _ = Entity(**kwargs) _ = Task(**kwargs) entity1 = Entity(**kwargs) task0 = Task(**kwargs) task1 = Task(**kwargs) task2 = Task(**kwargs) task3 = Task(**kwargs) task4 = Task(**kwargs) task5 = Task(**kwargs) task6 = Task(**kwargs) task1.depends_on = [task2] task2.parent = task3 task3.parent = task4 task5.children = [task6] assert new_task != entity1 assert not new_task != task0 assert new_task != task1 assert new_task != task5 assert task1 != task2 assert task1 != task3 assert task1 != task4 assert task2 != task3 assert task2 != task4 assert task3 != task4 assert task5 != task6 def test_parent_arg_is_skipped_there_is_a_project_arg(setup_task_tests): """Task is created okay without a parent if a Project is supplied in project arg.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) try: kwargs.pop("parent") except KeyError: pass kwargs["project"] = data["test_project1"] new_task = Task(**kwargs) assert new_task.project == data["test_project1"] # parent arg there but project skipped already tested # both skipped already tested def test_parent_arg_is_none_but_there_is_a_project_arg(setup_task_tests): """task is created okay without a parent if a Project is given in project arg.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None kwargs["project"] = data["test_project1"] new_task = Task(**kwargs) assert new_task.project == data["test_project1"] def test_parent_attr_is_set_to_none(setup_task_tests): """parent of a task can be set to None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) assert new_task2.parent == new_task1 # DBSession.add_all([new_task1, new_task2]) # DBSession.commit() # store the id to be used later # id_ = new_task2.id # assert id_ is not None new_task2.parent = None assert new_task2.parent is None # DBSession.commit() # we still should have this task # t = DBSession.get(Task, id_) # assert t is not None # assert t.name == kwargs['name'] def test_parent_arg_is_not_a_task_instance(setup_task_tests): """TypeError raised if the parent arg is not a Task instance.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = "not a task" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.parent should be an instance of stalker.models.task.Task, " "not str: 'not a task'" ) def test_parent_attr_is_not_a_task_instance(setup_task_tests): """TypeError raised if the parent attr is not a Task instance.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.parent = "not a task" assert str(cm.value) == ( "Task.parent should be an instance of stalker.models.task.Task, " "not str: 'not a task'" ) # there is no way to generate a CycleError by using the parent arg # cause the Task is just created, it is not in relationship with other # Tasks, there is no parent nor child. def test_parent_attr_creates_a_cycle(setup_task_tests): """CycleError raised if a child is tried to be set as the parent.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["name"] = "New Task" kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) with pytest.raises(CircularDependencyError) as cm: new_task1.parent = new_task2 assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "children" attribute' ) # deeper test kwargs["parent"] = new_task2 new_task3 = Task(**kwargs) with pytest.raises(CircularDependencyError) as cm: new_task1.parent = new_task3 assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "children" attribute' ) def test_parent_arg_is_working_as_expected(setup_task_tests): """parent arg is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) assert new_task2.parent == new_task1 def test_parent_attr_is_working_as_expected(setup_task_tests): """parent attr is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 kwargs["name"] = "New Task" new_task = Task(**kwargs) kwargs["name"] = "New Task 2" new_task2 = Task(**kwargs) assert new_task.parent != new_task2 new_task.parent = new_task2 assert new_task.parent == new_task2 def test_parent_arg_do_not_allow_a_dependent_task_to_be_parent(setup_task_tests): """CircularDependencyError raised if one of the dependencies assigned as parent.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_c = Task(**kwargs) kwargs["depends_on"] = [task_a, task_b, task_c] kwargs["parent"] = task_a with pytest.raises(CircularDependencyError) as cm: Task(**kwargs) assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "children" attribute' ) def test_parent_attr_do_not_allow_a_dependent_task_to_be_parent( setup_task_tests, ): """CircularDependencyError raised if one of the dependent tasks is set as parent.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_c = Task(**kwargs) task_d = Task(**kwargs) task_d.depends_on = [task_a, task_b, task_c] with pytest.raises(CircularDependencyError) as cm: task_d.parent = task_a assert ( str(cm.value) == " (Task) and (Task) are in " 'a circular dependency in their "depends_on" attribute' ) def test_children_attr_is_empty_list_by_default(setup_task_tests): """children attr is an empty list by default.""" data = setup_task_tests new_task = Task(**data["kwargs"]) assert new_task.children == [] def test_children_attr_is_set_to_none(setup_task_tests): """TypeError raised if the children attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.children = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_children_attr_accepts_tasks_only(setup_task_tests): """TypeError raised if children attr is set to a non list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.children = "no task" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_children_attr_is_working_as_expected(setup_task_tests): """children attr is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["name"] = "Task 1" task1 = Task(**kwargs) kwargs["name"] = "Task 2" task2 = Task(**kwargs) kwargs["name"] = "Task 3" task3 = Task(**kwargs) assert task2 not in task1.children assert task3 not in task1.children task1.children.append(task2) assert task2 in task1.children task3.parent = task1 assert task3 in task1.children def test_is_leaf_attr_is_read_only(setup_task_tests): """is_leaf attr is a read only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.is_leaf = True error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_leaf'", }.get(sys.version_info.minor, "property 'is_leaf' of 'Task' object has no setter") assert str(cm.value) == error_message def test_is_leaf_attr_is_working_as_expected(setup_task_tests): """is_leaf attr is True for a Task without a child Task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["name"] = "Task 1" task1 = Task(**kwargs) kwargs["name"] = "Task 2" task2 = Task(**kwargs) kwargs["name"] = "Task 3" task3 = Task(**kwargs) task2.parent = task1 task3.parent = task1 assert task2.is_leaf assert task3.is_leaf assert not task1.is_leaf def test_is_root_attr_is_read_only(setup_task_tests): """is_root attr is a read only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.is_root = True error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_root'", }.get(sys.version_info.minor, "property 'is_root' of 'Task' object has no setter") assert str(cm.value) == error_message def test_is_root_attr_is_working_as_expected(setup_task_tests): """is_root attr is True for a Task without a parent Task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["name"] = "Task 1" task1 = Task(**kwargs) kwargs["name"] = "Task 2" task2 = Task(**kwargs) kwargs["name"] = "Task 3" task3 = Task(**kwargs) task2.parent = task1 task3.parent = task1 assert not task2.is_root assert not task3.is_root assert not task1.is_root assert new_task.is_root def test_is_container_attr_is_read_only(setup_task_tests): """is_container attr is a read only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.is_container = False error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_container'", }.get( sys.version_info.minor, "property 'is_container' of 'Task' object has no setter" ) assert str(cm.value) == error_message def test_is_container_attr_working_as_expected(setup_task_tests): """is_container attr is True for a Task with at least one child Task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["name"] = "Task 1" task1 = Task(**kwargs) kwargs["name"] = "Task 2" task2 = Task(**kwargs) kwargs["name"] = "Task 3" task3 = Task(**kwargs) task2.parent = task1 task3.parent = task1 assert not task2.is_container assert not task3.is_container assert task1.is_container def test_project_and_parent_args_are_skipped(setup_task_tests): """TypeError raised if there is no project nor a parent task is given with the project and parent args respectively """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("project") try: kwargs.pop("parent") except KeyError: pass with pytest.raises(TypeError) as cm: Task(**kwargs) assert ( str(cm.value) == "Task.project should be an instance of " "stalker.models.project.Project, not NoneType: 'None'.\n\nOr please supply " "a stalker.models.task.Task with the parent argument, so " "Stalker can use the project of the supplied parent task" ) def test_project_arg_is_skipped_but_there_is_a_parent_arg(setup_task_tests): """Task created okay without a Project if there is a Task given in parent arg.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs.pop("project") kwargs["parent"] = new_task new_task2 = Task(**kwargs) assert new_task2.project == data["test_project1"] def test_project_arg_is_not_a_project_instance(setup_task_tests): """TypeError raised if the given value for the project arg is not a stalker.models.project.Project instance """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["name"] = "New Task 1" kwargs["project"] = "Not a Project instance" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.project should be an instance of stalker.models.project.Project, " "not str: 'Not a Project instance'" ) def test_project_attr_is_a_read_only_attr(setup_task_tests): """project attr is a read only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.project = data["test_project1"] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Task' object has no setter", 12: "property of 'Task' object has no setter", }.get( sys.version_info.minor, "property '_project_getter' of 'Task' object has no setter", ) assert str(cm.value) == error_message def test_project_arg_is_not_matching_the_given_parent_arg(setup_task_tests): """RuntimeWarning raised if project and parent is not matching. The project of the given parent is different from the supplied Project with the project arg. """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["name"] = "New Task" kwargs["parent"] = new_task kwargs["project"] = Project( name="Some Other Project", code="SOP", status_list=data["test_project_status_list"], repository=data["test_repository"], ) # catching warnings are different from catching exceptions # pytest.raises(RuntimeWarning, Task, **data["kwargs"]) warnings.simplefilter("always") with warnings.catch_warnings(record=True) as w: Task(**kwargs) assert issubclass(w[-1].category, RuntimeWarning) assert str(w[0].message) == ( "The supplied parent and the project is not matching in , " "Stalker will use the parent's project () as the " "parent of this Task" ) def test_project_arg_is_not_matching_the_given_parent_arg_new_task_uses_parents_project( setup_task_tests, ): """task uses the parent's project if project is not matching parent's project.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["name"] = "New Task" kwargs["parent"] = new_task kwargs["project"] = Project( name="Some Other Project", code="SOP", status_list=data["test_project_status_list"], repository=data["test_repository"], ) new_task2 = Task(**kwargs) assert new_task2.project == new_task.project def test_start_and_end_attr_values_of_a_container_task_are_defined_by_its_child_tasks( setup_task_tests, ): """start and end attr values is defined by the earliest start and the latest end date values of the children Tasks for a container Task """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) # remove effort and duration. Why? kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") kwargs["schedule_constraint"] = ScheduleConstraint.Both now = datetime.datetime(2013, 3, 22, 15, 0, tzinfo=pytz.utc) dt = datetime.timedelta # task1 kwargs["name"] = "Task1" kwargs["start"] = now kwargs["end"] = now + dt(3) task1 = Task(**kwargs) # task2 kwargs["name"] = "Task2" kwargs["start"] = now + dt(1) kwargs["end"] = now + dt(5) task2 = Task(**kwargs) # task3 kwargs["name"] = "Task3" kwargs["start"] = now + dt(3) kwargs["end"] = now + dt(8) task3 = Task(**kwargs) # check start conditions assert task1.start == now assert task1.end == now + dt(3) # now parent the task2 and task3 to task1 task2.parent = task1 task1.children.append(task3) # check if the start is not `now` anymore assert task1.start != now assert task1.end != now + dt(3) # but assert task1.start == now + dt(1) assert task1.end == now + dt(8) kwargs["name"] = "Task4" kwargs["start"] = now + dt(15) kwargs["end"] = now + dt(16) task4 = Task(**kwargs) task3.parent = task4 assert task4.start == task3.start assert task4.end == task3.end assert task1.start == task2.start assert task1.end == task2.end # TODO: with SQLAlchemy 0.9 please also check if removing the last # child from a parent will update the parents start and end date # values def test_end_value_is_calculated_with_the_schedule_timing_and_schedule_unit( setup_task_tests, ): """end attr is calculated using schedule_timing and schedule_unit for new task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["start"] = datetime.datetime(2013, 4, 17, 0, 0, tzinfo=pytz.utc) kwargs.pop("end") kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) assert new_task.end == datetime.datetime(2013, 4, 17, 10, 0, tzinfo=pytz.utc) kwargs["schedule_timing"] = 5 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) print(new_task.end) print(type(new_task.end)) assert new_task.end == datetime.datetime(2013, 4, 22, 0, 0, tzinfo=pytz.utc) def test_start_calc_with_schedule_timing_and_schedule_unit_if_schedule_constraint_is_end( setup_task_tests, ): """start_date is calc. from schedule_timing and schedule_unit if schedule_constraint is "end".""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) 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"] = ScheduleConstraint.End kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) assert new_task.end == datetime.datetime(2013, 4, 18, 0, 0, tzinfo=pytz.utc) assert new_task.start == datetime.datetime(2013, 4, 8, 0, 0, tzinfo=pytz.utc) def test_start_and_end_values_are_not_touched_if_the_schedule_constraint_is_set_to_both( setup_task_tests, ): """start and end date are not touched if schedule constraint is set to "both".""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) _ = Task(**kwargs) 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"] = ScheduleConstraint.Both kwargs["schedule_timing"] = 100 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) assert new_task.start == datetime.datetime(2013, 4, 17, 0, 0, tzinfo=pytz.utc) assert new_task.end == datetime.datetime(2013, 4, 27, 0, 0, tzinfo=pytz.utc) def test_level_attr_is_a_read_only_property(setup_task_tests): """level attr is a read only property.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.level = 0 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'level'", }.get(sys.version_info.minor, "property 'level' of 'Task' object has no setter") assert str(cm.value) == error_message def test_level_attr_returns_the_hierarchical_level_of_this_task(setup_task_tests): """level attr returns the hierarchical level of this task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) _ = Task(**kwargs) kwargs["name"] = "T1" test_task1 = Task(**kwargs) assert test_task1.level == 1 kwargs["name"] = "T2" test_task2 = Task(**kwargs) test_task2.parent = test_task1 assert test_task2.level == 2 kwargs["name"] = "T3" test_task3 = Task(**kwargs) test_task3.parent = test_task2 assert test_task3.level == 3 def test__check_circular_dependency_causes_recursion(setup_task_tests): """Bug ID: None Try to create one parent and three child tasks, second and third child are dependent to the first child. This was causing a recursion. """ data = setup_task_tests task1 = Task( project=data["test_project1"], name="Set Day", start=datetime.datetime(2013, 4, 1, tzinfo=pytz.utc), end=datetime.datetime(2013, 5, 6, tzinfo=pytz.utc), status_list=data["task_status_list"], responsible=[data["test_user1"]], ) task2 = Task( parent=task1, name="Supervising Shootings Part1", start=datetime.datetime(2013, 4, 1, tzinfo=pytz.utc), end=datetime.datetime(2013, 4, 11, tzinfo=pytz.utc), status_list=data["task_status_list"], ) task3 = Task( parent=task1, name="Supervising Shootings Part2", depends_on=[task2], start=datetime.datetime(2013, 4, 12, tzinfo=pytz.utc), end=datetime.datetime(2013, 4, 16, tzinfo=pytz.utc), status_list=data["task_status_list"], ) task4 = Task( parent=task1, name="Supervising Shootings Part3", depends_on=[task3], start=datetime.datetime(2013, 4, 12, tzinfo=pytz.utc), end=datetime.datetime(2013, 4, 17, tzinfo=pytz.utc), status_list=data["task_status_list"], ) # move task4 dependency to task2 task4.depends_on = [task2] def test_parent_attr_checks_cycle_on_self(setup_task_tests): """Bug ID: None Check if a CircularDependency Error raised if the parent attr is pointing itself.""" data = setup_task_tests task1 = Task( project=data["test_project1"], name="Set Day", start=datetime.datetime(2013, 4, 1, tzinfo=pytz.utc), end=datetime.datetime(2013, 5, 6, tzinfo=pytz.utc), status_list=data["task_status_list"], responsible=[data["test_user1"]], ) with pytest.raises(CircularDependencyError) as cm: task1.parent = task1 assert ( str(cm.value) == " (Task) and (Task) " 'are in a circular dependency in their "children" attribute' ) def test_bid_timing_arg_is_skipped(setup_task_tests): """bid_timing is equal to schedule_timing if bid_timing arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["schedule_timing"] = 155 kwargs.pop("bid_timing") new_task = Task(**kwargs) assert new_task.schedule_timing == kwargs["schedule_timing"] assert new_task.bid_timing == new_task.schedule_timing def test_bid_timing_arg_is_none(setup_task_tests): """bid_timing attr value is equal to schedule_timing attr value if the bid_timing arg is None """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_timing"] = None kwargs["schedule_timing"] = 1342 new_task = Task(**kwargs) assert new_task.schedule_timing == kwargs["schedule_timing"] assert new_task.bid_timing == new_task.schedule_timing def test_bid_timing_attr_is_set_to_none(setup_task_tests): """bid_timing attr can be set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.bid_timing = None assert new_task.bid_timing is None def test_bid_timing_arg_is_not_an_int_or_float(setup_task_tests): """TypeError raised if the bid_timing arg is not an int or float.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_timing"] = "10d" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.bid_timing should be an integer or float showing the value of the " "initial bid for this Task, not str: '10d'" ) def test_bid_timing_attr_is_not_an_int_or_float(setup_task_tests): """TypeError raised if the bid_timing attr is set to a value which is not an int or float """ data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.bid_timing = "10d" assert str(cm.value) == ( "Task.bid_timing should be an integer or float showing the value of the " "initial bid for this Task, not str: '10d'" ) def test_bid_timing_arg_is_working_as_expected(setup_task_tests): """bid_timing arg is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_timing"] = 23 new_task = Task(**kwargs) assert new_task.bid_timing == kwargs["bid_timing"] def test_bid_timing_attr_is_working_as_expected(setup_task_tests): """bid_timing attr is working as expected.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = 23 new_task.bid_timing = test_value assert new_task.bid_timing == test_value def test_bid_unit_arg_is_skipped(setup_task_tests): """bid_unit attr value is equal to schedule_unit attr value if the bid_unit arg is skipped """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["schedule_unit"] = TimeUnit.Day kwargs.pop("bid_unit") new_task = Task(**kwargs) assert new_task.schedule_unit == kwargs["schedule_unit"] assert new_task.bid_unit == new_task.schedule_unit def test_bid_unit_arg_is_none(setup_task_tests): """bid_unit attr value is equal to schedule_unit attr value if the bid_unit arg is None """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_unit"] = None kwargs["schedule_unit"] = TimeUnit.Minute new_task = Task(**kwargs) assert new_task.schedule_unit == kwargs["schedule_unit"] assert new_task.bid_unit == new_task.schedule_unit def test_bid_unit_attr_is_set_to_none(setup_task_tests): """bid_unit attr can be set to default value of 'h'.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.bid_unit = None assert new_task.bid_unit == TimeUnit.Hour def test_bid_unit_arg_is_not_a_str(setup_task_tests): """TypeError raised if the bid_hour arg is not a str.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_unit"] = 10 with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '10'" ) def test_bid_unit_attr_is_not_a_str(setup_task_tests): """TypeError raised if the bid_unit attr is set to a value which is not an int.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.bid_unit = 10 assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '10'" ) def test_bid_unit_arg_is_working_as_expected(setup_task_tests): """bid_unit arg is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_unit"] = TimeUnit.Hour new_task = Task(**kwargs) assert new_task.bid_unit == kwargs["bid_unit"] def test_bid_unit_attr_is_working_as_expected(setup_task_tests): """bid_unit attr is working as expected.""" data = setup_task_tests test_value = TimeUnit.Hour new_task = Task(**data["kwargs"]) new_task.bid_unit = test_value assert new_task.bid_unit == test_value def test_bid_unit_arg_value_not_in_defaults_datetime_units(setup_task_tests): """ValueError raised if the given unit value is not in the stalker.config.Config.datetime_units """ data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["bid_unit"] = "os" with pytest.raises(ValueError) as cm: Task(**kwargs) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'os'" ) def test_bid_unit_attr_value_not_in_defaults_datetime_units(setup_task_tests): """ValueError raised if the bid_unit value is set to a value which is not in stalker.config.Config.datetime_units. """ data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(ValueError) as cm: new_task.bid_unit = "sys" assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'sys'" ) def test_tjp_id_is_a_read_only_attr(setup_task_tests): """tjp_id attr is a read only attr.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(AttributeError): new_task.tjp_id = "some value" def test_tjp_abs_id_is_a_read_only_attr(setup_task_tests): """tjp_abs_id attr is a read only attr.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_task.tjp_abs_id = "some_value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'tjp_abs_id'", }.get( sys.version_info.minor, "property 'tjp_abs_id' of 'Task' object has no setter" ) assert str(cm.value) == error_message def test_tjp_id_attr_is_working_as_expected_for_a_root_task(setup_task_tests): """tjp_id is working as expected for a root task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None new_task = Task(**kwargs) assert new_task.tjp_id == f"Task_{new_task.id}" def test_tjp_id_attr_is_working_as_expected_for_a_leaf_task(setup_task_tests): """tjp_id is working as expected for a leaf task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 kwargs["depends_on"] = None new_task2 = Task(**kwargs) assert new_task2.tjp_id == f"Task_{new_task2.id}" def test_tjp_abs_id_attr_is_working_as_expected_for_a_root_task(setup_task_tests): """tjp_abs_id is working as expected for a root task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None new_task = Task(**kwargs) assert new_task.tjp_abs_id == "Project_{}.Task_{}".format( kwargs["project"].id, new_task.id, ) def test_tjp_abs_id_attr_is_working_as_expected_for_a_leaf_task(setup_task_tests): """tjp_abs_id is working as expected for a leaf task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None t1 = Task(**kwargs) t2 = Task(**kwargs) t3 = Task(**kwargs) t2.parent = t1 t3.parent = t2 assert t3.tjp_abs_id == "Project_{}.Task_{}.Task_{}.Task_{}".format( kwargs["project"].id, t1.id, t2.id, t3.id, ) def test_to_tjp_attr_is_working_as_expected_for_a_root_task(setup_task_tests): """to_tjp attr is working as expected for a root task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Day kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [] kwargs["resources"] = [data["test_user1"], data["test_user2"]] dep_t1 = Task(**kwargs) dep_t2 = Task(**kwargs) kwargs["depends_on"] = [dep_t1, dep_t2] kwargs["name"] = "Modeling" t1 = Task(**kwargs) data["test_project1"].id = 120 t1.id = 121 dep_t1.id = 122 dep_t2.id = 123 data["test_user1"].id = 124 data["test_user2"].id = 125 data["test_user3"].id = 126 data["test_user4"].id = 127 data["test_user5"].id = 128 expected_tjp = """task Task_{t1_id} "Task_{t1_id}" {{ depends Project_{project1_id}.Task_{dep_t1_id} {{onend}}, Project_{project1_id}.Task_{dep_t2_id} {{onend}} effort 10d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }}""".format( project1_id=data["test_project1"].id, t1_id=t1.id, dep_t1_id=dep_t1.id, dep_t2_id=dep_t2.id, user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(t1.to_tjp) assert t1.to_tjp == expected_tjp def test_to_tjp_attr_is_working_as_expected_for_a_leaf_task(setup_task_tests): """to_tjp attr is working as expected for a leaf task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["depends_on"] = [] dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1003 kwargs["schedule_unit"] = TimeUnit.Hour kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] kwargs["resources"] = [data["test_user1"], data["test_user2"]] new_task2 = Task(**kwargs) # create some random ids data["test_project1"].id = 120 new_task.id = 121 new_task2.id = 122 dep_task1.id = 123 dep_task2.id = 124 data["test_user1"].id = 125 data["test_user2"].id = 126 data["test_user3"].id = 127 data["test_user4"].id = 128 data["test_user5"].id = 129 # data["maxDiff"] = None expected_tjp = """ task Task_{new_task2_id} "Task_{new_task2_id}" {{ depends Project_{project1_id}.Task_{new_task_id}.Task_{dep_task1_id} {{onend}}, Project_{project1_id}.Task_{new_task_id}.Task_{dep_task2_id} {{onend}} effort 1003h allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }}""".format( project1_id=data["test_project1"].id, new_task_id=new_task.id, new_task2_id=new_task2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(new_task2.to_tjp) assert new_task2.to_tjp == expected_tjp def test_to_tjp_attr_is_working_as_expected_for_a_leaf_task_with_timelogs( setup_task_tests, ): """to_tjp attr is working as expected for a leaf task with timelogs.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["depends_on"] = [] dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1003 kwargs["schedule_unit"] = TimeUnit.Hour kwargs["schedule_model"] = ScheduleModel.Effort kwargs["resources"] = [data["test_user1"], data["test_user2"]] new_task2 = Task(**kwargs) # create some random ids data["test_project1"].id = 120 new_task.id = 121 new_task2.id = 122 dep_task1.id = 123 dep_task2.id = 124 data["test_user1"].id = 125 data["test_user2"].id = 126 data["test_user3"].id = 127 data["test_user4"].id = 128 data["test_user5"].id = 129 # add some timelogs start = datetime.datetime(2024, 11, 13, 12, 0, tzinfo=pytz.utc) end = start + datetime.timedelta(hours=2) new_task2.create_time_log(data["test_user1"], start, end) # data["maxDiff"] = None expected_tjp = """ task Task_{new_task2_id} "Task_{new_task2_id}" {{ effort 1003h allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} booking User_125 2024-11-13-12:00:00 - 2024-11-13-14:00:00 {{ overtime 2 }} }}""".format( project1_id=data["test_project1"].id, new_task_id=new_task.id, new_task2_id=new_task2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(new_task2.to_tjp) assert new_task2.to_tjp == expected_tjp def test_to_tjp_attr_is_working_as_expected_for_a_leaf_task_with_dependency_details( setup_task_tests, ): """to_tjp attr is working as expected for a leaf task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task kwargs["depends_on"] = [] dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1003 kwargs["schedule_unit"] = TimeUnit.Hour kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] kwargs["resources"] = [data["test_user1"], data["test_user2"]] new_task2 = Task(**kwargs) # modify dependency attributes tdep1 = new_task2.task_depends_on[0] tdep1.dependency_target = DependencyTarget.OnStart tdep1.gap_timing = 2 tdep1.gap_unit = TimeUnit.Day tdep1.gap_model = ScheduleModel.Length tdep2 = new_task2.task_depends_on[1] tdep1.dependency_target = DependencyTarget.OnStart tdep2.gap_timing = 4 tdep2.gap_unit = TimeUnit.Day tdep2.gap_model = ScheduleModel.Duration # create some random ids data["test_project1"].id = 120 new_task.id = 121 new_task2.id = 122 dep_task1.id = 123 dep_task2.id = 124 data["test_user1"].id = 125 data["test_user2"].id = 126 data["test_user3"].id = 127 data["test_user4"].id = 128 data["test_user5"].id = 129 expected_tjp = """ task Task_{new_task2_id} "Task_{new_task2_id}" {{ depends Project_{project1_id}.Task_{new_task_id}.Task_{dep_task1_id} {{onstart gaplength 2d}}, Project_{project1_id}.Task_{new_task_id}.Task_{dep_task2_id} {{onend gapduration 4d}} effort 1003h allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }}""".format( project1_id=data["test_project1"].id, new_task_id=new_task.id, new_task2_id=new_task2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(new_task2.to_tjp) assert new_task2.to_tjp == expected_tjp def test_to_tjp_attr_is_working_okay_for_a_leaf_task_with_custom_allocation_strategy( setup_task_tests, ): """to_tjp attr is working okay for a leaf task with custom allocation_strategy.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 kwargs["depends_on"] = [] dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1003 kwargs["schedule_unit"] = TimeUnit.Hour kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] kwargs["resources"] = [data["test_user1"], data["test_user2"]] kwargs["alternative_resources"] = [data["test_user3"]] kwargs["allocation_strategy"] = "minloaded" new_task2 = Task(**kwargs) # modify dependency attributes tdep1 = new_task2.task_depends_on[0] tdep1.dependency_target = DependencyTarget.OnStart tdep1.gap_timing = 2 tdep1.gap_unit = TimeUnit.Day tdep1.gap_model = ScheduleModel.Length tdep2 = new_task2.task_depends_on[1] tdep1.dependency_target = DependencyTarget.OnStart tdep2.gap_timing = 4 tdep2.gap_unit = TimeUnit.Day tdep2.gap_model = ScheduleModel.Duration # create some random id data["test_project1"].id = 120 new_task1.id = 121 new_task2.id = 122 dep_task1.id = 123 dep_task2.id = 124 data["test_user1"].id = 125 data["test_user2"].id = 126 data["test_user3"].id = 127 data["test_user4"].id = 128 data["test_user5"].id = 129 expected_tjp = """ task Task_{new_task2_id} "Task_{new_task2_id}" {{ depends Project_{project1_id}.Task_{new_task1_id}.Task_{dep_task1_id} {{onstart gaplength 2d}}, Project_{project1_id}.Task_{new_task1_id}.Task_{dep_task2_id} {{onend gapduration 4d}} effort 1003h allocate User_{user1_id} {{ alternative User_{user3_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id} select minloaded persistent }} }}""".format( project1_id=data["test_project1"].id, new_task1_id=new_task1.id, new_task2_id=new_task2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(new_task2.to_tjp) assert new_task2.to_tjp == expected_tjp def test_to_tjp_attr_is_working_as_expected_for_a_container_task(setup_task_tests): """to_tjp attr is working as expected for a container task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None kwargs["depends_on"] = [] t1 = Task(**kwargs) kwargs["parent"] = t1 dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1 kwargs["schedule_unit"] = TimeUnit.Day kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] kwargs["resources"] = [data["test_user1"], data["test_user2"]] t2 = Task(**kwargs) # set some random ids data["test_user1"].id = 123 data["test_user2"].id = 124 data["test_user3"].id = 125 data["test_user4"].id = 126 data["test_user5"].id = 127 data["test_project1"].id = 128 t1.id = 129 t2.id = 130 dep_task1.id = 131 dep_task2.id = 132 expected_tjp = """task Task_{t1_id} "Task_{t1_id}" {{ task Task_{dep_task1_id} "Task_{dep_task1_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{dep_task2_id} "Task_{dep_task2_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{t2_id} "Task_{t2_id}" {{ depends Project_{project1_id}.Task_{t1_id}.Task_{dep_task1_id} {{onend}}, Project_{project1_id}.Task_{t1_id}.Task_{dep_task2_id} {{onend}} effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} }}""".format( user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, project1_id=data["test_project1"].id, t1_id=t1.id, t2_id=t2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(t1.to_tjp) assert t1.to_tjp == expected_tjp def test_to_tjp_attr_is_working_as_expected_for_a_container_task_with_dependency( setup_task_tests, ): """to_tjp attr is working as expected for a container task which has dependency.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) # kwargs['project'].id = 87987 kwargs["parent"] = None kwargs["depends_on"] = [] kwargs["name"] = "Random Task Name 1" t0 = Task(**kwargs) kwargs["depends_on"] = [t0] kwargs["name"] = "Modeling" t1 = Task(**kwargs) t1.priority = 888 kwargs["parent"] = t1 kwargs["depends_on"] = [] dep_task1 = Task(**kwargs) dep_task1.depends_on = [] dep_task2 = Task(**kwargs) dep_task1.depends_on = [] kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1 kwargs["schedule_unit"] = TimeUnit.Day kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] data["test_user1"].name = "Test User 1" data["test_user1"].login = "test_user1" # data["test_user1"].id = 1231 data["test_user2"].name = "Test User 2" data["test_user2"].login = "test_user2" # data["test_user2"].id = 1232 kwargs["resources"] = [data["test_user1"], data["test_user2"]] t2 = Task(**kwargs) # generate random ids data["test_user1"].id = 123 data["test_user2"].id = 124 data["test_user3"].id = 125 data["test_user4"].id = 126 data["test_user5"].id = 127 data["test_project1"].id = 128 t0.id = 129 t1.id = 130 t2.id = 131 dep_task1.id = 132 dep_task2.id = 133 expected_tjp = """task Task_{t1_id} "Task_{t1_id}" {{ priority 888 depends Project_{project1_id}.Task_{t0_id} {{onend}} task Task_{dep_task1_id} "Task_{dep_task1_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{dep_task2_id} "Task_{dep_task2_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{t2_id} "Task_{t2_id}" {{ depends Project_{project1_id}.Task_{t1_id}.Task_{dep_task1_id} {{onend}}, Project_{project1_id}.Task_{t1_id}.Task_{dep_task2_id} {{onend}} effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} }}""".format( user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, project1_id=data["test_project1"].id, t0_id=t0.id, t1_id=t1.id, t2_id=t2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(t1.to_tjp) assert t1.to_tjp == expected_tjp def test_to_tjp_schedule_constraint_is_reflected_in_tjp_file(setup_task_tests): """schedule_constraint is reflected in the tjp file.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) # kwargs['project'].id = 87987 kwargs["parent"] = None kwargs["depends_on"] = [] t1 = Task(**kwargs) kwargs["parent"] = t1 dep_task1 = Task(**kwargs) dep_task2 = Task(**kwargs) kwargs["name"] = "Modeling" kwargs["schedule_timing"] = 1 kwargs["schedule_unit"] = TimeUnit.Day kwargs["schedule_model"] = ScheduleModel.Effort kwargs["depends_on"] = [dep_task1, dep_task2] kwargs["schedule_constraint"] = 3 kwargs["start"] = datetime.datetime(2013, 5, 3, 14, 0, tzinfo=pytz.utc) kwargs["end"] = datetime.datetime(2013, 5, 4, 14, 0, tzinfo=pytz.utc) data["test_user1"].name = "Test User 1" data["test_user1"].login = "test_user1" # data["test_user1"].id = 1231 data["test_user2"].name = "Test User 2" data["test_user2"].login = "test_user2" # data["test_user2"].id = 1232 kwargs["resources"] = [data["test_user1"], data["test_user2"]] t2 = Task(**kwargs) # create some random ids data["test_user1"].id = 120 data["test_user2"].id = 121 data["test_user3"].id = 122 data["test_user4"].id = 123 data["test_user5"].id = 124 data["test_project1"].id = 125 t1.id = 126 t2.id = 127 dep_task1.id = 128 dep_task2.id = 129 expected_tjp = """task Task_{t1_id} "Task_{t1_id}" {{ task Task_{dep_task1_id} "Task_{dep_task1_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{dep_task2_id} "Task_{dep_task2_id}" {{ effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} task Task_{t2_id} "Task_{t2_id}" {{ depends Project_{project1_id}.Task_{t1_id}.Task_{dep_task1_id} {{onend}}, Project_{project1_id}.Task_{t1_id}.Task_{dep_task2_id} {{onend}} start 2013-05-03-14:00 end 2013-05-04-14:00 effort 1d allocate User_{user1_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }}, User_{user2_id} {{ alternative User_{user3_id}, User_{user4_id}, User_{user5_id} select minloaded persistent }} }} }}""".format( user1_id=data["test_user1"].id, user2_id=data["test_user2"].id, user3_id=data["test_user3"].id, user4_id=data["test_user4"].id, user5_id=data["test_user5"].id, project1_id=data["test_project1"].id, t1_id=t1.id, t2_id=t2.id, dep_task1_id=dep_task1.id, dep_task2_id=dep_task2.id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------------------------') # print("Result:") # print("-------") # print(t1.to_tjp) data["maxDiff"] = None assert t1.to_tjp == expected_tjp def test_is_scheduled_is_a_read_only_attr(setup_task_tests): """is_scheduled is a read-only attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.is_scheduled = True error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'is_scheduled'", }.get( sys.version_info.minor, "property 'is_scheduled' of 'Task' object has no setter" ) assert str(cm.value) == error_message def test_is_scheduled_is_true_if_the_computed_start_and_computed_end_is_not_none( setup_task_tests, ): """is_scheduled attr is True if computed_start and computed_end are both valid.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.computed_start = datetime.datetime.now(pytz.utc) new_task.computed_end = datetime.datetime.now(pytz.utc) + datetime.timedelta(10) assert new_task.is_scheduled is True def test_is_scheduled_is_false_if_one_of_computed_start_and_computed_end_is_none( setup_task_tests, ): """is_scheduled attr is False if one of computed_start or computed_end is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.computed_start = None new_task.computed_end = datetime.datetime.now(pytz.utc) assert new_task.is_scheduled is False new_task.computed_start = datetime.datetime.now(pytz.utc) new_task.computed_end = None assert new_task.is_scheduled is False def test_parents_attr_is_read_only(setup_task_tests): """parents attr is read only.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.parents = data["test_dependent_task1"] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'parents'", }.get(sys.version_info.minor, "property 'parents' of 'Task' object has no setter") assert str(cm.value) == error_message def test_parents_attr_is_working_as_expected(setup_task_tests): """parents attr is working as expected.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["parent"] = None t1 = Task(**kwargs) t2 = Task(**kwargs) t3 = Task(**kwargs) t2.parent = t1 t3.parent = t2 assert t3.parents == [t1, t2] def test_responsible_arg_is_skipped_for_a_root_task(setup_task_tests): """responsible list is an empty list if a root task have no responsible set.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("responsible") new_task = Task(**kwargs) assert new_task.responsible == [] def test_responsible_arg_is_skipped_for_a_non_root_task(setup_task_tests): """parent task's responsible is used if the responsible arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["name"] = "Root Task" root_task = Task(**kwargs) assert root_task.responsible == [data["test_user1"]] kwargs.pop("responsible") kwargs["parent"] = root_task kwargs["name"] = "Child Task" new_task = Task(**kwargs) assert new_task.responsible == root_task.responsible def test_responsible_arg_is_none_for_a_root_task(setup_task_tests): """RuntimeError raised if the responsible arg is None for a root task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["responsible"] = None new_task = Task(**kwargs) assert new_task.responsible == [] def test_responsible_arg_is_none_for_a_non_root_task(setup_task_tests): """parent tasks responsible is used if responsible arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["name"] = "Root Task" root_task = Task(**kwargs) assert root_task.responsible == [data["test_user1"]] kwargs["responsible"] = None kwargs["parent"] = root_task kwargs["name"] = "Child Task" new_task = Task(**kwargs) assert new_task.responsible == root_task.responsible def test_responsible_arg_not_a_list_instance(setup_task_tests): """TypeError raised if the responsible arg is not a List instance.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["responsible"] = "not a list" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_responsible_attr_not_a_list_instance(setup_task_tests): """TypeError raised if the responsible attr is not a List of User instances.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.responsible = "not a list of users" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_responsible_arg_is_not_a_list_of_user_instance(setup_task_tests): """TypeError raised if the responsible arg value is not a List of User instance.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["responsible"] = ["not a user instance"] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.responsible should only contain instances of " "stalker.models.auth.User, not str: 'not a user instance'" ) def test_responsible_attr_is_set_to_something_other_than_a_list_of_user_instance( setup_task_tests, ): """TypeError raised if the responsible attr is not list of Users.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.responsible = ["not a user instance"] assert str(cm.value) == ( "Task.responsible should only contain instances of " "stalker.models.auth.User, not str: 'not a user instance'" ) def test_responsible_arg_is_none_or_skipped_responsible_attr_comes_from_parents( setup_task_tests, ): """responsible arg is None or skipped, responsible attr value comes from parents.""" data = setup_task_tests # create two new tasks kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["responsible"] = None kwargs["parent"] = new_task new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) kwargs["parent"] = new_task2 new_task3 = Task(**kwargs) assert new_task1.responsible == [data["test_user1"]] assert new_task2.responsible == [data["test_user1"]] assert new_task3.responsible == [data["test_user1"]] new_task2.responsible = [data["test_user2"]] assert new_task1.responsible == [data["test_user1"]] assert new_task2.responsible == [data["test_user2"]] assert new_task3.responsible == [data["test_user1"]] def test_responsible_arg_is_none_or_skipped_responsible_attr_comes_from_the_first_parent_with_responsible( setup_task_tests, ): """responsible arg is None or skipped, responsible attr value comes from the first parent with responsible.""" data = setup_task_tests # create two new tasks kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["responsible"] = None kwargs["parent"] = new_task new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) kwargs["parent"] = new_task2 new_task3 = Task(**kwargs) new_task2.responsible = [data["test_user2"]] assert new_task1.responsible == [data["test_user1"]] assert new_task2.responsible == [data["test_user2"]] assert new_task3.responsible == [data["test_user2"]] def test_responsible_attr_is_set_to_none_responsible_attr_comes_from_parents( setup_task_tests, ): """responsible attr is None or skipped then its value comes from parents.""" data = setup_task_tests # create two new tasks kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) kwargs["parent"] = new_task2 new_task3 = Task(**kwargs) new_task1.responsible = [] new_task2.responsible = [] new_task3.responsible = [] new_task.responsible = [data["test_user2"]] assert new_task1.responsible == [data["test_user2"]] assert new_task2.responsible == [data["test_user2"]] assert new_task3.responsible == [data["test_user2"]] new_task2.responsible = [data["test_user1"]] assert new_task1.responsible == [data["test_user2"]] assert new_task2.responsible == [data["test_user1"]] assert new_task3.responsible == [data["test_user2"]] def test_responsible_attr_is_set_to_none_responsible_attr_comes_from_parents_immutable( setup_task_tests, ): """responsible attr is None or skipped then its value comes from parents.""" data = setup_task_tests # create two new tasks kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) kwargs["parent"] = new_task new_task1 = Task(**kwargs) kwargs["parent"] = new_task1 new_task2 = Task(**kwargs) kwargs["parent"] = new_task2 new_task3 = Task(**kwargs) new_task1.responsible = [] new_task2.responsible = [] new_task3.responsible = [] new_task.responsible = [data["test_user2"]] # set the attr now and expect the parent and the current tasks # responsible are divergent new_task1.responsible.append(data["test_user3"]) assert data["test_user3"] in new_task1.responsible assert data["test_user2"] in new_task1.responsible assert data["test_user3"] not in new_task.responsible assert data["test_user2"] in new_task.responsible def test_computed_start_also_sets_start(setup_task_tests): """computed_start also sets the start value of the task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task1 = Task(**kwargs) test_value = datetime.datetime(2013, 8, 2, 13, 0, tzinfo=pytz.utc) assert new_task1.start != test_value new_task1.computed_start = test_value assert new_task1.computed_start == test_value assert new_task1.start == test_value def test_computed_end_also_sets_end(setup_task_tests): """computed_end also sets the end value of the task.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) _ = Task(**kwargs) new_task1 = Task(**kwargs) test_value = datetime.datetime(2013, 8, 2, 13, 0, tzinfo=pytz.utc) assert new_task1.end != test_value new_task1.computed_end = test_value assert new_task1.computed_end == test_value assert new_task1.end == test_value # TODO: please add tests for _total_logged_seconds for leaf tasks def test_tickets_attr_is_a_read_only_property(setup_task_tests): """tickets attr is a read-only property.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.tickets = "some value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'tickets'", }.get(sys.version_info.minor, "property 'tickets' of 'Task' object has no setter") assert str(cm.value) == error_message def test_open_tickets_attr_is_a_read_only_property(setup_task_tests): """open_tickets attr is a read-only property.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.open_tickets = "some value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'open_tickets'", }.get( sys.version_info.minor, "property 'open_tickets' of 'Task' object has no setter" ) assert str(cm.value) == error_message def test_reviews_attr_is_an_empty_list_by_default(setup_task_tests): """reviews attr is an empty list by default.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) assert new_task.reviews == [] def test_reviews_is_not_a_list_of_review_instances(setup_task_tests): """reviews attr is not a List[Review] raises TypeError.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) test_value = [1234, "test value"] with pytest.raises(TypeError) as cm: new_task.reviews = test_value assert str(cm.value) == ( "Task.reviews should only contain instances of " "stalker.models.review.Review, not int: '1234'" ) def test_reviews_attr_is_validated_as_expected(setup_task_db_tests): """reviews attr is validated as expected.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) from stalker import Review assert new_task.reviews == [] new_review = Review( task=new_task, reviewer=data["test_user1"], ) assert new_task.reviews == [new_review] def test_status_is_wfd_for_a_newly_created_task_with_dependencies(setup_task_tests): """status for a newly created task is WFD by default if there are dependencies.""" data = setup_task_tests # try to trick it kwargs = copy.copy(data["kwargs"]) kwargs["status"] = data["status_cmpl"] # this is ignored new_task = Task(**kwargs) assert new_task.status == data["status_wfd"] def test_status_is_rts_for_a_newly_created_task_without_dependency(setup_task_tests): """status for a newly created task is RTS if there are no dependencies.""" data = setup_task_tests # try to trick it kwargs = copy.copy(data["kwargs"]) kwargs["status"] = data["status_cmpl"] kwargs.pop("depends_on") new_task = Task(**kwargs) assert new_task.status == data["status_rts"] def test_review_number_attr_is_read_only(setup_task_tests): """review_number attr is read-only.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(AttributeError) as cm: new_task.review_number = 12 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Task' object has no setter", 12: "property of 'Task' object has no setter", }.get( sys.version_info.minor, "property '_review_number_getter' of 'Task' object has no setter", ) assert str(cm.value) == error_message def test_review_number_attr_initializes_with_0(setup_task_tests): """review_number attr initializes to 0.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) assert new_task.review_number == 0 def test_task_dependency_auto_generates_task_dependency_object(setup_task_tests): """TaskDependency instance is automatically created if association proxy is used.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) new_task.depends_on.append(data["test_dependent_task1"]) task_depends = new_task.task_depends_on[0] assert task_depends.task == new_task assert task_depends.depends_on == data["test_dependent_task1"] def test_task_depends_on_is_an_empty_list(setup_task_tests): """task_depends_on is an empty list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.task_depends_on = [] def test_task_depends_on_is_not_a_task_dependency_object(setup_task_tests): """task_depends_on is not a TaskDependency object raises TypeError.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.task_depends_on.append("not a TaskDependency object.") assert str(cm.value) == ( "Task.task_depends_on should only contain instances of TaskDependency, " "not str: 'not a TaskDependency object.'" ) def test_alternative_resources_arg_is_skipped(setup_task_tests): """alternative_resources attr is an empty list if it is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("alternative_resources") new_task = Task(**kwargs) assert new_task.alternative_resources == [] def test_alternative_resources_arg_is_none(setup_task_tests): """alternative_resources attr is empty list if alternative_resources arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["alternative_resources"] = None new_task = Task(**kwargs) assert new_task.alternative_resources == [] def test_alternative_resources_attr_is_set_to_none(setup_task_tests): """TypeError raised if the alternative_resources attr is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.alternative_resources = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_alternative_resources_arg_is_not_a_list(setup_task_tests): """TypeError raised if the alternative_resources arg value is not a list.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["alternative_resources"] = data["test_user3"] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == "Incompatible collection type: User is not list-like" def test_alternative_resources_attr_is_not_a_list(setup_task_tests): """TypeError raised if the alternative_resources attr is not a list.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.alternative_resources = data["test_user3"] assert str(cm.value) == "Incompatible collection type: User is not list-like" def test_alternative_resources_arg_elements_are_not_user_instances( setup_task_tests, ): """TypeError raised if items in the alternative_resources arg are not all User.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["alternative_resources"] = ["not", 1, "user"] with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.alternative_resources should only contain instances of " "stalker.models.auth.User, not str: 'not'" ) def test_alternative_resources_attr_elements_are_not_all_user_instances( setup_task_tests, ): """TypeError raised if the items in the alternative_resources attr not all User.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.alternative_resources = ["not", 1, "user"] assert str(cm.value) == ( "Task.alternative_resources should only contain instances of " "stalker.models.auth.User, not str: 'not'" ) def test_alternative_resources_arg_is_working_as_expected(setup_task_tests): """alternative_resources arg is passed okay to the alternative_resources attr.""" data = setup_task_tests new_task = Task(**data["kwargs"]) assert sorted( [data["test_user3"], data["test_user4"], data["test_user5"]], key=lambda x: x.name, ) == sorted(new_task.alternative_resources, key=lambda x: x.name) def test_alternative_resources_attr_is_working_as_expected(setup_task_tests): """alternative_resources attr value can be correctly set.""" data = setup_task_tests new_task = Task(**data["kwargs"]) assert sorted(new_task.alternative_resources, key=lambda x: x.name) == sorted( [data["test_user3"], data["test_user4"], data["test_user5"]], key=lambda x: x.name, ) alternative_resources = [data["test_user4"], data["test_user5"]] new_task.alternative_resources = alternative_resources assert sorted(alternative_resources, key=lambda x: x.name) == sorted( new_task.alternative_resources, key=lambda x: x.name ) def test_allocation_strategy_arg_is_skipped(setup_task_tests): """default value is used for allocation_strategy attr if arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("allocation_strategy") new_task = Task(**kwargs) assert new_task.allocation_strategy == defaults.allocation_strategy[0] def test_allocation_strategy_arg_is_none(setup_task_tests): """default value is used for allocation_strategy attr if arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["allocation_strategy"] = None new_task = Task(**kwargs) assert new_task.allocation_strategy == defaults.allocation_strategy[0] def test_allocation_strategy_attr_is_set_to_none(setup_task_tests): """default value is used for the allocation_strategy if it is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.allocation_strategy = None assert new_task.allocation_strategy == defaults.allocation_strategy[0] def test_allocation_strategy_arg_is_not_a_str(setup_task_tests): """TypeError raised if the allocation_strategy arg value is not a str.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["allocation_strategy"] = 234 with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.allocation_strategy should be one of ['minallocated', " "'maxloaded', 'minloaded', 'order', 'random'], not int: '234'" ) def test_allocation_strategy_attr_is_set_to_a_value_other_than_str( setup_task_tests, ): """TypeError is raised if the allocation_strategy attr is not a str.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.allocation_strategy = 234 assert ( str(cm.value) == "Task.allocation_strategy should be one of ['minallocated', " "'maxloaded', 'minloaded', 'order', 'random'], not int: '234'" ) def test_allocation_strategy_arg_value_is_not_correct(setup_task_tests): """ValueError raised if the allocation_strategy arg value is not valid.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["allocation_strategy"] = "not in the list" with pytest.raises(ValueError) as cm: Task(**kwargs) assert ( str(cm.value) == "Task.allocation_strategy should be one of ['minallocated', " "'maxloaded', 'minloaded', 'order', 'random'], not 'not in the list'" ) def test_allocation_strategy_attr_value_is_not_correct(setup_task_tests): """ValueError raised if the allocation_strategy attr is set to an invalid value.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(ValueError) as cm: new_task.allocation_strategy = "not in the list" assert ( str(cm.value) == "Task.allocation_strategy should be one of ['minallocated', " "'maxloaded', 'minloaded', 'order', 'random'], not 'not in the list'" ) def test_allocation_strategy_arg_is_working_as_expected(setup_task_tests): """allocation_strategy arg value is passed to the allocation_strategy attr.""" data = setup_task_tests test_value = defaults.allocation_strategy[1] kwargs = copy.copy(data["kwargs"]) kwargs["allocation_strategy"] = test_value new_task = Task(**kwargs) assert test_value == new_task.allocation_strategy def test_allocation_strategy_attr_is_working_as_expected(setup_task_tests): """allocation_strategy attr value can be correctly set.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = defaults.allocation_strategy[1] assert new_task.allocation_strategy != test_value new_task.allocation_strategy = test_value assert new_task.allocation_strategy == test_value def test_computed_resources_attr_value_is_equal_to_the_resources_attr_for_a_new_task( setup_task_tests, ): """computed_resources attr is equal to the resources attr if a task initialized.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) # DBSession.commit() assert new_task.is_scheduled is False assert new_task.resources == new_task.computed_resources def test_computed_resources_attr_updates_with_resources_if_is_scheduled_is_false_append( setup_task_tests, ): """computed_resources attr updated with the resources if is_scheduled is False.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) assert new_task.is_scheduled is False test_value = [data["test_user3"], data["test_user5"]] assert new_task.resources != test_value assert new_task.computed_resources != test_value new_task.resources = test_value assert sorted(new_task.computed_resources, key=lambda x: x.name) == sorted( test_value, key=lambda x: x.name ) def test_computed_resources_attr_updates_with_resources_if_is_scheduled_is_false_remove( setup_task_tests, ): """computed_resources attr updated with the resources if is_scheduled is False.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) assert new_task.is_scheduled is False test_value = [data["test_user3"], data["test_user5"]] assert new_task.resources != test_value assert new_task.computed_resources != test_value new_task.resources = test_value assert sorted(new_task.computed_resources, key=lambda x: x.name) == sorted( test_value, key=lambda x: x.name ) def test_computed_resources_attr_dont_update_with_resources_if_is_scheduled_is_true( setup_task_tests, ): """computed_resources attr not updated with resources if is_scheduled is True.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) assert new_task.is_scheduled is False test_value = [data["test_user3"], data["test_user5"]] assert new_task.resources != test_value assert new_task.computed_resources != test_value # now set computed_start and computed_end to emulate a computation has # been done dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) assert new_task.is_scheduled is False new_task.computed_start = now new_task.computed_end = now + td(hours=1) assert new_task.is_scheduled is True new_task.resources = test_value assert new_task.computed_resources != test_value def test_computed_resources_is_not_a_user_instance(setup_task_tests): """computed_resource is not a User instance raises TypeError.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) with pytest.raises(TypeError) as cm: new_task.computed_resources.append("not a user") assert str(cm.value) == ( "Task.computed_resources should only contain instances of " "stalker.models.auth.User, not str: 'not a user'" ) def test_persistent_allocation_arg_is_skipped(setup_task_tests): """persistent_allocation defaults if the persistent_allocation arg is skipped.""" data = setup_task_tests data["kwargs"].pop("persistent_allocation") new_task = Task(**data["kwargs"]) assert new_task.persistent_allocation is True def test_persistent_allocation_arg_is_none(setup_task_tests): """persistent_allocation defaults if the persistent_allocation arg is None.""" data = setup_task_tests data["kwargs"]["persistent_allocation"] = None new_task = Task(**data["kwargs"]) assert new_task.persistent_allocation is True def test_persistent_allocation_attr_is_set_to_none(setup_task_tests): """persistent_allocation defaults if it is set to None.""" data = setup_task_tests new_task = Task(**data["kwargs"]) new_task.persistent_allocation = None assert new_task.persistent_allocation is True def test_persistent_allocation_arg_is_not_bool(setup_task_tests): """persistent_allocation is converted to bool if arg is not a bool value.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) test_value = "not a bool" kwargs["persistent_allocation"] = test_value new_task1 = Task(**kwargs) assert bool(test_value) == new_task1.persistent_allocation test_value = 0 kwargs["persistent_allocation"] = test_value new_task2 = Task(**kwargs) assert bool(test_value) == new_task2.persistent_allocation def test_persistent_allocation_attr_is_not_bool(setup_task_tests): """persistent_allocation attr is converted to a bool if is not set to a bool.""" data = setup_task_tests new_task = Task(**data["kwargs"]) test_value = "not a bool" new_task.persistent_allocation = test_value assert bool(test_value) == new_task.persistent_allocation test_value = 0 new_task.persistent_allocation = test_value assert bool(test_value) == new_task.persistent_allocation def test_persistent_allocation_arg_is_working_as_expected(setup_task_tests): """persistent_allocation arg is passed to the persistent_allocation attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["persistent_allocation"] = False new_task = Task(**kwargs) assert new_task.persistent_allocation is False def test_persistent_allocation_attr_is_working_as_expected(setup_task_tests): """persistent_allocation attr value can be correctly set.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) new_task.persistent_allocation = False assert new_task.persistent_allocation is False def test_path_attr_is_read_only(setup_task_tests): """path attr is read only.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_task.path = "some_path" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'path'", }.get(sys.version_info.minor, "property 'path' of 'Task' object has no setter") assert str(cm.value) == error_message def test_path_attr_raises_a_runtime_error_if_no_filename_template_found( setup_task_tests, ): """path attr raises RuntimeError if no FilenameTemplate w/ matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(RuntimeError) as cm: _ = new_task.path assert ( str(cm.value) == "There are no suitable FilenameTemplate (target_entity_type == " "'Task') defined in the Structure of the related Project " "instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to 'Task' and assign it " "to the `templates` attribute of the structure of the project" ) def test_path_attr_raises_a_runtime_error_if_no_matching_filename_template_found( setup_task_tests, ): """path attr raises RuntimeError if no FilenameTemplate w/ matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) ft = FilenameTemplate( name="Asset Filename Template", target_entity_type="Asset", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", filename="{{task.nice_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}', ) structure = Structure(name="Movie Project Structure", templates=[ft]) data["test_project1"].structure = structure with pytest.raises(RuntimeError) as cm: _ = new_task.path assert ( str(cm.value) == "There are no suitable FilenameTemplate (target_entity_type == " "'Task') defined in the Structure of the related Project " "instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to 'Task' and assign it " "to the `templates` attribute of the structure of the project" ) def test_path_attr_is_the_rendered_vers_of_the_related_filename_template_in_the_project( setup_task_tests, ): """path attr is the rendered from the FilenameTemplate with matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Task", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", filename="{{task.nice_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}', ) structure = Structure(name="Movie Project Structure", templates=[ft]) data["test_project1"].structure = structure assert new_task.path == "tp1/Modeling" data["test_project1"].structure = None def test_absolute_path_attr_is_read_only(setup_task_tests): """absolute_path is read only.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(AttributeError) as cm: new_task.absolute_path = "some_path" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'absolute_path'", }.get( sys.version_info.minor, "property 'absolute_path' of 'Task' object has no setter", ) assert str(cm.value) == error_message def test_absolute_path_attr_raises_a_runtime_error_if_no_filename_template_found( setup_task_tests, ): """absolute_path attr raises RuntimeError. if there are no FilenameTemplate with matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(RuntimeError) as cm: _ = new_task.absolute_path assert ( str(cm.value) == "There are no suitable FilenameTemplate (target_entity_type == " "'Task') defined in the Structure of the related Project " "instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to 'Task' and assign it " "to the `templates` attribute of the structure of the project" ) def test_absolute_path_attr_raises_a_runtime_error_if_no_matching_filename_template( setup_task_tests, ): """absolute_path attr raises RuntimeError. if there is no FilenameTemplate with matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) ft = FilenameTemplate( name="Asset Filename Template", target_entity_type="Asset", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", filename="{{task.nice_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}', ) structure = Structure(name="Movie Project Structure", templates=[ft]) data["test_project1"].structure = structure with pytest.raises(RuntimeError) as cm: _ = new_task.path assert ( str(cm.value) == "There are no suitable FilenameTemplate (target_entity_type == " "'Task') defined in the Structure of the related Project " "instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to 'Task' and assign it " "to the `templates` attribute of the structure of the project" ) def test_absolute_path_attr_is_rendered_version_of_related_filename_template_in_project( setup_task_tests, ): """absolute_path attr is rendered vers. of FilenameTemplate matching entity_type.""" data = setup_task_tests new_task = Task(**data["kwargs"]) ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Task", path="{{project.repository.path}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_v{{"%03d"|format(version.version_number)}}{{extension}}', ) structure = Structure(name="Movie Project Structure", templates=[ft]) data["test_project1"].structure = structure assert ( os.path.normpath( "{}/tp1/Modeling".format(data["test_project1"].repositories[0].path) ).replace("\\", "/") == new_task.absolute_path ) def test_good_arg_is_skipped(setup_task_tests): """good attr is None if good arg is skipped.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) try: kwargs.pop("good") except KeyError: pass new_task = Task(**kwargs) # DBSession.add(new_task) # DBSession.commit() assert new_task.good is None def test_good_arg_is_none(setup_task_tests): """good attr is None if good arg is None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["good"] = None new_task = Task(**kwargs) # DBSession.add(new_task) # DBSession.commit() assert new_task.good is None def test_good_attr_is_none(setup_task_tests): """it is possible to set the good attr to None.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["good"] = Good(name="Some Good") new_task = Task(**kwargs) # DBSession.add(new_task) # DBSession.commit() assert new_task.good is not None new_task.good = None assert new_task.good is None def test_good_arg_is_not_a_good_instance(setup_task_tests): """TypeError raised if the good arg value is not a Good instance.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["good"] = "not a good instance" with pytest.raises(TypeError) as cm: Task(**kwargs) assert str(cm.value) == ( "Task.good should be a stalker.models.budget.Good instance, " "not str: 'not a good instance'" ) def test_good_attr_is_not_a_good_instance(setup_task_tests): """TypeError raised if the good attr is not set to a Good instance.""" data = setup_task_tests new_task = Task(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_task.good = "not a good instance" assert str(cm.value) == ( "Task.good should be a stalker.models.budget.Good instance, " "not str: 'not a good instance'" ) def test_good_arg_is_working_as_expected(setup_task_tests): """good arg value is passed to the good attr.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) new_good = Good(name="Some Good") kwargs["good"] = new_good new_task = Task(**kwargs) assert new_task.good == new_good def test_good_attr_is_working_as_expected(setup_task_tests): """good attr value can be correctly set.""" data = setup_task_tests new_good = Good(name="Some Good") new_task = Task(**data["kwargs"]) assert new_task.good != new_good new_task.good = new_good assert new_task.good == new_good @pytest.mark.parametrize("schedule_unit", ["d", TimeUnit.Day]) def test_reschedule_on_a_container_task(setup_task_tests, schedule_unit): """_reschedule on a container task will return immediately.""" data = setup_task_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = None task_a = Task(**kwargs) task_b = Task(**kwargs) task_c = Task(**kwargs) task_b.parent = task_a task_a.parent = task_c start = task_a.start end = task_a.end assert task_a._reschedule(10, schedule_unit) is None assert task_a.start == start assert task_a.end == end @pytest.fixture(scope="function") def setup_task_db_tests(setup_postgresql_db): """stalker.models.task.Task class with a DB.""" data = dict() data["status_wfd"] = Status.query.filter_by(code="WFD").first() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_prev"] = Status.query.filter_by(code="PREV").first() data["status_hrev"] = Status.query.filter_by(code="HREV").first() data["status_drev"] = Status.query.filter_by(code="DREV").first() data["status_oh"] = Status.query.filter_by(code="OH").first() data["status_stop"] = Status.query.filter_by(code="STOP").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() data["task_status_list"] = StatusList.query.filter_by( target_entity_type="Task" ).first() data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234", ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234", ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234", ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234", ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234", ) data["test_project1"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], repositories=[data["test_repository"]], ) data["test_dependent_task1"] = Task( name="Dependent Task1", project=data["test_project1"], status_list=data["task_status_list"], responsible=[data["test_user1"]], ) data["test_dependent_task2"] = Task( name="Dependent Task2", project=data["test_project1"], status_list=data["task_status_list"], responsible=[data["test_user1"]], ) data["kwargs"] = { "name": "Modeling", "description": "A Modeling Task", "project": data["test_project1"], "priority": 500, "responsible": [data["test_user1"]], "resources": [data["test_user1"], data["test_user2"]], "alternative_resources": [ data["test_user3"], data["test_user4"], data["test_user5"], ], "allocation_strategy": "minloaded", "persistent_allocation": True, "watchers": [data["test_user3"]], "bid_timing": 4, "bid_unit": TimeUnit.Day, "schedule_timing": 1, "schedule_unit": TimeUnit.Day, "start": datetime.datetime(2013, 4, 8, 13, 0, tzinfo=pytz.utc), "end": datetime.datetime(2013, 4, 8, 18, 0, tzinfo=pytz.utc), "depends_on": [data["test_dependent_task1"], data["test_dependent_task2"]], "time_logs": [], "versions": [], "is_milestone": False, "status": 0, "status_list": data["task_status_list"], } # create a test Task DBSession.add_all( [ data["test_movie_project_type"], data["test_repository_type"], data["test_repository"], data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], data["test_project1"], data["test_dependent_task1"], data["test_dependent_task2"], ] ) DBSession.commit() return data def test_open_tickets_attr_is_working_as_expected(setup_task_db_tests): """open_tickets attr is working as expected.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) DBSession.add(new_task) DBSession.commit() # create ticket statuses stalker.db.setup.init() new_ticket1 = Ticket(project=new_task.project, links=[new_task]) DBSession.add(new_ticket1) DBSession.commit() new_ticket2 = Ticket(project=new_task.project, links=[new_task]) DBSession.add(new_ticket2) DBSession.commit() # close this ticket new_ticket2.resolve(None, "fixed") DBSession.commit() # add some other tickets new_ticket3 = Ticket( project=new_task.project, links=[], ) DBSession.add(new_ticket3) DBSession.commit() assert new_task.open_tickets == [new_ticket1] def test_tickets_attr_is_working_as_expected(setup_task_db_tests): """tickets attr is working as expected.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) new_task = Task(**kwargs) DBSession.add(new_task) DBSession.commit() # create ticket statuses stalker.db.setup.init() new_ticket1 = Ticket(project=new_task.project, links=[new_task]) DBSession.add(new_ticket1) DBSession.commit() new_ticket2 = Ticket(project=new_task.project, links=[new_task]) DBSession.add(new_ticket2) DBSession.commit() # add some other tickets new_ticket3 = Ticket(project=new_task.project, links=[]) DBSession.add(new_ticket3) DBSession.commit() assert sorted(new_task.tickets, key=lambda x: x.name) == sorted( [new_ticket1, new_ticket2], key=lambda x: x.name ) def test_percent_complete_attr_is_not_using_any_time_logs_for_a_duration_task( setup_task_db_tests, ): """percent_complete attr doesn't use any time log info if task is duration based.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] kwargs["schedule_model"] = ScheduleModel.Duration dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task = Task(**kwargs) new_task.computed_start = now + td(days=1) new_task.computed_end = now + td(days=2) resource1 = new_task.resources[0] _ = TimeLog( task=new_task, resource=resource1, start=now + td(days=1), end=now + td(days=2), ) assert new_task.percent_complete == 0 def test_percent_complete_attr_is_working_as_expected_for_a_container_task( setup_task_db_tests, ): """percent complete attr is working as expected for a container task.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] # remove dependencies just to make it # easy to create time logs after stalker # v0.2.6.1 new_task = Task(**kwargs) new_task.status = data["status_rts"] DBSession.add(new_task) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) defaults["timing_resolution"] = td(hours=1) defaults["daily_working_hours"] = 9 parent_task = Task(**kwargs) DBSession.add(parent_task) new_task.time_logs = [] resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog( task=new_task, resource=resource1, start=now - td(hours=4), end=now - td(hours=2), ) DBSession.add(tlog1) assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now - td(hours=4), end=now + td(hours=1), ) DBSession.add(tlog2) DBSession.commit() new_task.parent = parent_task DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 7 * 3600 assert new_task.schedule_seconds == 9 * 3600 assert new_task.percent_complete == pytest.approx(77.7777778) assert parent_task.percent_complete == pytest.approx(77.7777778) def test_percent_complete_attr_is_working_as_expected_for_a_container_task_with_no_data_1( setup_task_db_tests, ): """percent complete attr is working as expected for a container task with no data.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] # remove dependencies just to make it # easy to create time logs after stalker # v0.2.6.1 new_task = Task(**kwargs) new_task.status = data["status_rts"] DBSession.add(new_task) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) defaults["timing_resolution"] = td(hours=1) defaults["daily_working_hours"] = 9 parent_task = Task(**kwargs) DBSession.add(parent_task) new_task.time_logs = [] resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog( task=new_task, resource=resource1, start=now - td(hours=4), end=now - td(hours=2), ) DBSession.add(tlog1) assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now - td(hours=4), end=now + td(hours=1), ) DBSession.add(tlog2) DBSession.commit() new_task.parent = parent_task DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 7 * 3600 assert new_task.schedule_seconds == 9 * 3600 assert new_task.percent_complete == pytest.approx(77.7777778) parent_task._total_logged_seconds = None # parent_task._schedule_seconds = None assert parent_task.percent_complete == pytest.approx(77.7777778) def test_percent_complete_attr_is_working_as_expected_for_a_container_task_with_no_data_2( setup_task_db_tests, ): """percent complete attr is working as expected for a container task with no data.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] # remove dependencies just to make it # easy to create time logs after stalker # v0.2.6.1 new_task = Task(**kwargs) new_task.status = data["status_rts"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) defaults["timing_resolution"] = td(hours=1) defaults["daily_working_hours"] = 9 parent_task = Task(**kwargs) new_task.time_logs = [] resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog( task=new_task, resource=resource1, start=now - td(hours=4), end=now - td(hours=2), ) DBSession.add(tlog1) assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now - td(hours=4), end=now + td(hours=1), ) DBSession.add(tlog2) DBSession.commit() new_task.parent = parent_task DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 7 * 3600 assert new_task.schedule_seconds == 9 * 3600 assert new_task.percent_complete == pytest.approx(77.7777778) # parent_task._total_logged_seconds = None parent_task._schedule_seconds = None assert parent_task.percent_complete == pytest.approx(77.7777778) def test_percent_complete_attr_working_okay_for_a_task_w_effort_and_duration_children( setup_task_db_tests, ): """percent complete attr is okay with effort and duration based children tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] # remove dependencies just to make it # easy to create time logs after stalker # v0.2.6.1 dt = datetime.datetime td = datetime.timedelta defaults["timing_resolution"] = td(hours=1) defaults["daily_working_hours"] = 9 now = DateRangeMixin.round_time(dt.now(pytz.utc)) new_task1 = Task(**kwargs) new_task1.status = data["status_rts"] DBSession.add(new_task1) parent_task = Task(**kwargs) DBSession.add(parent_task) new_task1.time_logs = [] tlog1 = TimeLog( task=new_task1, resource=new_task1.resources[0], start=now - td(hours=4), end=now - td(hours=2), ) DBSession.add(tlog1) assert tlog1 in new_task1.time_logs tlog2 = TimeLog( task=new_task1, resource=new_task1.resources[1], start=now - td(hours=6), end=now - td(hours=1), ) DBSession.add(tlog2) DBSession.commit() # create a duration based task new_task2 = Task(**kwargs) new_task2.status = data["status_rts"] new_task2.schedule_model = ScheduleModel.Duration new_task2.start = now - td(days=1, hours=1) new_task2.end = now - td(hours=1) DBSession.add(new_task2) DBSession.commit() new_task1.parent = parent_task DBSession.commit() new_task2.parent = parent_task DBSession.commit() assert tlog2 in new_task1.time_logs assert new_task1.total_logged_seconds == 7 * 3600 assert new_task1.schedule_seconds == 9 * 3600 assert new_task1.percent_complete == pytest.approx(77.7777778) assert ( new_task2.total_logged_seconds == 24 * 3600 ) # 1 day for a duration task is 24 hours assert ( new_task2.schedule_seconds == 24 * 3600 ) # 1 day for a duration task is 24 hours assert new_task2.percent_complete == 100 # as if there are 9 * 3600 seconds of time logs entered to new_task2 assert parent_task.percent_complete == pytest.approx(93.939393939) def test_percent_complete_attr_is_okay_for_a_task_with_effort_and_length_based_children( setup_task_db_tests, ): """percent complete attr is okay with effort and length based children tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] # remove dependencies just to make it # easy to create time logs after stalker # v0.2.6.1 dt = datetime.datetime td = datetime.timedelta defaults["timing_resolution"] = td(hours=1) defaults["daily_working_hours"] = 9 now = DateRangeMixin.round_time(dt.now(pytz.utc)) new_task1 = Task(**kwargs) new_task1.status = data["status_rts"] DBSession.add(new_task1) parent_task = Task(**kwargs) DBSession.add(parent_task) new_task1.time_logs = [] tlog1 = TimeLog( task=new_task1, resource=new_task1.resources[0], start=now - td(hours=4), end=now - td(hours=2), ) DBSession.add(tlog1) assert tlog1 in new_task1.time_logs tlog2 = TimeLog( task=new_task1, resource=new_task1.resources[1], start=now - td(hours=6), end=now - td(hours=1), ) DBSession.add(tlog2) DBSession.commit() # create a length based task new_task2 = Task(**kwargs) new_task2.status = data["status_rts"] new_task2.schedule_model = ScheduleModel.Length new_task2.start = now - td(hours=10) new_task2.end = now - td(hours=1) DBSession.add(new_task2) DBSession.commit() new_task1.parent = parent_task DBSession.commit() new_task2.parent = parent_task DBSession.commit() assert tlog2 in new_task1.time_logs assert new_task1.total_logged_seconds == 7 * 3600 assert new_task1.schedule_seconds == 9 * 3600 assert new_task1.percent_complete == pytest.approx(77.7777778) assert ( new_task2.total_logged_seconds == 9 * 3600 ) # 1 day for a length task is 9 hours assert new_task2.schedule_seconds == 9 * 3600 # 1 day for a length task is 9 hours assert new_task2.percent_complete == 100 # as if there are 9 * 3600 seconds of time logs entered to new_task2 assert parent_task.percent_complete == pytest.approx(88.8888889) def test_percent_complete_attr_is_working_as_expected_for_a_leaf_task( setup_task_db_tests, ): """percent_complete attr is working as expected for a leaf task.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task.time_logs = [] # we can't use new_task.resources list directly between commits, # as apparently the order is changing after a TimeLog is created resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(tlog1) DBSession.commit() assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog2) DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 20 * 3600 assert new_task.percent_complete == 20.0 / 9.0 * 100.0 def test_time_logs_attr_is_working_as_expected(setup_task_db_tests): """time_log attr is working as expected.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task1 = Task(**kwargs) assert new_task1.depends_on == [] now = datetime.datetime.now(pytz.utc) dt = datetime.timedelta new_time_log1 = TimeLog( task=new_task1, resource=new_task1.resources[0], start=now + dt(100), end=now + dt(101), ) new_time_log2 = TimeLog( task=new_task1, resource=new_task1.resources[0], start=now + dt(101), end=now + dt(102), ) # create a new task kwargs["name"] = "New Task" new_task2 = Task(**kwargs) # create a new TimeLog for that task new_time_log3 = TimeLog( task=new_task2, resource=new_task2.resources[0], start=now + dt(102), end=now + dt(103), ) # logger.debug('DBSession.get(Task, 37): {}'.format(DBSession.get(Task, 37))) assert new_task2.depends_on == [] # check if everything is in place assert new_time_log1 in new_task1.time_logs assert new_time_log2 in new_task1.time_logs assert new_time_log3 in new_task2.time_logs # now move the time_log to test_task1 new_task1.time_logs.append(new_time_log3) # check if new_time_log3 is in test_task1 assert new_time_log3 in new_task1.time_logs # there needs to be a database session commit to remove the time_log # from the previous tasks time_logs attr assert new_time_log3 in new_task1.time_logs assert new_time_log3 not in new_task2.time_logs def test_total_logged_seconds_attr_is_correct_if_the_time_log_of_child_is_changed( setup_task_db_tests, ): """total_logged_seconds attr is correct if time log updated on children.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] _ = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) parent_task = Task(**kwargs) child_task = Task(**kwargs) parent_task.children.append(child_task) tlog1 = TimeLog( task=child_task, resource=child_task.resources[0], start=now, end=now + td(hours=8), ) assert parent_task.total_logged_seconds == 8 * 60 * 60 # now update the time log tlog1.end = now + td(hours=16) assert parent_task.total_logged_seconds == 16 * 60 * 60 def test_total_logged_seconds_is_the_sum_of_all_time_logs(setup_task_db_tests): """total_logged_seconds is the sum of all time_logs in hours.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) new_task.depends_on = [] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) new_task.time_logs = [] # apparently the new_task.resources order is changing between commits. resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(tlog1) DBSession.commit() assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog2) DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 20 * 3600 def test_total_logged_seconds_calls_update_schedule_info( setup_task_db_tests, ): """total_logged_seconds is the sum of all time_logs of the child tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") parent_task = Task(**kwargs) new_task.parent = parent_task new_task.time_logs = [] resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(tlog1) DBSession.commit() assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog2) DBSession.commit() # set the total_logged_seconds to None # so the getter calls the update_schedule_info parent_task._total_logged_seconds = None assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 20 * 3600 assert parent_task.total_logged_seconds == 20 * 3600 def test_update_schedule_info_on_a_container_of_containers_task( setup_task_db_tests, ): """total_logged_seconds is the sum of all time_logs of the child tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") parent_task = Task(**kwargs) root_task = Task(**kwargs) new_task.parent = parent_task parent_task.parent = root_task new_task.time_logs = [] # apparently the new_task.resources order is changing between commits. resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(new_task) DBSession.add(parent_task) DBSession.add(root_task) DBSession.add(tlog1) DBSession.commit() assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog2) DBSession.commit() # set the total_logged_seconds to None # so the getter calls the update_schedule_info root_task.update_schedule_info() def test_update_schedule_info_with_leaf_tasks( setup_task_db_tests, ): """total_logged_seconds is the sum of all time_logs of the child tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) new_task.update_schedule_info() def test_total_logged_seconds_is_the_sum_of_all_time_logs_of_children( setup_task_db_tests, ): """total_logged_seconds is the sum of all time_logs of the child tasks.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") parent_task = Task(**kwargs) new_task.parent = parent_task new_task.time_logs = [] # apparently the new_task.resources order is changing between commits. resource1 = new_task.resources[0] resource2 = new_task.resources[1] tlog1 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(tlog1) DBSession.commit() assert tlog1 in new_task.time_logs tlog2 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog2) DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 20 * 3600 assert parent_task.total_logged_seconds == 20 * 3600 def test_total_logged_seconds_is_the_sum_of_all_time_logs_of_children_deeper( setup_task_db_tests, ): """total_logged_seconds is the sum of all time_logs of the children (deeper).""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] new_task = Task(**kwargs) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) kwargs.pop("schedule_timing") kwargs.pop("schedule_unit") parent_task1 = Task(**kwargs) assert parent_task1.total_logged_seconds == 0 parent_task2 = Task(**kwargs) assert parent_task2.total_logged_seconds == 0 # create some other child child = Task(**kwargs) assert child.total_logged_seconds == 0 # create a TimeLog for that child tlog1 = TimeLog( task=child, resource=child.resources[0], start=now - td(hours=50), end=now - td(hours=40), ) DBSession.add(tlog1) DBSession.commit() assert child.total_logged_seconds == 10 * 3600 parent_task2.children.append(child) assert parent_task2.total_logged_seconds == 10 * 3600 # data["test_task1"].parent = parent_task parent_task1.children.append(new_task) assert parent_task1.total_logged_seconds == 0 parent_task1.parent = parent_task2 assert parent_task2.total_logged_seconds == 10 * 3600 # we can't use new_task.resources list directly between commits, # as apparently the order is changing after a TimeLog is created resource1 = new_task.resources[0] resource2 = new_task.resources[1] new_task.time_logs = [] tlog2 = TimeLog(task=new_task, resource=resource1, start=now, end=now + td(hours=8)) DBSession.add(tlog2) DBSession.commit() assert tlog2 in new_task.time_logs assert new_task.total_logged_seconds == 8 * 3600 assert parent_task1.total_logged_seconds == 8 * 3600 assert parent_task2.total_logged_seconds == 18 * 3600 tlog3 = TimeLog( task=new_task, resource=resource2, start=now, end=now + td(hours=12) ) DBSession.add(tlog3) DBSession.commit() assert new_task.total_logged_seconds == 20 * 3600 assert parent_task1.total_logged_seconds == 20 * 3600 assert parent_task2.total_logged_seconds == 30 * 3600 def test_remaining_seconds_is_working_as_expected(setup_task_db_tests): """remaining hours is working as expected.""" data = setup_task_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["depends_on"] = [] dt = datetime.datetime td = datetime.timedelta now = dt(2013, 4, 19, 10, 0, tzinfo=pytz.utc) kwargs["schedule_model"] = ScheduleModel.Effort # -------------- HOURS -------------- kwargs["schedule_timing"] = 10 kwargs["schedule_unit"] = TimeUnit.Hour new_task = Task(**kwargs) # create a time_log of 2 hours resource1 = new_task.resources[0] _ = TimeLog(task=new_task, start=now, duration=td(hours=2), resource=resource1) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # -------------- DAYS -------------- kwargs["schedule_timing"] = 23 kwargs["schedule_unit"] = TimeUnit.Day new_task = Task(**kwargs) # create a time_log of 5 days _ = TimeLog( task=new_task, start=now + td(hours=2), end=now + td(days=5), resource=resource1, ) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # add another 2 hours _ = TimeLog( task=new_task, start=now + td(days=5), duration=td(hours=2), resource=resource1, ) assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # ------------------- WEEKS ------------------ kwargs["schedule_timing"] = 2 kwargs["schedule_unit"] = TimeUnit.Week new_task = Task(**kwargs) # create a time_log of 2 hours tlog4 = TimeLog( task=new_task, start=now + td(days=6), duration=td(hours=2), resource=resource1, ) new_task.time_logs.append(tlog4) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # create a time_log of 1 week tlog5 = TimeLog( task=new_task, start=now + td(days=7), duration=td(weeks=1), resource=resource1, ) new_task.time_logs.append(tlog5) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # ------------------ MONTH ------------------- kwargs["schedule_timing"] = 2.5 kwargs["schedule_unit"] = TimeUnit.Month new_task = Task(**kwargs) # create a time_log of 1 month or 30 days, remaining_seconds can be # negative tlog6 = TimeLog( task=new_task, start=now + td(days=15), duration=td(days=30), resource=resource1, ) new_task.time_logs.append(tlog6) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) # ------------------ YEARS --------------------- kwargs["schedule_timing"] = 3.1 kwargs["schedule_unit"] = TimeUnit.Year new_task = Task(**kwargs) # create a time_log of 1 month or 30 days, remaining_seconds can be # negative tlog8 = TimeLog( task=new_task, start=now + td(days=55), duration=td(days=30), resource=resource1, ) new_task.time_logs.append(tlog8) # check assert ( new_task.remaining_seconds == new_task.schedule_seconds - new_task.total_logged_seconds ) def test_template_variables_for_non_shot_related_task(setup_task_db_tests): """_template_variables() for a non shot related task returns correct data.""" data = setup_task_db_tests task = Task(**data["kwargs"]) assert task._template_variables() == { "asset": None, "parent_tasks": [task], "project": data["test_project1"], "scene": None, "sequence": None, "shot": None, "task": task, "type": None, } ================================================ FILE: tests/models/test_task_dependency.py ================================================ # -*- coding: utf-8 -*- """Tests related to the TaskDependency class.""" import pytest from sqlalchemy.exc import IntegrityError, SAWarning from stalker import defaults from stalker import Project from stalker import Repository from stalker import Structure from stalker import Task from stalker import TaskDependency from stalker import User from stalker.db.session import DBSession from stalker.models.enum import ScheduleModel, TimeUnit from stalker.models.enum import DependencyTarget @pytest.fixture(scope="function") def setup_task_dependency_db_test(setup_postgresql_db): """set up the test TaskDependency class.""" data = dict() data["test_user1"] = User( name="Test User 1", login="testuser1", email="user1@test.com", password="secret" ) DBSession.add(data["test_user1"]) data["test_user2"] = User( name="Test User 2", login="testuser2", email="user2@test.com", password="secret" ) DBSession.add(data["test_user2"]) data["test_user3"] = User( name="Test User 3", login="testuser3", email="user3@test.com", password="secret" ) DBSession.add(data["test_user3"]) data["test_repo"] = Repository( name="Test Repository", code="TR", ) DBSession.add(data["test_repo"]) data["test_structure"] = Structure(name="test structure") DBSession.add(data["test_structure"]) data["test_project1"] = Project( name="Test Project 1", code="TP1", repository=data["test_repo"], structure=data["test_structure"], ) DBSession.add(data["test_project1"]) DBSession.commit() # create three Tasks data["test_task1"] = Task(name="Test Task 1", project=data["test_project1"]) DBSession.add(data["test_task1"]) data["test_task2"] = Task(name="Test Task 2", project=data["test_project1"]) DBSession.add(data["test_task2"]) data["test_task3"] = Task(name="Test Task 3", project=data["test_project1"]) DBSession.add(data["test_task3"]) DBSession.commit() data["kwargs"] = { "task": data["test_task1"], "depends_on": data["test_task2"], "dependency_target": "onend", "gap_timing": 0, "gap_unit": TimeUnit.Hour, "gap_model": ScheduleModel.Length, } return data def test_task_argument_is_skipped(setup_task_dependency_db_test): """no error raised if the task argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("task") TaskDependency(**data["kwargs"]) def test_task_argument_is_skipped_raises_error_on_commit(setup_task_dependency_db_test): """IntegrityError raised if the task arg is skipped and the session is committed.""" data = setup_task_dependency_db_test data["kwargs"].pop("task") new_dependency = TaskDependency(**data["kwargs"]) DBSession.add(new_dependency) with pytest.raises(IntegrityError) as cm: with pytest.warns(SAWarning) as _: DBSession.commit() assert ( '(psycopg2.errors.NotNullViolation) null value in column "task_id" of ' 'relation "Task_Dependencies" violates not-null constraint' in str(cm.value) ) def test_task_argument_is_not_a_task_instance(setup_task_dependency_db_test): """TypeError raised if the task arg is not a stalker.models.task.Task instance.""" data = setup_task_dependency_db_test data["kwargs"]["task"] = "Not a Task instance" with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert ( str(cm.value) == "TaskDependency.task should be and instance of " "stalker.models.task.Task, not str: 'Not a Task instance'" ) def test_task_attribute_is_not_a_task_instance(setup_task_dependency_db_test): """TypeError raised if the task attr is not a stalker.models.task.Task instance.""" data = setup_task_dependency_db_test new_dep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_dep.task = "not a task" assert ( str(cm.value) == "TaskDependency.task should be and instance of " "stalker.models.task.Task, not str: 'not a task'" ) def test_task_argument_is_working_as_expected(setup_task_dependency_db_test): """task argument value is correctly passed to task attribute.""" data = setup_task_dependency_db_test data["test_task1"].depends_on = [] new_dep = TaskDependency(**data["kwargs"]) assert new_dep.task == data["test_task1"] def test_depends_on_argument_is_skipped(setup_task_dependency_db_test): """no error raised if the depends_on argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("depends_on") TaskDependency(**data["kwargs"]) def test_depends_on_argument_is_skipped_raises_error_on_commit( setup_task_dependency_db_test, ): """IntegrityError raised if depends_on arg is skipped and session is committed.""" data = setup_task_dependency_db_test data["kwargs"].pop("depends_on") new_dependency = TaskDependency(**data["kwargs"]) DBSession.add(new_dependency) with pytest.raises(IntegrityError) as cm: with pytest.warns(SAWarning) as _: DBSession.commit() assert ( '(psycopg2.errors.NotNullViolation) null value in column "depends_on_id" of ' 'relation "Task_Dependencies" violates not-null constraint' in str(cm.value) ) def test_depends_on_argument_is_not_a_task_instance(setup_task_dependency_db_test): """TypeError raised if the depends_on arg is not a Task instance.""" data = setup_task_dependency_db_test data["kwargs"]["depends_on"] = "Not a Task instance" with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert ( str(cm.value) == "TaskDependency.depends_on should be and instance of " "stalker.models.task.Task, not str: 'Not a Task instance'" ) def test_depends_on_attribute_is_not_a_task_instance(setup_task_dependency_db_test): """TypeError raised if depends_on attr is not a Task instance.""" data = setup_task_dependency_db_test new_dep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_dep.depends_on = "not a task" assert ( str(cm.value) == "TaskDependency.depends_on should be and instance of " "stalker.models.task.Task, not str: 'not a task'" ) def test_depends_on_argument_is_working_as_expected(setup_task_dependency_db_test): """depends_on argument value is correctly passed to depends_on attribute.""" data = setup_task_dependency_db_test data["test_task1"].depends_on = [] new_dep = TaskDependency(**data["kwargs"]) assert new_dep.depends_on == data["test_task2"] def test_gap_timing_argument_is_skipped(setup_task_dependency_db_test): """gap_timing attribute value 0 if the gap_timing argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("gap_timing") tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_timing == 0 def test_gap_timing_argument_is_none(setup_task_dependency_db_test): """gap_timing attribute value 0 if the gap_timing argument value is None.""" data = setup_task_dependency_db_test data["kwargs"]["gap_timing"] = None tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_timing == 0 def test_gap_timing_attribute_is_set_to_none(setup_task_dependency_db_test): """gap_timing attribute value 0 if it is set to None.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) tdep.gap_timing = None assert tdep.gap_timing == 0 def test_gap_timing_argument_is_not_a_float(setup_task_dependency_db_test): """TypeError raised if the gap_timing argument value is not a float value.""" data = setup_task_dependency_db_test data["kwargs"]["gap_timing"] = "not a time delta" with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "TaskDependency.gap_timing should be an integer or float number showing the " "value of the gap timing of this TaskDependency, " "not str: 'not a time delta'" ) def test_gap_timing_attribute_is_not_a_float(setup_task_dependency_db_test): """TypeError raised if the gap_timing attribute value is not float.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: tdep.gap_timing = "not float" assert str(cm.value) == ( "TaskDependency.gap_timing should be an integer or float number showing the " "value of the gap timing of this TaskDependency, not str: 'not float'" ) def test_gap_timing_argument_is_working_as_expected(setup_task_dependency_db_test): """gap_timing argument value is correctly passed to the gap_timing attribute.""" data = setup_task_dependency_db_test test_value = 11 data["kwargs"]["gap_timing"] = test_value tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_timing == test_value def test_gap_timing_attribute_is_working_as_expected(setup_task_dependency_db_test): """gap_timing attribute is working as expected.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) test_value = 11 tdep.gap_timing = test_value assert tdep.gap_timing == test_value def test_gap_unit_argument_is_skipped(setup_task_dependency_db_test): """default value used if the gap_unit argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("gap_unit") tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_unit == TaskDependency.__default_schedule_unit__ def test_gap_unit_argument_is_none(setup_task_dependency_db_test): """default value used if the gap_unit argument is None.""" data = setup_task_dependency_db_test data["kwargs"]["gap_unit"] = None tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_unit == TaskDependency.__default_schedule_unit__ def test_gap_unit_attribute_is_none(setup_task_dependency_db_test): """default value used if the gap_unit attribute is set to None.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) tdep.gap_unit = None assert tdep.gap_unit == TaskDependency.__default_schedule_unit__ def test_gap_unit_argument_is_not_a_str_instance(setup_task_dependency_db_test): """TypeError raised if the gap_unit argument is not a str.""" data = setup_task_dependency_db_test data["kwargs"]["gap_unit"] = 231 with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '231'" ) def test_gap_unit_attribute_is_not_a_str_instance(setup_task_dependency_db_test): """TypeError raised if the gap_unit attribute is not a str.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: tdep.gap_unit = 2342 assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not int: '2342'" ) def test_gap_unit_argument_value_is_not_in_the_enum_list(setup_task_dependency_db_test): """ValueError raised if the gap_unit arg value is not valid.""" data = setup_task_dependency_db_test data["kwargs"]["gap_unit"] = "not in the list" with pytest.raises(ValueError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'not in the list'" ) def test_gap_unit_attribute_value_is_not_in_the_enum_list( setup_task_dependency_db_test, ): """ValueError raised if the gap_unit attr is not valid.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(ValueError) as cm: tdep.gap_unit = "not in the list" assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'not in the list'" ) @pytest.mark.parametrize("gap_unit", ["y", TimeUnit.Year]) def test_gap_unit_argument_is_working_as_expected( setup_task_dependency_db_test, gap_unit ): """gap_unit argument value is correctly passed to the gap_unit attribute on init.""" data = setup_task_dependency_db_test test_value = gap_unit data["kwargs"]["gap_unit"] = test_value tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_unit == TimeUnit.Year @pytest.mark.parametrize("gap_unit", ["w", TimeUnit.Week]) def test_gap_unit_attribute_is_working_as_expected( setup_task_dependency_db_test, gap_unit ): """gap_unit attribute is working as expected.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) test_value = gap_unit assert tdep.gap_unit != TimeUnit.to_unit(test_value) tdep.gap_unit = test_value assert tdep.gap_unit == TimeUnit.to_unit(test_value) def test_gap_model_argument_is_skipped(setup_task_dependency_db_test): """default value used if the gap_model argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("gap_model") tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_model == ScheduleModel.to_model( defaults.task_dependency_gap_models[0] ) def test_gap_model_argument_is_none(setup_task_dependency_db_test): """default value used if the gap_model argument is None.""" data = setup_task_dependency_db_test data["kwargs"]["gap_model"] = None tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_model == ScheduleModel.to_model( defaults.task_dependency_gap_models[0] ) def test_gap_model_attribute_is_none(setup_task_dependency_db_test): """default value used if the gap_model attribute is set to None.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) tdep.gap_model = None assert tdep.gap_model == ScheduleModel.to_model( defaults.task_dependency_gap_models[0] ) def test_gap_model_argument_is_not_a_str_instance(setup_task_dependency_db_test): """TypeError raised if the gap_model argument is not a str.""" data = setup_task_dependency_db_test data["kwargs"]["gap_model"] = 231 with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not int: '231'" ) def test_gap_model_attribute_is_not_a_str_instance(setup_task_dependency_db_test): """TypeError raised if the gap_model attribute is not a str.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: tdep.gap_model = 2342 assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not int: '2342'" ) def test_gap_model_argument_value_is_not_in_the_enum_list( setup_task_dependency_db_test, ): """ValueError raised if the gap_model arg is not valid.""" data = setup_task_dependency_db_test data["kwargs"]["gap_model"] = "not in the list" with pytest.raises(ValueError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not 'not in the list'" ) def test_gap_model_attribute_value_is_not_in_the_enum_list( setup_task_dependency_db_test, ): """ValueError raised if the gap_model attr is not valid.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(ValueError) as cm: tdep.gap_model = "not in the list" assert str(cm.value) == ( "model should be a ScheduleModel enum value or one of ['Effort', " "'Duration', 'Length', 'effort', 'duration', 'length'], " "not 'not in the list'" ) @pytest.mark.parametrize("gap_model", ["duration", ScheduleModel.Duration]) def test_gap_model_argument_is_working_as_expected( setup_task_dependency_db_test, gap_model ): """gap_model arg is passed okay to the gap_model attr on init.""" data = setup_task_dependency_db_test test_value = gap_model data["kwargs"]["gap_model"] = test_value tdep = TaskDependency(**data["kwargs"]) assert tdep.gap_model == ScheduleModel.to_model(test_value) @pytest.mark.parametrize("gap_model", ["duration", ScheduleModel.Duration]) def test_gap_model_attribute_is_working_as_expected( setup_task_dependency_db_test, gap_model ): """gap_model attribute is working as expected.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) test_value = gap_model assert tdep.gap_model != ScheduleModel.to_model(test_value) tdep.gap_model = test_value assert tdep.gap_model == ScheduleModel.to_model(test_value) def test_dependency_target_argument_is_skipped(setup_task_dependency_db_test): """default value used if the dependency_target argument is skipped.""" data = setup_task_dependency_db_test data["kwargs"].pop("dependency_target") tdep = TaskDependency(**data["kwargs"]) assert tdep.dependency_target == DependencyTarget.to_target( defaults.task_dependency_targets[0] ) def test_dependency_target_argument_is_none(setup_task_dependency_db_test): """default value used if the dependency_target argument is None.""" data = setup_task_dependency_db_test data["kwargs"]["dependency_target"] = None tdep = TaskDependency(**data["kwargs"]) assert tdep.dependency_target == DependencyTarget.to_target( defaults.task_dependency_targets[0] ) def test_dependency_target_attribute_is_none(setup_task_dependency_db_test): """default value used if the dependency_target attribute is set to None.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) tdep.dependency_target = None assert tdep.dependency_target == DependencyTarget.to_target( defaults.task_dependency_targets[0] ) def test_dependency_target_argument_is_not_a_str_instance( setup_task_dependency_db_test, ): """TypeError raised if the dependency_target argument is not a str.""" data = setup_task_dependency_db_test data["kwargs"]["dependency_target"] = 0 with pytest.raises(TypeError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', " "'OnEnd', 'onstart', 'onend'], not int: '0'" ) def test_dependency_target_attribute_is_not_a_str_instance( setup_task_dependency_db_test, ): """TypeError raised if the dependency_target attribute is not a str.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(TypeError) as cm: tdep.dependency_target = 0 assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', " "'OnEnd', 'onstart', 'onend'], not int: '0'" ) def test_dependency_target_argument_value_is_not_in_the_enum_list( setup_task_dependency_db_test, ): """ValueError raised if dependency_target arg is not valid.""" data = setup_task_dependency_db_test data["kwargs"]["dependency_target"] = "not in the list" with pytest.raises(ValueError) as cm: TaskDependency(**data["kwargs"]) assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', " "'OnEnd', 'onstart', 'onend'], not 'not in the list'" ) def test_dependency_target_attribute_value_is_not_in_the_enum_list( setup_task_dependency_db_test, ): """ValueError raised if the dependency_target attr is not valid.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) with pytest.raises(ValueError) as cm: tdep.dependency_target = "not in the list" assert str(cm.value) == ( "target should be a DependencyTarget enum value or one of ['OnStart', " "'OnEnd', 'onstart', 'onend'], not 'not in the list'" ) @pytest.mark.parametrize( "target", [ "onstart", "onend", DependencyTarget.OnStart, DependencyTarget.OnEnd, ], ) def test_dependency_target_argument_is_working_as_expected( setup_task_dependency_db_test, target ): """dependency_target arg is passed okay to the dependency_target attr on init.""" data = setup_task_dependency_db_test data["kwargs"]["dependency_target"] = target tdep = TaskDependency(**data["kwargs"]) assert tdep.dependency_target == DependencyTarget.to_target(target) @pytest.mark.parametrize( "target", [ "onstart", "onend", DependencyTarget.OnStart, DependencyTarget.OnEnd, ], ) def test_dependency_target_attribute_is_working_as_expected( setup_task_dependency_db_test, target ): """dependency_target attribute is working as expected.""" data = setup_task_dependency_db_test tdep = TaskDependency(**data["kwargs"]) onstart = target tdep.dependency_target = onstart assert tdep.dependency_target == DependencyTarget.to_target(onstart) ================================================ FILE: tests/models/test_task_juggler_scheduler.py ================================================ # -*- coding: utf-8 -*- """Tests for the stalker.models.scheduler.TaskJugglerScheduler class.""" import datetime import os import tempfile import sys import jinja2 import pytest import pytz import stalker from stalker import TaskJugglerScheduler from stalker import Department from stalker import User from stalker import Repository from stalker import Status from stalker import Studio from stalker import Project from stalker import Task from stalker import TimeLog from stalker.db.session import DBSession from stalker.models.enum import TimeUnit from stalker.models.enum import ScheduleModel @pytest.fixture(scope="function") def monkeypatch_tj3(): """patch tj3 command with a python script that returns an error message.""" default_tj3_command_path = stalker.defaults.tj_command patched_tj3_command_path = tempfile.mktemp("patched_tj3_command") # create the script with open(patched_tj3_command_path, "w") as f: f.write( f"#!{sys.executable}\n" "# -*- coding: utf-8 -*-\n" "import sys\n" 'sys.exit("some random exit message")\n' ) # make it executable os.chmod(patched_tj3_command_path, 0o777) stalker.defaults["tj_command"] = patched_tj3_command_path yield stalker.defaults["tj_command"] = default_tj3_command_path # and clean the temp file os.remove(patched_tj3_command_path) @pytest.fixture(scope="function") def setup_tsk_juggler_scheduler_db_tests(setup_postgresql_db): """Set up tests for the TaskJugglerScheduler class.""" data = dict() # create departments data["test_dep1"] = Department(name="Dep1") data["test_dep2"] = Department(name="Dep2") # create resources data["test_user1"] = User( login="user1", name="User1", email="user1@users.com", password="1234", departments=[data["test_dep1"]], ) DBSession.add(data["test_user1"]) data["test_user2"] = User( login="user2", name="User2", email="user2@users.com", password="1234", departments=[data["test_dep1"]], ) DBSession.add(data["test_user2"]) data["test_user3"] = User( login="user3", name="User3", email="user3@users.com", password="1234", departments=[data["test_dep2"]], ) DBSession.add(data["test_user3"]) data["test_user4"] = User( login="user4", name="User4", email="user4@users.com", password="1234", departments=[data["test_dep2"]], ) DBSession.add(data["test_user4"]) # user with two departments data["test_user5"] = User( login="user5", name="User5", email="user5@users.com", password="1234", departments=[data["test_dep1"], data["test_dep2"]], ) DBSession.add(data["test_user5"]) # user with no departments data["test_user6"] = User( login="user6", name="User6", email="user6@users.com", password="1234" ) DBSession.add(data["test_user6"]) # repository data["test_repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) DBSession.add(data["test_repo"]) # statuses data["test_status1"] = Status(name="Status 1", code="STS1") data["test_status2"] = Status(name="Status 2", code="STS2") data["test_status3"] = Status(name="Status 3", code="STS3") data["test_status4"] = Status(name="Status 4", code="STS4") data["test_status5"] = Status(name="Status 5", code="STS5") DBSession.add_all( [ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ] ) # create one project data["test_proj1"] = Project( name="Test Project 1", code="TP1", repository=data["test_repo"], start=datetime.datetime(2013, 4, 4, tzinfo=pytz.utc), end=datetime.datetime(2013, 5, 4, tzinfo=pytz.utc), ) DBSession.add(data["test_proj1"]) data["test_proj1"].now = datetime.datetime(2013, 4, 4, tzinfo=pytz.utc) # create two tasks with the same resources data["test_task1"] = Task( name="Task1", project=data["test_proj1"], resources=[data["test_user1"], data["test_user2"]], alternative_resources=[ data["test_user3"], data["test_user4"], data["test_user5"], ], schedule_model=ScheduleModel.Effort, schedule_timing=50, schedule_unit=TimeUnit.Hour, ) DBSession.add(data["test_task1"]) data["test_task2"] = Task( name="Task2", project=data["test_proj1"], resources=[data["test_user1"], data["test_user2"]], alternative_resources=[ data["test_user3"], data["test_user4"], data["test_user5"], ], depends_on=[data["test_task1"]], schedule_model=ScheduleModel.Effort, schedule_timing=60, schedule_unit=TimeUnit.Hour, priority=800, ) DBSession.save(data["test_task2"]) return data def test_tjp_file_is_created(setup_tsk_juggler_scheduler_db_tests): """tjp file is correctly created.""" data = setup_tsk_juggler_scheduler_db_tests # create the scheduler tjp_sched = TaskJugglerScheduler() tjp_sched.projects = [data["test_proj1"]] tjp_sched._create_tjp_file() tjp_sched._create_tjp_file_content() tjp_sched._fill_tjp_file() # check assert os.path.exists(tjp_sched.tjp_file_full_path) # clean up the test tjp_sched._clean_up() def test_tjp_file_content_is_correct(setup_tsk_juggler_scheduler_db_tests): """tjp file content is correct.""" data = setup_tsk_juggler_scheduler_db_tests # enter a couple of time_logs tlog1 = TimeLog( resource=data["test_user1"], task=data["test_task1"], start=datetime.datetime(2013, 4, 16, 6, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc), ) DBSession.add(tlog1) DBSession.commit() tjp_sched = TaskJugglerScheduler() test_studio = Studio( name="Test Studio", timing_resolution=datetime.timedelta(minutes=30) ) test_studio.daily_working_hours = 9 test_studio.id = 564 test_studio.start = datetime.datetime(2013, 4, 16, 0, 7, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc) test_studio.now = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) tjp_sched.studio = test_studio tjp_sched._create_tjp_file() tjp_sched._create_tjp_file_content() assert TimeLog.query.all() != [] expected_tjp_template = jinja2.Template( """# Generated By Stalker v{{stalker.__version__}} project Studio_564 "Studio_564" 2013-04-16 - 2013-06-30 { timingresolution 30min now 2013-04-16-00:00 dailyworkinghours 9 weekstartsmonday workinghours mon 09:00 - 18:00 workinghours tue 09:00 - 18:00 workinghours wed 09:00 - 18:00 workinghours thu 09:00 - 18:00 workinghours fri 09:00 - 18:00 workinghours sat off workinghours sun off timeformat "%Y-%m-%d" scenario plan "Plan" trackingscenario plan } # resources resource resources "Resources" { resource User_3 "User_3" { efficiency 1.0 } resource User_{{user1.id}} "User_{{user1.id}}" { efficiency 1.0 } resource User_{{user2.id}} "User_{{user2.id}}" { efficiency 1.0 } resource User_{{user3.id}} "User_{{user3.id}}" { efficiency 1.0 } resource User_{{user4.id}} "User_{{user4.id}}" { efficiency 1.0 } resource User_{{user5.id}} "User_{{user5.id}}" { efficiency 1.0 } resource User_{{user6.id}} "User_{{user6.id}}" { efficiency 1.0 } } # tasks task Project_{{proj.id}} "Project_{{proj.id}}" { task Task_{{task1.id}} "Task_{{task1.id}}" { effort 50.0h allocate User_{{user1.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent }, User_{{user2.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent } booking User_{{user1.id}} 2013-04-16-06:00:00 - 2013-04-16-09:00:00 { overtime 2 } } task Task_{{task2.id}} "Task_{{task2.id}}" { priority 800 depends Project_{{proj.id}}.Task_{{task1.id}} {onend} effort 60.0h allocate User_{{user1.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent }, User_{{user2.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent } } } # reports taskreport breakdown "{{csv_path}}"{ formats csv timeformat "%Y-%m-%d-%H:%M" columns id, start, end } """ ) expected_tjp_content = expected_tjp_template.render( { "stalker": stalker, "studio": test_studio, "csv_path": tjp_sched.temp_file_name, "user1": data["test_user1"], "user2": data["test_user2"], "user3": data["test_user3"], "user4": data["test_user4"], "user5": data["test_user5"], "user6": data["test_user6"], "proj": data["test_proj1"], "task1": data["test_task1"], "task2": data["test_task2"], } ) data["maxDiff"] = None tjp_content = tjp_sched.tjp_content # print tjp_content tjp_sched._clean_up() # print("Expected:") # print("---------") # print(expected_tjp_content) # print('----------------') # print("Result:") # print("-------") # print(tjp_content) assert tjp_content == expected_tjp_content def test_schedule_will_not_work_if_the_studio_attribute_is_None( setup_tsk_juggler_scheduler_db_tests, ): """TypeError raised if the studio attribute is None.""" tjp_sched = TaskJugglerScheduler() tjp_sched.studio = None with pytest.raises(TypeError) as cm: tjp_sched.schedule() assert ( str(cm.value) == "TaskJugglerScheduler.studio should be an instance of " "stalker.models.studio.Studio, not NoneType: 'None'" ) @pytest.mark.skipif(sys.platform == "win32", reason="Runs in Linux/macOS for now!") def test_schedule_will_raise_tj3_command_errors_as_a_runtime_error( setup_tsk_juggler_scheduler_db_tests, monkeypatch_tj3 ): data = setup_tsk_juggler_scheduler_db_tests # create a dummy Project to schedule dummy_project = Project( name="Dummy Project", code="DP", repository=data["test_repo"] ) dt1 = Task( name="Dummy Task 1", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user1"]], ) dt2 = Task( name="Dummy Task 2", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user2"]], ) DBSession.add_all([dummy_project, dt1, dt2]) DBSession.commit() tjp_sched = TaskJugglerScheduler(compute_resources=True, projects=[dummy_project]) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) DBSession.commit() tjp_sched.studio = test_studio # update the defaults.tj_command to false so that it returns an error with pytest.raises(RuntimeError) as cm: tjp_sched.schedule() assert str(cm.value) == "some random exit message" def test_tasks_are_correctly_scheduled(setup_tsk_juggler_scheduler_db_tests): """tasks are correctly scheduled.""" data = setup_tsk_juggler_scheduler_db_tests tjp_sched = TaskJugglerScheduler(compute_resources=True) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) tjp_sched.studio = test_studio tjp_sched.schedule() DBSession.commit() # check if the task and project timings are all adjusted assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_end ) possible_resources = [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_task1"].computed_start ) assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task1"].computed_end ) assert len(data["test_task1"].computed_resources) == 2 assert data["test_task1"].computed_resources[0] in possible_resources assert data["test_task1"].computed_resources[1] in possible_resources assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task2"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_task2"].computed_end ) assert len(data["test_task2"].computed_resources) == 2 assert data["test_task2"].computed_resources[0] in possible_resources assert data["test_task2"].computed_resources[1] in possible_resources def test_tasks_are_correctly_scheduled_if_compute_resources_is_False( setup_tsk_juggler_scheduler_db_tests, ): """tasks are correctly scheduled if the compute_resources is False.""" data = setup_tsk_juggler_scheduler_db_tests tjp_sched = TaskJugglerScheduler(compute_resources=False) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) tjp_sched.studio = test_studio tjp_sched.schedule() DBSession.commit() # check if the task and project timings are all adjusted assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_end ) assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_task1"].computed_start ) assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task1"].computed_end ) assert len(data["test_task1"].computed_resources) == 2 assert data["test_task1"].computed_resources[0] in [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] assert data["test_task1"].computed_resources[1] in [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task2"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_task2"].computed_end ) assert len(data["test_task2"].computed_resources) == 2 assert data["test_task2"].computed_resources[0] in [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] assert data["test_task2"].computed_resources[1] in [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] def test_tasks_are_correctly_scheduled_if_compute_resources_is_True( setup_tsk_juggler_scheduler_db_tests, ): """tasks are correctly scheduled if the compute_resources is True.""" data = setup_tsk_juggler_scheduler_db_tests tjp_sched = TaskJugglerScheduler(compute_resources=True) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) tjp_sched.studio = test_studio tjp_sched.schedule() DBSession.commit() possible_resources = [ data["test_user1"], data["test_user2"], data["test_user3"], data["test_user4"], data["test_user5"], ] # check if the task and project timings are all adjusted assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_proj1"].computed_end ) assert ( datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) == data["test_task1"].computed_start ) assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task1"].computed_end ) assert len(data["test_task1"].computed_resources) == 2 assert data["test_task1"].computed_resources[0] in possible_resources assert data["test_task1"].computed_resources[1] in possible_resources assert ( datetime.datetime(2013, 4, 18, 16, 0, tzinfo=pytz.utc) == data["test_task2"].computed_start ) assert ( datetime.datetime(2013, 4, 24, 10, 0, tzinfo=pytz.utc) == data["test_task2"].computed_end ) assert data["test_task2"].computed_resources[0] in possible_resources assert data["test_task2"].computed_resources[1] in possible_resources def test_tasks_of_given_projects_are_correctly_scheduled( setup_tsk_juggler_scheduler_db_tests, ): """tasks of given projects are correctly scheduled.""" data = setup_tsk_juggler_scheduler_db_tests # create a dummy Project to schedule dummy_project = Project( name="Dummy Project", code="DP", repository=data["test_repo"] ) dt1 = Task( name="Dummy Task 1", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user1"]], ) dt2 = Task( name="Dummy Task 2", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user2"]], ) DBSession.add_all([dummy_project, dt1, dt2]) DBSession.commit() tjp_sched = TaskJugglerScheduler(compute_resources=True, projects=[dummy_project]) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) DBSession.commit() tjp_sched.studio = test_studio tjp_sched.schedule() DBSession.commit() # check if the task and project timings are all adjusted assert data["test_proj1"].computed_start is None assert data["test_proj1"].computed_end is None assert data["test_task1"].computed_start is None assert data["test_task1"].computed_end is None assert sorted(data["test_task1"].computed_resources, key=lambda x: x.id) == sorted( [ data["test_user1"], data["test_user2"], ], key=lambda x: x.id, ) assert data["test_task2"].computed_start is None assert data["test_task2"].computed_end is None assert sorted(data["test_task2"].computed_resources, key=lambda x: x.id) == sorted( [ data["test_user1"], data["test_user2"], ], key=lambda x: x.id, ) assert dt1.computed_start == datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) assert dt1.computed_end == datetime.datetime(2013, 4, 16, 13, 0, tzinfo=pytz.utc) assert dt2.computed_start == datetime.datetime(2013, 4, 16, 9, 0, tzinfo=pytz.utc) assert dt2.computed_end == datetime.datetime(2013, 4, 16, 13, 0, tzinfo=pytz.utc) def test_csv_file_does_not_exist_returns_without_scheduling( setup_tsk_juggler_scheduler_db_tests, monkeypatch ): """csv_file_full_path doesn't exist will return without schedule data parsed.""" data = setup_tsk_juggler_scheduler_db_tests # create a dummy Project to schedule dummy_project = Project( name="Dummy Project", code="DP", repository=data["test_repo"] ) dt1 = Task( name="Dummy Task 1", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user1"]], ) dt2 = Task( name="Dummy Task 2", project=dummy_project, schedule_timing=4, schedule_unit=TimeUnit.Hour, resources=[data["test_user2"]], ) DBSession.add_all([dummy_project, dt1, dt2]) DBSession.commit() tjp_sched = TaskJugglerScheduler(compute_resources=True, projects=[dummy_project]) test_studio = Studio( name="Test Studio", now=datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) ) test_studio.start = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 4, 30, 0, 0, tzinfo=pytz.utc) test_studio.daily_working_hours = 9 DBSession.add(test_studio) DBSession.commit() tjp_sched.studio = test_studio # trick _arse_csv_file() to think that the csv file doesn't exist import os called = [] def patched_exists(path): if path == tjp_sched.csv_file_full_path: called.append(path) return False return os.path.exists(path) monkeypatch.setattr("stalker.models.schedulers.os.path.exists", patched_exists) assert len(called) == 0 # should run without any errors tjp_sched.schedule() assert len(called) > 0 assert tjp_sched.csv_file_full_path in called def test_projects_argument_is_skipped(setup_tsk_juggler_scheduler_db_tests): """projects attribute an empty list if the projects argument is skipped.""" tjp_sched = TaskJugglerScheduler(compute_resources=True) assert tjp_sched.projects == [] def test_projects_argument_is_None(setup_tsk_juggler_scheduler_db_tests): """projects attribute an empty list if the projects argument is None.""" tjp_sched = TaskJugglerScheduler(compute_resources=True, projects=None) assert tjp_sched.projects == [] def test_projects_attribute_is_set_to_None(setup_tsk_juggler_scheduler_db_tests): """projects attribute an empty list if it is set to None.""" tjp_sched = TaskJugglerScheduler(compute_resources=True) tjp_sched.projects = None assert tjp_sched.projects == [] def test_projects_argument_is_not_a_list(setup_tsk_juggler_scheduler_db_tests): """TypeError raised if the projects argument value is not a list.""" with pytest.raises(TypeError) as cm: TaskJugglerScheduler(compute_resources=True, projects="not a list of projects") assert str(cm.value) == ( "TaskJugglerScheduler.projects should only contain instances of " "stalker.models.project.Project, not str: 'not a list of projects'" ) def test_projects_attribute_is_not_a_list(setup_tsk_juggler_scheduler_db_tests): """TypeError raised if the projects attribute not a list.""" tjp = TaskJugglerScheduler(compute_resources=True) with pytest.raises(TypeError) as cm: tjp.projects = "not a list of projects" assert str(cm.value) == ( "TaskJugglerScheduler.projects should only contain instances of " "stalker.models.project.Project, not str: 'not a list of projects'" ) def test_projects_argument_is_not_a_list_of_all_projects(): """TypeError raised if the items in the projects arg are not all Projects.""" with pytest.raises(TypeError) as cm: TaskJugglerScheduler( compute_resources=True, projects=["not", 1, [], "of", "projects"] ) assert str(cm.value) == ( "TaskJugglerScheduler.projects should only contain instances of " "stalker.models.project.Project, not str: 'not'" ) def test_projects_attribute_is_not_list_of_all_projects(): """TypeError raised if the items in the projects attr is not all Projects.""" tjp = TaskJugglerScheduler(compute_resources=True) with pytest.raises(TypeError) as cm: tjp.projects = ["not", 1, [], "of", "projects"] assert str(cm.value) == ( "TaskJugglerScheduler.projects should only contain instances of " "stalker.models.project.Project, not str: 'not'" ) def test_projects_argument_is_working_as_expected(setup_tsk_juggler_scheduler_db_tests): """projects argument value is correctly passed to the projects attribute.""" data = setup_tsk_juggler_scheduler_db_tests dp1 = Project(name="Dummy Project", code="DP", repository=data["test_repo"]) dp2 = Project(name="Dummy Project", code="DP", repository=data["test_repo"]) tjp = TaskJugglerScheduler(compute_resources=True, projects=[dp1, dp2]) assert tjp.projects == [dp1, dp2] def test_projects_attribute_is_working_as_expected( setup_tsk_juggler_scheduler_db_tests, ): """projects attribute is working as expected.""" data = setup_tsk_juggler_scheduler_db_tests dp1 = Project(name="Dummy Project", code="DP", repository=data["test_repo"]) dp2 = Project(name="Dummy Project", code="DP", repository=data["test_repo"]) tjp = TaskJugglerScheduler(compute_resources=True) tjp.projects = [dp1, dp2] assert tjp.projects == [dp1, dp2] def test_tjp_file_content_is_correct_2(setup_tsk_juggler_scheduler_db_tests): """tjp file content is correct.""" data = setup_tsk_juggler_scheduler_db_tests tjp_sched = TaskJugglerScheduler() test_studio = Studio( name="Test Studio", timing_resolution=datetime.timedelta(minutes=30) ) test_studio.daily_working_hours = 9 test_studio.id = 564 test_studio.start = datetime.datetime(2013, 4, 16, 0, 7, tzinfo=pytz.utc) test_studio.end = datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc) test_studio.now = datetime.datetime(2013, 4, 16, 0, 0, tzinfo=pytz.utc) tjp_sched.studio = test_studio tjp_sched._create_tjp_file() tjp_sched._create_tjp_file_content() expected_tjp_template = jinja2.Template( """# Generated By Stalker v{{stalker.__version__}} project Studio_564 "Studio_564" 2013-04-16 - 2013-06-30 { timingresolution 30min now 2013-04-16-00:00 dailyworkinghours 9 weekstartsmonday workinghours mon 09:00 - 18:00 workinghours tue 09:00 - 18:00 workinghours wed 09:00 - 18:00 workinghours thu 09:00 - 18:00 workinghours fri 09:00 - 18:00 workinghours sat off workinghours sun off timeformat "%Y-%m-%d" scenario plan "Plan" trackingscenario plan } # resources resource resources "Resources" { resource User_3 "User_3" { efficiency 1.0 } resource User_{{user1.id}} "User_{{user1.id}}" { efficiency 1.0 } resource User_{{user2.id}} "User_{{user2.id}}" { efficiency 1.0 } resource User_{{user3.id}} "User_{{user3.id}}" { efficiency 1.0 } resource User_{{user4.id}} "User_{{user4.id}}" { efficiency 1.0 } resource User_{{user5.id}} "User_{{user5.id}}" { efficiency 1.0 } resource User_{{user6.id}} "User_{{user6.id}}" { efficiency 1.0 } } # tasks task Project_{{proj1.id}} "Project_{{proj1.id}}" { task Task_{{task1.id}} "Task_{{task1.id}}" { effort 50.0h allocate User_{{user1.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent }, User_{{user2.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent } } task Task_{{task2.id}} "Task_{{task2.id}}" { priority 800 depends Project_{{proj1.id}}.Task_{{task1.id}} {onend} effort 60.0h allocate User_{{user1.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent }, User_{{user2.id}} { alternative User_{{user3.id}}, User_{{user4.id}}, User_{{user5.id}} select minallocated persistent } } } # reports taskreport breakdown "{{csv_path}}"{ formats csv timeformat "%Y-%m-%d-%H:%M" columns id, start, end }""" ) expected_tjp_content = expected_tjp_template.render( { "stalker": stalker, "studio": test_studio, "csv_path": tjp_sched.temp_file_name, "user1": data["test_user1"], "user2": data["test_user2"], "user3": data["test_user3"], "user4": data["test_user4"], "user5": data["test_user5"], "user6": data["test_user6"], "proj1": data["test_proj1"], "task1": data["test_task1"], "task2": data["test_task2"], } ) data["maxDiff"] = None tjp_content = tjp_sched.tjp_content # print tjp_content tjp_sched._clean_up() assert tjp_content == expected_tjp_content ================================================ FILE: tests/models/test_task_status_workflow.py ================================================ # -*- coding: utf-8 -*- """Tests for the Task status workflow.""" import datetime import pytest import pytz from stalker import ( Asset, Project, Repository, Review, Status, StatusList, Task, TaskDependency, TimeLog, Type, User, Version, ) from stalker.db.session import DBSession from stalker.exceptions import StatusError from stalker.models.enum import ScheduleModel, TimeUnit from stalker.models.enum import DependencyTarget @pytest.fixture(scope="function") def setup_task_status_workflow_tests(): """Set up tests for the Task Status Workflow.""" data = dict() # test users data["test_user1"] = User( name="Test User 1", login="tuser1", email="tuser1@test.com", password="secret", ) data["test_user2"] = User( name="Test User 2", login="tuser2", email="tuser2@test.com", password="secret", ) # create a couple of tasks data["status_new"] = Status(name="New", code="NEW") data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["status_rrev"] = Status(name="Requested Revision", code="RREV") data["status_app"] = Status(name="Approved", code="APP") data["test_project_status_list"] = StatusList( name="Project Statuses", target_entity_type="Project", statuses=[data["status_wfd"], data["status_wip"], data["status_cmpl"]], ) data["test_task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Task", ) # repository data["test_repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T", ) # proj1 data["test_project1"] = Project( name="Test Project 1", code="TProj1", status_list=data["test_project_status_list"], repository=data["test_repo"], start=datetime.datetime(2013, 6, 20, 0, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, 0, tzinfo=pytz.utc), ) # root tasks data["test_task1"] = Task( name="Test Task 1", project=data["test_project1"], responsible=[data["test_user1"]], status_list=data["test_task_status_list"], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_task2"] = Task( name="Test Task 2", project=data["test_project1"], responsible=[data["test_user1"]], status_list=data["test_task_status_list"], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_task3"] = Task( name="Test Task 3", project=data["test_project1"], status_list=data["test_task_status_list"], resources=[data["test_user1"], data["test_user2"]], responsible=[data["test_user1"], data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) # children tasks # children of data["test_task1"] data["test_task4"] = Task( name="Test Task 4", parent=data["test_task1"], status=data["status_wfd"], status_list=data["test_task_status_list"], resources=[data["test_user1"]], depends_on=[data["test_task3"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_task5"] = Task( name="Test Task 5", parent=data["test_task1"], status_list=data["test_task_status_list"], resources=[data["test_user1"]], depends_on=[data["test_task4"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_task6"] = Task( name="Test Task 6", parent=data["test_task1"], status_list=data["test_task_status_list"], resources=[data["test_user1"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) # children of data["test_task2"] data["test_task7"] = Task( name="Test Task 7", parent=data["test_task2"], status_list=data["test_task_status_list"], resources=[data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_task8"] = Task( name="Test Task 8", parent=data["test_task2"], status_list=data["test_task_status_list"], resources=[data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) data["test_asset_status_list"] = StatusList( name="Asset Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Asset", ) # create an asset in between data["test_asset1"] = Asset( name="Test Asset 1", code="TA1", parent=data["test_task7"], type=Type( name="Character", code="Char", target_entity_type="Asset", ), status_list=data["test_asset_status_list"], ) # new task under asset data["test_task9"] = Task( name="Test Task 9", parent=data["test_asset1"], status_list=data["test_task_status_list"], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), resources=[data["test_user2"]], schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) # -------------- # Task Hierarchy # -------------- # # +-> Test Task 1 # | | # | +-> Test Task 4 # | | # | +-> Test Task 5 # | | # | +-> Test Task 6 # | # +-> Test Task 2 # | | # | +-> Test Task 7 # | | | # | | +-> Test Asset 1 # | | | # | | +-> Test Task 9 # | | # | +-> Test Task 8 # | # +-> Test Task 3 # no children for data["test_task3"] data["all_tasks"] = [ data["test_task1"], data["test_task2"], data["test_task3"], data["test_task4"], data["test_task5"], data["test_task6"], data["test_task7"], data["test_task8"], data["test_task9"], data["test_asset1"], ] return data def test_walk_hierarchy_is_working_as_expected(setup_task_status_workflow_tests): """walk_hierarchy is working as expected.""" data = setup_task_status_workflow_tests # this test should not be placed here visited_tasks = [] expected_result = [ data["test_task2"], data["test_task7"], data["test_task8"], data["test_asset1"], data["test_task9"], ] for task in data["test_task2"].walk_hierarchy(method=1): visited_tasks.append(task) assert expected_result == visited_tasks def test_walk_dependencies_is_working_as_expected(setup_task_status_workflow_tests): """walk_dependencies is working as expected.""" data = setup_task_status_workflow_tests # this test should not be placed here visited_tasks = [] expected_result = [ data["test_task9"], data["test_task6"], data["test_task4"], data["test_task5"], data["test_task8"], data["test_task3"], data["test_task4"], data["test_task8"], data["test_task3"], ] # setup dependencies data["test_task9"].depends_on = [data["test_task6"]] data["test_task6"].depends_on = [data["test_task4"], data["test_task5"]] data["test_task5"].depends_on = [data["test_task4"]] data["test_task4"].depends_on = [data["test_task8"], data["test_task3"]] for task in data["test_task9"].walk_dependencies(): visited_tasks.append(task) assert expected_result == visited_tasks # The following tests will test the status changes in dependency changes # Leaf Tasks - dependency relation changes # WFD def test_leaf_wfd_task_updated_to_have_a_dependency_of_wfd_task_task( setup_task_status_workflow_tests, ): """set dependency between a WFD task to another WFD task and the status stay WFD.""" # create another dependency to make the task3 a WFD task data = setup_task_status_workflow_tests data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_wfd"] assert data["test_task8"].status == data["status_wfd"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_rts_task( setup_task_status_workflow_tests, ): """set dependency between a WFD task to an RTS task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_rts"] assert data["test_task8"].status == data["status_rts"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_wip_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a WIP task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_wip"] assert data["test_task8"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_prev_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a PREV task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_prev"] assert data["test_task8"].status == data["status_prev"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_hrev_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a HREV task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_hrev"] assert data["test_task8"].status == data["status_hrev"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_oh_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a OH task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_oh"] assert data["test_task8"].status == data["status_oh"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_stop_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a STOP task and the status stay WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_stop"] assert data["test_task8"].status == data["status_stop"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_wfd_task_updated_to_have_a_dependency_of_cmpl_task( setup_task_status_workflow_tests, ): """set a dependency between a WFD task to a CMPL task and the status stay to WFD.""" data = setup_task_status_workflow_tests # create another dependency to make the task3 a WFD task data["test_task3"].depends_on = [] data["test_task9"].status = data["status_wip"] assert data["test_task9"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task9"]) assert data["test_task3"].status == data["status_wfd"] # make a task with HREV status # create dependency data["test_task8"].status = data["status_cmpl"] assert data["test_task8"].status == data["status_cmpl"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] # Leaf Tasks - dependency relation changes # RTS def test_leaf_rts_task_updated_to_have_a_dependency_of_wfd_task_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a WFD task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with WFD status data["test_task8"].status = data["status_wfd"] assert data["test_task8"].status == data["status_wfd"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_rts_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a RTS task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with RTS status data["test_task8"].status = data["status_rts"] assert data["test_task8"].status == data["status_rts"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_wip_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a WIP task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with WIP status data["test_task8"].status = data["status_wip"] assert data["test_task8"].status == data["status_wip"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_prev_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a PREV task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with PREV status data["test_task8"].status = data["status_prev"] assert data["test_task8"].status == data["status_prev"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_hrev_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a HREV task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with HREV status data["test_task8"].status = data["status_hrev"] assert data["test_task8"].status == data["status_hrev"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_oh_task( setup_task_status_workflow_tests, ): """set a dependency between a RTS task to a OH task the status updated to WFD.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with OH status data["test_task8"].status = data["status_oh"] assert data["test_task8"].status == data["status_oh"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_leaf_rts_task_updated_to_have_a_dependency_of_stop_task( setup_task_status_workflow_tests, ): """set a dependency between an RTS task to a STOP task the status will stay RTS.""" data = setup_task_status_workflow_tests # find an RTS task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with STOP status data["test_task8"].status = data["status_stop"] assert data["test_task8"].status == data["status_stop"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_rts"] def test_leaf_rts_task_updated_to_have_a_dependency_of_cmpl_task( setup_task_status_workflow_tests, ): """set a dependency between an RTS task to a CMPL task the status will stay RTS.""" data = setup_task_status_workflow_tests # find an RTS task assert data["test_task3"].depends_on == [] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency # make a task with CMPL status data["test_task8"].status = data["status_cmpl"] assert data["test_task8"].status == data["status_cmpl"] data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_rts"] # Leaf Tasks - dependency changes # WIP - DREV - PREV - HREV - OH - STOP - CMPL def test_leaf_wip_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a WIP task.""" data = setup_task_status_workflow_tests # find an WIP task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_wip"] assert data["test_task3"].status == data["status_wip"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a WIP task and it is not allowed to change the " "dependencies of a WIP task" ) def test_leaf_prev_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a PREV task.""" data = setup_task_status_workflow_tests # find an PREV task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_prev"] assert data["test_task3"].status == data["status_prev"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a PREV task and it is not allowed to change the " "dependencies of a PREV task" ) def test_leaf_hrev_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a HREV task.""" data = setup_task_status_workflow_tests # find an HREV task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_hrev"] assert data["test_task3"].status == data["status_hrev"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a HREV task and it is not allowed to change the " "dependencies of a HREV task" ) def test_leaf_drev_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a DREV task """ data = setup_task_status_workflow_tests # find an DREV task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_drev"] assert data["test_task3"].status == data["status_drev"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a DREV task and it is not allowed to change the " "dependencies of a DREV task" ) def test_leaf_oh_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a OH task.""" data = setup_task_status_workflow_tests # find an OH task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_oh"] assert data["test_task3"].status == data["status_oh"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a OH task and it is not allowed to change the " "dependencies of a OH task" ) def test_leaf_stop_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a STOP task.""" data = setup_task_status_workflow_tests # find an STOP task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_stop"] assert data["test_task3"].status == data["status_stop"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a STOP task and it is not allowed to change the " "dependencies of a STOP task" ) def test_leaf_cmpl_task_dependency_cannot_be_updated(setup_task_status_workflow_tests): """it is not possible to update the dependencies of a CMPL task.""" data = setup_task_status_workflow_tests # find an CMPL task data["test_task3"].depends_on = [] data["test_task3"].status = data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] # create dependency with pytest.raises(StatusError) as cm: data["test_task3"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a CMPL task and it is not allowed to change the " "dependencies of a CMPL task" ) # dependencies of containers # container Tasks - dependency relation changes # RTS def test_container_rts_task_updated_to_have_a_dependency_of_wfd_task_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to a WFD task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with WFD status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_wfd"] assert data["test_task8"].status == data["status_wfd"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_rts_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to an RTS task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with WFD status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_rts"] assert data["test_task8"].status == data["status_rts"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_wip_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to a WIP task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with WIP status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_wip"] assert data["test_task8"].status == data["status_wip"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_prev_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to a PREV task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with PREV status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_prev"] assert data["test_task8"].status == data["status_prev"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_hrev_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to an HREV task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with HREV status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_hrev"] assert data["test_task8"].status == data["status_hrev"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_oh_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to an OH task and status is updated to WFD.""" data = setup_task_status_workflow_tests # make a task with OH status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_oh"] assert data["test_task8"].status == data["status_oh"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_wfd"] def test_container_rts_task_updated_to_have_a_dependency_of_stop_task( setup_task_status_workflow_tests, ): """dep. between an RTS parent task to a STOP task and status will stay RTS.""" data = setup_task_status_workflow_tests # make a task with STOP status data["test_task3"].depends_on = [] data["test_task8"].status = data["status_stop"] assert data["test_task8"].status == data["status_stop"] # find a RTS container task data["test_task3"].children.append(data["test_task2"]) data["test_task2"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_rts"] # Container Tasks - dependency relation changes # WIP - DREV - PREV - HREV - OH - STOP - CMPL def test_container_wip_task_dependency_cannot_be_updated( setup_task_status_workflow_tests, ): """it is not possible to update the dependencies of a WIP container task.""" data = setup_task_status_workflow_tests # find an WIP task data["test_task1"].depends_on = [] data["test_task1"].status = data["status_wip"] assert data["test_task1"].status == data["status_wip"] # create dependency with pytest.raises(StatusError) as cm: data["test_task1"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a WIP task and it is not allowed to change the " "dependencies of a WIP task" ) def test_container_cmpl_task_dependency_cannot_be_updated( setup_task_status_workflow_tests, ): """it is not possible to update the dependencies of a CMPL container task.""" data = setup_task_status_workflow_tests # find an CMPL task data["test_task1"].status = data["status_cmpl"] assert data["test_task1"].status == data["status_cmpl"] # create dependency with pytest.raises(StatusError) as cm: data["test_task1"].depends_on.append(data["test_task8"]) assert ( str(cm.value) == "This is a CMPL task and it is not allowed to change the " "dependencies of a CMPL task" ) # # Action Tests # # create_time_log # WFD def test_create_time_log_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if create_time_log action is used in a WFD task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] resource = data["test_task3"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) with pytest.raises(StatusError) as cm: data["test_task3"].create_time_log(resource, start, end) assert ( str(cm.value) == "Test Task 3 is a WFD task, and it is not allowed to create " "TimeLogs for a WFD task, please supply a RTS, WIP, HREV or " "DREV task!" ) # RTS: status updated to WIP def test_create_time_log_in_rts_leaf_task_status_updated_to_wip( setup_task_status_workflow_tests, ): """RTS task converted to WIP if create_time_log action is used.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_rts"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task9"].create_time_log(resource, start, end) assert data["test_task9"].status == data["status_wip"] # RTS -> parent update def test_create_time_log_in_rts_leaf_task_update_parent_status( setup_task_status_workflow_tests, ): """parent of the RTS task converted to WIP after create_time_log action used.""" data = setup_task_status_workflow_tests data["test_task2"].status = data["status_rts"] data["test_task8"].status = data["status_rts"] assert data["test_task8"].parent == data["test_task2"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1) ) assert data["test_task8"].status == data["status_wip"] assert data["test_task2"].status == data["status_wip"] # RTS -> root task no problem def test_create_time_log_in_rts_root_task_no_parent_no_problem( setup_task_status_workflow_tests, ): """RTS leaf task status converted to WIP if create_time_log action is used.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] resource = data["test_task3"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task3"].create_time_log(resource, start, end) assert data["test_task3"].status == data["status_wip"] # WIP def test_create_time_log_in_wip_leaf_task(setup_task_status_workflow_tests): """no problem if create_time_log in a WIP task, and the status stays WIP.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_wip"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task9"].create_time_log(resource, start, end) assert data["test_task9"].status == data["status_wip"] # PREV def test_create_time_log_in_prev_leaf_task(setup_task_status_workflow_tests): """no problem to call create_time_log for a PREV task and the status stays PREV.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_prev"] resource = data["test_task3"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) assert data["test_task3"].status == data["status_prev"] tlog = data["test_task3"].create_time_log(resource, start, end) assert isinstance(tlog, TimeLog) assert data["test_task3"].status == data["status_prev"] # HREV def test_create_time_log_in_hrev_leaf_task(setup_task_status_workflow_tests): """status converted to WIP if create_time_log is used in a HREV task.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_hrev"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task9"].create_time_log(resource, start, end) assert data["test_task9"].status == data["status_wip"] # DREV def test_create_time_log_in_drev_leaf_task(setup_task_status_workflow_tests): """status will stay DREV if create_time_log is used in a DREV task.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_drev"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task9"].create_time_log(resource, start, end) assert data["test_task9"].status == data["status_drev"] # OH def test_create_time_log_in_oh_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the create_time_log actions is used in a OH task.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_oh"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) with pytest.raises(StatusError) as cm: data["test_task9"].create_time_log(resource, start, end) assert ( str(cm.value) == "Test Task 9 is a OH task, and it is not allowed to create " "TimeLogs for a OH task, please supply a RTS, WIP, HREV or DREV " "task!" ) # STOP def test_create_time_log_in_stop_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the create_time_log action is used in a STOP task.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_stop"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) with pytest.raises(StatusError) as cm: data["test_task9"].create_time_log(resource, start, end) assert ( str(cm.value) == "Test Task 9 is a STOP task, and it is not allowed to create " "TimeLogs for a STOP task, please supply a RTS, WIP, HREV or " "DREV task!" ) # CMPL def test_create_time_log_in_cmpl_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the create_time_log action is used in a CMPL task.""" data = setup_task_status_workflow_tests data["test_task9"].status = data["status_cmpl"] resource = data["test_task9"].resources[0] start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) with pytest.raises(StatusError) as cm: data["test_task9"].create_time_log(resource, start, end) assert ( str(cm.value) == "Test Task 9 is a CMPL task, and it is not allowed to create " "TimeLogs for a CMPL task, please supply a RTS, WIP, HREV or " "DREV task!" ) # On Container Task def test_create_time_log_on_container_task(setup_task_status_workflow_tests): """ValueError raised if the create_time_log action used in a container task.""" data = setup_task_status_workflow_tests start = datetime.datetime.now(pytz.utc) end = datetime.datetime.now(pytz.utc) + datetime.timedelta(hours=1) data["test_task2"].id = 36 with pytest.raises(ValueError) as cm: data["test_task2"].create_time_log(resource=None, start=start, end=end) assert ( str(cm.value) == "Test Task 2 (id: 36) is a container task, and it is not " "allowed to create TimeLogs for a container task" ) def test_create_time_log_is_creating_time_logs(setup_task_status_workflow_tests): """create_time_log action is really creating some time logs.""" data = setup_task_status_workflow_tests # initial condition assert len(data["test_task3"].time_logs) == 0 now = datetime.datetime.now(pytz.utc) data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) assert len(data["test_task3"].time_logs) == 1 assert data["test_task3"].total_logged_seconds == 3600 now = datetime.datetime.now(pytz.utc) data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + datetime.timedelta(hours=1), end=now + datetime.timedelta(hours=2), ) assert len(data["test_task3"].time_logs) == 2 assert data["test_task3"].total_logged_seconds == 7200 def test_create_time_log_returns_time_log_instance(setup_task_status_workflow_tests): """create_time_log returns a TimeLog instance.""" data = setup_task_status_workflow_tests assert len(data["test_task3"].time_logs) == 0 now = datetime.datetime.now(pytz.utc) tl = data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) assert isinstance(tl, TimeLog) # request_review # WFD def test_request_review_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a WFD leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a WFD task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # RTS def test_request_review_in_rts_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a RTS leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a RTS task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # PREV def test_request_review_in_prev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a PREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_prev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a PREV task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # HREV def test_request_review_in_hrev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a HREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_hrev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a HREV task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # DREV def test_request_review_in_drev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a DREV task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # OH def test_request_review_in_oh_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a OH leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_oh"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a OH task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # STOP def test_request_review_in_stop_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a STOP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_stop"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a STOP task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # CMPL def test_request_review_in_cmpl_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_review action is used in a CMPL leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_cmpl"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_review() assert ( str(cm.value) == "Test Task 3 (id:37) is a CMPL task, and it is not " "suitable for requesting a review, please supply a WIP task " "instead." ) # request_revision # WFD def test_request_revision_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if request_revision action is used in a WFD leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_revision() assert ( str(cm.value) == "Test Task 3 (id: 37) is a WFD task, and it is not suitable for " "requesting a revision, please supply a PREV or CMPL task" ) # RTS def test_request_revision_in_rts_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_revision action is used in a RTS leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_revision() assert ( str(cm.value) == "Test Task 3 (id: 37) is a RTS task, and it is not suitable for " "requesting a revision, please supply a PREV or CMPL task" ) # WIP def test_request_revision_in_wip_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the request_revision action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].request_revision() assert ( str(cm.value) == "Test Task 3 (id: 37) is a WIP task, and it is not suitable for " "requesting a revision, please supply a PREV or CMPL task" ) # HREV @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_in_hrev_leaf_task( setup_task_status_workflow_tests, schedule_unit ): """StatusError raised if the request_revision action is used in a HREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_hrev"] data["test_task3"].id = 37 kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": schedule_unit, } with pytest.raises(StatusError) as cm: data["test_task3"].request_revision(**kw) assert str(cm.value) == ( "Test Task 3 (id: 37) is a HREV task, and it is not suitable " "for requesting a revision, please supply a PREV or CMPL task" ) # OH @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_in_oh_leaf_task( setup_task_status_workflow_tests, schedule_unit, ): """StatusError raised if the request_revision action is used in a OH leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_oh"] data["test_task3"].id = 37 kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": schedule_unit, } with pytest.raises(StatusError) as cm: data["test_task3"].request_revision(**kw) assert ( str(cm.value) == "Test Task 3 (id: 37) is a OH task, and it is not suitable for " "requesting a revision, please supply a PREV or CMPL task" ) # STOP @pytest.mark.parametrize("schedule_unit", ["h", TimeUnit.Hour]) def test_request_revision_in_stop_leaf_task( setup_task_status_workflow_tests, schedule_unit ): """StatusError raised if the request_revision action is used in a STOP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_stop"] data["test_task3"].id = 37 kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": schedule_unit, } with pytest.raises(StatusError) as cm: data["test_task3"].request_revision(**kw) assert ( str(cm.value) == "Test Task 3 (id: 37) is a STOP task, and it is not suitable " "for requesting a revision, please supply a PREV or CMPL task" ) # hold # WFD def test_hold_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a WFD leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a WFD task, only WIP or DREV tasks can " "be set to On Hold" ) # RTS def test_hold_in_rts_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a RTS leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a RTS task, only WIP or DREV tasks can " "be set to On Hold" ) # WIP: Status updated to OH def test_hold_in_wip_leaf_task_status(setup_task_status_workflow_tests): """status updated to OH if the hold action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].hold() assert data["test_task3"].status == data["status_oh"] # WIP: Schedule values are intact def test_hold_in_wip_leaf_task_schedule_values(setup_task_status_workflow_tests): """schedule values intact if the hold action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].hold() assert data["test_task3"].schedule_timing == 10 assert data["test_task3"].schedule_unit == TimeUnit.Day # WIP: Priority is set to 0 def test_hold_in_wip_leaf_task(setup_task_status_workflow_tests): """priority set to 0 if the hold action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].hold() assert data["test_task3"].priority == 0 # PREV def test_hold_in_prev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a PREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_prev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a PREV task, only WIP or DREV tasks can " "be set to On Hold" ) # HREV def test_hold_in_hrev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a HREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_hrev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a HREV task, only WIP or DREV tasks can " "be set to On Hold" ) # DREV: Status updated to OH def test_hold_in_drev_leaf_task_status_updated_to_oh(setup_task_status_workflow_tests): """status updated to OH if the hold action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].hold() assert data["test_task3"].status == data["status_oh"] # DREV: Schedule values are intact def test_hold_in_drev_leaf_task_schedule_values_are_intact( setup_task_status_workflow_tests, ): """schedule values intact if the hold action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].hold() assert data["test_task3"].schedule_timing == 10 assert data["test_task3"].schedule_unit == TimeUnit.Day # DREV: Priority is set to 0 def test_hold_in_drev_leaf_task_priority_set_to_0(setup_task_status_workflow_tests): """priority set to 0 if the hold action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].hold() assert data["test_task3"].priority == 0 # OH def test_hold_in_oh_leaf_task(setup_task_status_workflow_tests): """status will stay on OH if the hold action is used in a OH leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_oh"] data["test_task3"].hold() assert data["test_task3"].status == data["status_oh"] # STOP def test_hold_in_stop_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a STOP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_stop"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a STOP task, only WIP or DREV tasks can " "be set to On Hold" ) # CMPL def test_hold_in_cmpl_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the hold action is used in a CMPL leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_cmpl"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].hold() assert ( str(cm.value) == "Test Task 3 (id:37) is a CMPL task, only WIP or DREV tasks can " "be set to On Hold" ) # stop # WFD def test_stop_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a WFD leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a WFD task and it is not possible to " "stop a WFD task." ) # RTS def test_stop_in_rts_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a RTS leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a RTS task and it is not possible to " "stop a RTS task." ) # WIP: Status Test def test_stop_in_wip_leaf_task_status_is_updated_to_stop( setup_task_status_workflow_tests, ): """status updated to STOP if the stop action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].hold() assert data["test_task3"].status == data["status_oh"] # WIP: Schedule Timing Test def test_stop_in_wip_leaf_task_schedule_values_clamped( setup_task_status_workflow_tests, ): """stop action on a WIP task clamps the schedule values to total_logged_seconds.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].status = data["status_rts"] TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task8"].status = data["status_wip"] data["test_task8"].stop() assert data["test_task8"].schedule_timing == 2 assert data["test_task8"].schedule_unit == TimeUnit.Hour # WIP: Dependency Status: WFD -> RTS def test_stop_in_wip_leaf_task_dependent_task_status_updated_from_wfd_to_rts( setup_task_status_workflow_tests, ): """stop action updates dependent task status from WFD to RTS on a WIP task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].status = data["status_rts"] data["test_task8"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task8"]] TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task8"].status = data["status_wip"] data["test_task8"].stop() assert data["test_task9"].status == data["status_rts"] # WIP: Dependency Status: DREV -> WIP def test_stop_in_wip_leaf_task_status_from_drev_to_hrev( setup_task_status_workflow_tests, ): """stop action updates dependent task status from DREV to HREV on a WIP task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].status = data["status_rts"] data["test_task8"].status = data["status_cmpl"] data["test_task9"].depends_on = [data["test_task8"]] TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_wip"] data["test_task8"].status = data["status_hrev"] data["test_task9"].status = data["status_drev"] TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=4), end=now + td(hours=5), ) data["test_task8"].status = data["status_wip"] data["test_task8"].stop() assert data["test_task9"].status == data["status_hrev"] # WIP: parent statuses def test_stop_in_drev_leaf_task_check_parent_status(setup_task_status_workflow_tests): """parent status is updated okay if stop action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_drev"] data["test_task9"].stop() assert data["test_task9"].status == data["status_stop"] assert data["test_asset1"].status == data["status_cmpl"] # PREV def test_stop_in_prev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a PREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_prev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a PREV task and it is not possible to " "stop a PREV task." ) # HREV def test_stop_in_hrev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a HREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_hrev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a HREV task and it is not possible to " "stop a HREV task." ) # DREV: Status Test def test_stop_in_drev_leaf_task_status_is_updated_to_stop( setup_task_status_workflow_tests, ): """status set to STOP if the stop action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].stop() assert data["test_task3"].status == data["status_stop"] # DREV: Schedule Timing Test def test_stop_in_drev_leaf_task_schedule_values_are_clamped( setup_task_status_workflow_tests, ): """stop action clamps schedule_timing to current time logs in a DREV lef task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].status = data["status_rts"] TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now, end=now + td(hours=2), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=2), end=now + td(hours=4), ) data["test_task8"].status = data["status_drev"] data["test_task8"].stop() assert data["test_task8"].schedule_timing == 4 assert data["test_task8"].schedule_unit == TimeUnit.Hour # DREV: parent statuses def test_stop_in_drev_leaf_task_parent_status(setup_task_status_workflow_tests): """parent status is updated okay if the stop action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_wip"] data["test_task9"].stop() assert data["test_task9"].status == data["status_stop"] assert data["test_asset1"].status == data["status_cmpl"] # DREV: Dependency Status: WFD -> RTS def test_stop_in_drev_leaf_task_dependent_task_status_updated_from_wfd_to_rts( setup_task_status_workflow_tests, ): """dependent task statuses updated okay if stop action taken on a DREV leaf task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].status = data["status_rts"] data["test_task8"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task8"]] TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task8"].status = data["status_wip"] data["test_task8"].stop() assert data["test_task9"].status == data["status_rts"] # DREV: Dependency Status: DREV -> WIP def test_stop_in_drev_leaf_task_dependent_task_status_updated_from_drev_to_hrev( setup_task_status_workflow_tests, ): """dependent task statuses updated okay if stop action taken on a DREV leaf task.""" data = setup_task_status_workflow_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].status = data["status_rts"] data["test_task8"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task8"]] data["test_task9"].status = data["status_drev"] # this set by an # action in normal run TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task8"], resource=data["test_task8"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task8"].status = data["status_wip"] data["test_task8"].stop() assert data["test_task9"].status == data["status_hrev"] # OH def test_stop_in_oh_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a OH leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_oh"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a OH task and it is not possible to stop " "a OH task." ) # STOP def test_stop_in_stop_leaf_task(setup_task_status_workflow_tests): """status will stay on STOP if the stop action is used in a STOP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_stop"] data["test_task3"].stop() assert data["test_task3"].status == data["status_stop"] # CMPL def test_stop_in_cmpl_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the stop action is used in a CMPL leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_cmpl"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].stop() assert ( str(cm.value) == "Test Task 3 (id:37)is a CMPL task and it is not possible to " "stop a CMPL task." ) # resume # WFD def test_resume_in_wfd_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a WFD leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wfd"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a WFD task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # RTS def test_resume_in_rts_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a RTS leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a RTS task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # WIP def test_resume_in_wip_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a WIP leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_wip"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a WIP task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # PREV def test_resume_in_prev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a PREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_prev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a PREV task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # HREV def test_resume_in_hrev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a HREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_hrev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a HREV task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # DREV def test_resume_in_drev_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a DREV leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a DREV task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) # OH: no dependency -> WIP def test_resume_in_oh_leaf_task_with_no_dependencies(setup_task_status_workflow_tests): """resume action on a OH leaf task with no dependencies updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_oh"] data["test_task3"].depends_on = [] data["test_task3"].resume() # no time logs so it will return back to rts # the test is wrong in the first place (no way to turn a task with no # time logs in to a OH task), # but checks a situation that the system needs to be more robust assert data["test_task3"].status == data["status_rts"] # OH: STOP dependencies -> WIP def test_resume_in_oh_leaf_task_with_stop_dependencies( setup_task_status_workflow_tests, ): """resume action on a OH leaf task with STOP dependencies updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].status = data["status_stop"] data["test_task9"].status = data["status_oh"] data["test_task9"].resume() assert data["test_task9"].status == data["status_wip"] # OH: CMPL dependencies -> WIP def test_resume_in_oh_leaf_task_with_cmpl_dependencies( setup_task_status_workflow_tests, ): """resume action on a OH leaf task with CMPL dependencies updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].status = data["status_cmpl"] data["test_task9"].status = data["status_oh"] data["test_task9"].resume() assert data["test_task9"].status == data["status_wip"] # STOP: no dependency -> WIP def test_resume_in_stop_leaf_task_with_no_dependencies( setup_task_status_workflow_tests, ): """resume action on a STOP leaf task with no dependencies updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_stop"] data["test_task3"].depends_on = [] data["test_task3"].resume() # no time logs so it will return back to rts # the test is wrong in the first place (no way to turn a task with no # time logs in to a OH task), # but checks a situation that the system needs to be more robust assert data["test_task3"].status == data["status_rts"] # STOP: STOP dependencies -> WIP def test_resume_in_stop_leaf_task_with_stop_dependencies( setup_task_status_workflow_tests, ): """resume action on a STOP leaf task with STOP dep.s updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].status = data["status_stop"] data["test_task9"].status = data["status_stop"] data["test_task9"].resume() assert data["test_task9"].status == data["status_wip"] # STOP: CMPL dependencies -> WIP def test_resume_in_stop_leaf_task_with_cmpl_dependencies( setup_task_status_workflow_tests, ): """resume action on a STOP leaf task with CMPL dep.s updates status to WIP.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].status = data["status_cmpl"] data["test_task9"].status = data["status_stop"] data["test_task9"].resume() assert data["test_task9"].status == data["status_wip"] # CMPL def test_resume_in_cmpl_leaf_task(setup_task_status_workflow_tests): """StatusError raised if the resume action is used in a CMPL leaf task.""" data = setup_task_status_workflow_tests data["test_task3"].status = data["status_drev"] data["test_task3"].id = 37 with pytest.raises(StatusError) as cm: data["test_task3"].resume() assert ( str(cm.value) == "Test Task 3 (id:37) is a DREV task, and it is not suitable to " "be resumed, please supply an OH or STOP task" ) def test_review_set_review_number_is_not_an_integer(setup_task_status_workflow_tests): """TypeError raised if the review_number arg is not an int in Task.review_set().""" data = setup_task_status_workflow_tests with pytest.raises(TypeError) as cm: data["test_task3"].review_set("not an integer") assert ( str(cm.value) == "review_number argument in Task.review_set should be a positive " "integer, not str: 'not an integer'" ) def test_review_set_review_number_is_a_negative_integer( setup_task_status_workflow_tests, ): """ValueError raised if the review_number is a negative number.""" data = setup_task_status_workflow_tests with pytest.raises(ValueError) as cm: data["test_task3"].review_set(-10) assert ( str(cm.value) == "review_number argument in Task.review_set should be a positive " "integer, not -10" ) def test_review_set_review_number_is_zero(setup_task_status_workflow_tests): """ValueError raised if the review_number is zero.""" data = setup_task_status_workflow_tests with pytest.raises(ValueError) as cm: data["test_task3"].review_set(0) assert ( str(cm.value) == "review_number argument in Task.review_set should be a positive " "integer, not 0" ) def test_leaf_drev_task_with_no_dependency_and_no_timelogs_update_status_with_dependent_statuses_fixes_status( setup_task_status_workflow_tests, ): """Task.update_status_with_dependent_statuses() fixes status of a leaf DREV task with no deps. (something went wrong) to RTS if there is no TimeLog and to WIP if there is a TimeLog. """ data = setup_task_status_workflow_tests # use task6 and task5 data["test_task5"].depends_on = [] # set the statuses data["test_task5"].status = data["status_drev"] assert data["status_drev"] == data["test_task5"].status # fix status with dependencies data["test_task5"].update_status_with_dependent_statuses() # check the status assert data["status_rts"] == data["test_task5"].status def test_leaf_drev_task_with_no_dependency_but_with_timelogs_update_status_with_dependent_statuses_fixes_status( setup_task_status_workflow_tests, ): """Task.update_status_with_dependent_statuses() will fix the status of a leaf DREV task with no dependency (something went wrong) to RTS if there is no TimeLog and to WIP if there is a TimeLog. """ data = setup_task_status_workflow_tests # use task6 and task5 data["test_task5"].depends_on = [] # create some time logs for dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task5"].create_time_log( resource=data["test_task5"].resources[0], start=now, end=now + td(hours=1) ) # set the statuses data["test_task5"].status = data["status_drev"] assert data["status_drev"] == data["test_task5"].status # fix status with dependencies data["test_task5"].update_status_with_dependent_statuses() # check the status assert data["status_wip"] == data["test_task5"].status def test_leaf_wip_task_with_no_dependency_and_no_timelogs_update_status_with_dependent_statuses_fixes_status( setup_task_status_workflow_tests, ): """Task.update_status_with_dependent_statuses() will fix the status of a leaf WIP task with no dependency (something went wrong) to RTS if there is no TimeLog and to WIP if there is a TimeLog. """ data = setup_task_status_workflow_tests # use task6 and task5 data["test_task5"].depends_on = [] # check if there is no time logs assert data["test_task5"].time_logs == [] # set the statuses data["test_task5"].status = data["status_wip"] assert data["status_wip"] == data["test_task5"].status # fix status with dependencies data["test_task5"].update_status_with_dependent_statuses() # check the status assert data["status_rts"] == data["test_task5"].status def test_container_task_update_status_with_dependent_status_will_skip( setup_task_status_workflow_tests, ): """update_status_with_dependent_status() will skip container tasks.""" data = setup_task_status_workflow_tests # the following should do nothing data["test_task1"].update_status_with_dependent_statuses() def test_update_status_with_children_statuses_with_leaf_task( setup_task_status_workflow_tests, ): """update_status_with_children_statuses will skip leaf tasks.""" data = setup_task_status_workflow_tests # the following should do nothing data["test_task4"].update_status_with_children_statuses() @pytest.fixture(scope="function") def setup_task_status_workflow_db_tests(setup_postgresql_db): """Set up the Task status workflow tests with a database.""" data = dict() # test users data["test_user1"] = User( name="Test User 1", login="tuser1", email="tuser1@test.com", password="secret" ) DBSession.add(data["test_user1"]) data["test_user2"] = User( name="Test User 2", login="tuser2", email="tuser2@test.com", password="secret" ) DBSession.add(data["test_user2"]) # create a couple of tasks data["status_new"] = Status.query.filter_by(code="NEW").first() data["status_wfd"] = Status.query.filter_by(code="WFD").first() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_prev"] = Status.query.filter_by(code="PREV").first() data["status_hrev"] = Status.query.filter_by(code="HREV").first() data["status_drev"] = Status.query.filter_by(code="DREV").first() data["status_oh"] = Status.query.filter_by(code="OH").first() data["status_stop"] = Status.query.filter_by(code="STOP").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() data["status_rrev"] = Status.query.filter_by(code="RREV").first() data["status_app"] = Status.query.filter_by(code="APP").first() # repository data["test_repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T", ) DBSession.add(data["test_repo"]) # proj1 data["test_project1"] = Project( name="Test Project 1", code="TProj1", repository=data["test_repo"], start=datetime.datetime(2013, 6, 20, 0, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, 0, tzinfo=pytz.utc), ) DBSession.add(data["test_project1"]) # root tasks data["test_task1"] = Task( name="Test Task 1", project=data["test_project1"], responsible=[data["test_user1"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task1"]) data["test_task2"] = Task( name="Test Task 2", project=data["test_project1"], responsible=[data["test_user1"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task2"]) data["test_task3"] = Task( name="Test Task 3", project=data["test_project1"], resources=[data["test_user1"], data["test_user2"]], responsible=[data["test_user1"], data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task3"]) # children tasks # children of data["test_task1"] data["test_task4"] = Task( name="Test Task 4", parent=data["test_task1"], status=data["status_wfd"], resources=[data["test_user1"]], depends_on=[data["test_task3"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task4"]) data["test_task5"] = Task( name="Test Task 5", parent=data["test_task1"], resources=[data["test_user1"]], depends_on=[data["test_task4"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task5"]) data["test_task6"] = Task( name="Test Task 6", parent=data["test_task1"], resources=[data["test_user1"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task6"]) # children of data["test_task2"] data["test_task7"] = Task( name="Test Task 7", parent=data["test_task2"], resources=[data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task7"]) data["test_task8"] = Task( name="Test Task 8", parent=data["test_task2"], resources=[data["test_user2"]], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task8"]) # create an asset in between data["test_asset1"] = Asset( name="Test Asset 1", code="TA1", parent=data["test_task7"], type=Type( name="Character", code="Char", target_entity_type="Asset", ), ) DBSession.add(data["test_asset1"]) # new task under asset data["test_task9"] = Task( name="Test Task 9", parent=data["test_asset1"], start=datetime.datetime(2013, 6, 20, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 30, 0, 0, tzinfo=pytz.utc), resources=[data["test_user2"]], schedule_timing=10, schedule_unit=TimeUnit.Day, schedule_model=ScheduleModel.Effort, ) DBSession.add(data["test_task9"]) DBSession.commit() # -------------- # Task Hierarchy # -------------- # # +-> Test Task 1 # | | # | +-> Test Task 4 # | | # | +-> Test Task 5 # | | # | +-> Test Task 6 # | # +-> Test Task 2 # | | # | +-> Test Task 7 # | | | # | | +-> Test Asset 1 # | | | # | | +-> Test Task 9 # | | # | +-> Test Task 8 # | # +-> Test Task 3 # no children for data["test_task3"] data["all_tasks"] = [ data["test_task1"], data["test_task2"], data["test_task3"], data["test_task4"], data["test_task5"], data["test_task6"], data["test_task7"], data["test_task8"], data["test_task9"], data["test_asset1"], ] return data def test_container_rts_task_updated_to_have_a_dependency_of_cmpl_task( setup_task_status_workflow_db_tests, ): """set dependency between an RTS container task to a CMPL task and will stay RTS.""" data = setup_task_status_workflow_db_tests # make a task with CMPL status data["test_task3"].depends_on = [] data["test_task3"].children.append(data["test_task6"]) dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["test_task8"].request_review() for review in reviews: review.approve() assert data["test_task8"].status == data["status_cmpl"] # find a RTS container task assert data["test_task3"].status == data["status_rts"] # create dependency data["test_task3"].depends_on.append(data["test_task8"]) assert data["test_task3"].status == data["status_rts"] # WIP: review instances def test_request_review_in_wip_leaf_task_review_instances( setup_task_status_workflow_db_tests, ): """request_review action returns reviews for each responsible on a WIP leaf task.""" data = setup_task_status_workflow_db_tests data["test_task3"].responsible = [data["test_user1"], data["test_user2"]] data["test_task3"].status = data["status_wip"] reviews = data["test_task3"].request_review() assert len(reviews) == 2 assert isinstance(reviews[0], Review) assert isinstance(reviews[1], Review) # WIP: review instances review_number is correct def test_request_review_in_wip_leaf_task_review_instances_review_number( setup_task_status_workflow_db_tests, ): """review_number attribute of the created Reviews are correctly set.""" data = setup_task_status_workflow_db_tests data["test_task3"].responsible = [data["test_user1"], data["test_user2"]] data["test_task3"].status = data["status_wip"] # request a review reviews = data["test_task3"].request_review() review1 = reviews[0] review2 = reviews[1] assert review1.review_number == 1 assert review2.review_number == 1 # finalize reviews review1.approve() review2.approve() # request a revision review3 = data["test_task3"].request_revision( reviewer=data["test_user1"], description="some description", schedule_timing=1, schedule_unit=TimeUnit.Day, ) # the new_review.revision number still should be 1 assert review3.review_number == 2 # and then ask a review again data["test_task3"].status = data["status_wip"] reviews = data["test_task3"].request_review() assert reviews[0].review_number == 3 assert reviews[1].review_number == 3 # WIP: status updated to PREV def test_request_review_in_wip_leaf_task_status_updated_to_prev( setup_task_status_workflow_db_tests, ): """request_review action updates WIP leaf task to PREV.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] data["test_task3"].request_review() assert data["test_task3"].status == data["status_prev"] # CMPL: dependent task dependency_target update CMPL -> DREV def test_request_revision_in_cmpl_leaf_task_cmpl_dependent_task_dependency_target_updated_to_onstart( setup_task_status_workflow_db_tests, ): """dependency_target attribute of the TaskDependency object between the revised task and the dependent CMPL task set to 'onstart' if the request_revision action is used in a CMPL leaf task. """ data = setup_task_status_workflow_db_tests # create a couple of TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # remove any TaskDependency instances # for i in TaskDependency.query.all(): # DBSession.delete(i) # # DBSession.commit() data["test_task3"].depends_on = [data["test_task9"]] # PREV data["test_task4"].depends_on = [data["test_task9"]] # HREV data["test_task5"].depends_on = [data["test_task9"]] # STOP data["test_task6"].depends_on = [data["test_task9"]] # OH data["test_task8"].depends_on = [data["test_task9"]] # DREV assert data["test_task9"] in data["test_task5"].depends_on assert data["test_task9"] in data["test_task6"].depends_on assert data["test_task9"] in data["test_task8"].depends_on data["test_task9"].status = data["status_rts"] data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1) ) data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) reviews = data["test_task9"].request_review() for r in reviews: r.approve() assert data["test_task9"].status == data["status_cmpl"] assert data["test_task8"].status == data["status_rts"] data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) assert data["test_task8"].status == data["status_wip"] [r.approve() for r in data["test_task8"].request_review()] assert data["test_task8"].status == data["status_cmpl"] # now work on task5 data["test_task5"].create_time_log( resource=data["test_task5"].resources[0], start=now + td(hours=3), end=now + td(hours=4), ) assert data["test_task5"].status == data["status_wip"] data["test_task5"].hold() assert data["test_task5"].status == data["status_oh"] # now work on task6 data["test_task6"].create_time_log( resource=data["test_task6"].resources[0], start=now + td(hours=4), end=now + td(hours=5), ) assert data["test_task6"].status == data["status_wip"] data["test_task6"].stop() assert data["test_task6"].status == data["status_stop"] # now work on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=5), end=now + td(hours=6), ) assert data["test_task3"].status == data["status_wip"] data["test_task3"].request_review() assert data["test_task3"].status == data["status_prev"] # now work on task4 data["test_task4"].create_time_log( resource=data["test_task4"].resources[0], start=now + td(hours=6), end=now + td(hours=7), ) assert data["test_task4"].status == data["status_wip"] reviews = data["test_task4"].request_review() DBSession.add_all(reviews) DBSession.commit() assert data["test_task4"].status == data["status_prev"] for r in reviews: r.request_revision(schedule_timing=1, schedule_unit=TimeUnit.Hour) assert data["test_task4"].status == data["status_hrev"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } data["test_task9"].request_revision(**kw) tdep_t3 = ( TaskDependency.query.filter_by(task=data["test_task3"]) .filter_by(depends_on=data["test_task9"]) .first() ) tdep_t4 = ( TaskDependency.query.filter_by(task=data["test_task4"]) .filter_by(depends_on=data["test_task9"]) .first() ) tdep_t5 = ( TaskDependency.query.filter_by(task=data["test_task5"]) .filter_by(depends_on=data["test_task9"]) .first() ) tdep_t6 = ( TaskDependency.query.filter_by(task=data["test_task6"]) .filter_by(depends_on=data["test_task9"]) .first() ) tdep_t8 = ( TaskDependency.query.filter_by(task=data["test_task8"]) .filter_by(depends_on=data["test_task9"]) .first() ) assert tdep_t3 is not None assert tdep_t4 is not None assert tdep_t5 is not None assert tdep_t6 is not None assert tdep_t8 is not None assert tdep_t3.dependency_target == DependencyTarget.OnStart assert tdep_t4.dependency_target == DependencyTarget.OnStart assert tdep_t5.dependency_target == DependencyTarget.OnStart assert tdep_t6.dependency_target == DependencyTarget.OnStart assert tdep_t8.dependency_target == DependencyTarget.OnStart # CMPL: dependent task status update CMPL -> DREV def test_request_revision_in_cmpl_leaf_task_cmpl_dependent_task_updated_to_drev( setup_task_status_workflow_db_tests, ): """status of the dependent CMPL task set to DREV if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].depends_on = [data["test_task9"]] assert data["test_task9"] in data["test_task8"].depends_on data["test_task9"].status = data["status_rts"] data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1) ) data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) reviews = data["test_task9"].request_review() for r in reviews: r.approve() assert data["test_task9"].status == data["status_cmpl"] assert data["test_task8"].status == data["status_rts"] data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) assert data["test_task8"].status == data["status_wip"] [r.approve() for r in data["test_task8"].request_review()] assert data["test_task8"].status == data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } data["test_task9"].request_revision(**kw) assert data["test_task9"].status == data["status_hrev"] assert data["test_task8"].status == data["status_drev"] # CMPL: dependent task parent status updated to WIP def test_request_revision_in_cmpl_leaf_task_dependent_task_parent_status_updated_to_wip( setup_task_status_workflow_db_tests, ): """status of the dependent task parent updated to WIP if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].depends_on = [data["test_task8"]] data["test_task9"].status = data["status_wfd"] data["test_asset1"].status = data["status_wfd"] data["test_task8"].status = data["status_rts"] data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now, end=now + td(hours=1) ) data["test_task8"].create_time_log( resource=data["test_task8"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task8"].status = data["status_cmpl"] data["test_task9"].status = data["status_cmpl"] data["test_asset1"].status = data["status_cmpl"] data["test_task7"].status = data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } review = data["test_task8"].request_revision(**kw) assert data["test_task9"].status == data["status_drev"] assert data["test_asset1"].status == data["status_wip"] assert data["test_task7"].status == data["status_wip"] # CMPL: parent status update def test_request_revision_in_cmpl_leaf_task_parent_status_updated_to_wip( setup_task_status_workflow_db_tests, ): """status of the parent set to WIP if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task9"].status = data["status_rts"] TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_cmpl"] data["test_asset1"].status = data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } review = data["test_task9"].request_revision(**kw) assert data["test_asset1"].status == data["status_wip"] # CMPL: dependent task status update RTS -> WFD def test_request_revision_in_cmpl_leaf_task_rts_dependent_task_updated_to_wfd( setup_task_status_workflow_db_tests, ): """status of the dependent RTS task set to WFD if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].depends_on = [data["test_task9"]] data["test_task8"].status = data["status_wfd"] data["test_task9"].status = data["status_rts"] TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } review = data["test_task9"].request_revision(**kw) assert data["test_task8"].status == data["status_wfd"] # CMPL: schedule info update def test_request_revision_in_cmpl_leaf_task_schedule_info_update( setup_task_status_workflow_db_tests, ): """timing values are extended with the supplied values if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task3"].status = data["status_rts"] tlog0 = data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) DBSession.add(tlog0) tlog1 = data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) DBSession.add(tlog1) DBSession.commit() assert data["test_task3"].total_logged_seconds == 7200 reviews = data["test_task3"].request_review() review1 = reviews[0] review2 = reviews[1] review1.approve() review2.approve() kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } revision = data["test_task3"].request_revision(**kw) DBSession.add(revision) assert data["test_task3"].schedule_timing == 6 assert data["test_task3"].schedule_unit == TimeUnit.Hour # CMPL: status update def test_request_revision_in_cmpl_leaf_task_status_updated_to_hrev( setup_task_status_workflow_db_tests, ): """status set to HREV and the timing values are extended with the supplied values if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } review = data["test_task3"].request_revision(**kw) assert data["test_task3"].status == data["status_hrev"] # CMPL: dependent task status update WIP -> DREV def test_request_revision_in_cmpl_leaf_task_wip_dependent_task_updated_to_drev( setup_task_status_workflow_db_tests, ): """status of the dependent WIP task set to DREV if the request_revision action is used in a CMPL leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task8"].depends_on = [data["test_task9"]] data["test_task8"].status = data["status_wip"] data["test_task9"].status = data["status_rts"] TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now, end=now + td(hours=1), ) TimeLog( task=data["test_task9"], resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) data["test_task9"].status = data["status_cmpl"] kw = { "reviewer": data["test_user1"], "description": "do something uleyn", "schedule_timing": 4, "schedule_unit": TimeUnit.Hour, } review = data["test_task9"].request_revision(**kw) assert data["test_task8"].status == data["status_drev"] def test_request_revision_in_deeper_dependency_setup( setup_task_status_workflow_db_tests, ): """all the dependent task statuses are updated to DREV.""" data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # # remove any TaskDependency instances # for i in TaskDependency.query.all(): # DBSession.delete(i) # DBSession.commit() data["test_task5"].depends_on = [] data["test_task6"].depends_on = [data["test_task5"]] data["test_task3"].depends_on = [data["test_task6"]] data["test_task8"].depends_on = [data["test_task3"]] data["test_task9"].depends_on = [data["test_task8"]] data["test_task5"].update_status_with_dependent_statuses() data["test_task6"].update_status_with_dependent_statuses() data["test_task3"].update_status_with_dependent_statuses() data["test_task8"].update_status_with_dependent_statuses() data["test_task9"].update_status_with_dependent_statuses() assert data["test_task5"].status == data["status_rts"] assert data["test_task6"].status == data["status_wfd"] assert data["test_task3"].status == data["status_wfd"] assert data["test_task3"].status == data["status_wfd"] assert data["test_task3"].status == data["status_wfd"] # DBSession.commit() # complete each of them first # test_task5 data["test_task5"].create_time_log( data["test_task5"].resources[0], now - td(hours=1), now ) data["test_task5"].schedule_timing = 1 data["test_task5"].schedule_unit = TimeUnit.Hour data["test_task5"].status = data["status_cmpl"] # test_task6 data["test_task6"].status = data["status_rts"] data["test_task6"].create_time_log( data["test_task6"].resources[0], now, now + td(hours=1) ) data["test_task6"].schedule_timing = 1 data["test_task6"].schedule_unit = TimeUnit.Hour data["test_task6"].status = data["status_cmpl"] # test_task3 data["test_task3"].status = data["status_rts"] data["test_task3"].create_time_log( data["test_task3"].resources[0], now + td(hours=1), now + td(hours=2) ) data["test_task3"].schedule_timing = 1 data["test_task3"].schedule_unit = TimeUnit.Hour data["test_task3"].status = data["status_cmpl"] # test_task8 data["test_task8"].status = data["status_rts"] data["test_task8"].create_time_log( data["test_task8"].resources[0], now + td(hours=2), now + td(hours=3) ) data["test_task8"].schedule_timing = 1 data["test_task8"].schedule_unit = TimeUnit.Hour data["test_task8"].status = data["status_cmpl"] # test_task9 data["test_task9"].status = data["status_rts"] data["test_task9"].create_time_log( data["test_task9"].resources[0], now + td(hours=3), now + td(hours=4) ) data["test_task9"].schedule_timing = 1 data["test_task9"].schedule_unit = TimeUnit.Hour data["test_task9"].status = data["status_cmpl"] # now request a revision to the first task (test_task6) # and expect all of the task dependency targets to be turned # in to DependencyTarget.OnStart data["test_task6"].request_revision(data["test_user1"]) assert ( data["test_task6"].task_depends_on[0].dependency_target == DependencyTarget.OnEnd ) assert ( data["test_task3"].task_depends_on[0].dependency_target == DependencyTarget.OnStart ) assert ( data["test_task8"].task_depends_on[0].dependency_target == DependencyTarget.OnStart ) assert ( data["test_task9"].task_depends_on[0].dependency_target == DependencyTarget.OnStart ) # PREV: Review instances statuses are updated def test_request_revision_in_prev_leaf_task_new_review_instance_is_created( setup_task_status_workflow_db_tests, ): """statuses of review instances are correctly updated to RREV if the request_revision action is used in a PREV leaf task """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] reviews = data["test_task3"].request_review() new_review = data["test_task3"].request_revision( reviewer=data["test_user2"], description="some description", schedule_timing=1, schedule_unit=TimeUnit.Week, ) assert isinstance(new_review, Review) # PREV: Review instances statuses are updated def test_request_revision_in_prev_leaf_task_review_instances_are_deleted( setup_task_status_workflow_db_tests, ): """NEW Review instances are deleted if the request_revision action is used in a PREV leaf task """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] reviews = data["test_task3"].request_review() review1 = reviews[0] review2 = reviews[1] assert review1.status == data["status_new"] assert review2.status == data["status_new"] review3 = data["test_task3"].request_revision( reviewer=data["test_user2"], description="some description", schedule_timing=4, schedule_unit=TimeUnit.Hour, ) # now check if the review instances are not in task3.reviews list # anymore assert review1 not in data["test_task3"].reviews assert review2 not in data["test_task3"].reviews assert review3 in data["test_task3"].reviews # PREV: Schedule info update also consider RREV Reviews def test_request_revision_in_prev_leaf_task_schedule_info_update_also_considers_other_rrev_reviews_with_same_review_number( setup_task_status_workflow_db_tests, ): """timing values are extended with the supplied values and also any RREV Review timings with the same revision number are included if the request_revision action is used in a PREV leaf task """ data = setup_task_status_workflow_db_tests # create a couple TimeLogs dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task3"].status = data["status_rts"] data["test_task3"].responsible = [data["test_user1"], data["test_user2"]] data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check the status assert data["test_task3"].status == data["status_wip"] # first request a review reviews = data["test_task3"].request_review() # only finalize the first review review1 = reviews[0] review2 = reviews[1] review1.request_revision( schedule_timing=6, schedule_unit=TimeUnit.Hour, description="" ) # now request_revision using the task review3 = data["test_task3"].request_revision( reviewer=data["test_user1"], description="do something uleyn", schedule_timing=4, schedule_unit=TimeUnit.Hour, ) assert len(data["test_task3"].reviews) == 2 # check if they are in the same review set assert review1.review_number == review3.review_number # the final timing should be 12 hours assert data["test_task3"].schedule_timing == 10 assert data["test_task3"].schedule_unit == TimeUnit.Day # PREV: Status updated to HREV def test_request_revision_in_prev_leaf_task_status_updated_to_hrev( setup_task_status_workflow_db_tests, ): """the status of the PREV leaf task converted to HREV if the request_revision action is used in a PREV leaf task """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_prev"] reviewer = data["test_user1"] description = "do something uleyn" schedule_timing = 4 schedule_unit = TimeUnit.Hour data["test_task3"].request_revision( reviewer=reviewer, description=description, schedule_timing=schedule_timing, schedule_unit=schedule_unit, ) assert data["test_task3"].status == data["status_hrev"] # PREV: Schedule info update def test_request_revision_in_prev_leaf_task_timing_is_extended( setup_task_status_workflow_db_tests, ): """timing extended as stated in the action when the request_revision action is used in a PREV leaf task """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_prev"] reviewer = data["test_user1"] description = "do something uleyn" schedule_timing = 4 schedule_unit = "h" data["test_task3"].request_revision( reviewer=reviewer, description=description, schedule_timing=schedule_timing, schedule_unit=schedule_unit, ) assert data["test_task3"].schedule_timing == 10 assert data["test_task3"].schedule_unit == TimeUnit.Day # OH: DREV dependencies -> DREV def test_resume_in_oh_leaf_task_with_drev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a OH leaf task with DREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task6"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].depends_on = [data["test_task6"]] # check statuses assert data["test_task6"].status == data["status_rts"] assert data["test_task3"].status == data["status_wfd"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task6"].create_time_log( resource=data["test_task6"].resources[0], start=now, end=now + td(hours=1) ) # approve task6 reviews = data["test_task6"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # approve task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # hold task9 data["test_task9"].hold() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_oh"] # request a revision to task6 data["test_task6"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task6"].status == data["status_hrev"] assert data["test_task3"].status == data["status_drev"] assert data["test_task9"].status == data["status_oh"] # resume task9 data["test_task9"].resume() # check statuses assert data["test_task6"].status == data["status_hrev"] assert data["test_task3"].status == data["status_drev"] assert data["test_task9"].status == data["status_drev"] # OH: HREV dependencies -> DREV def test_resume_in_oh_leaf_task_with_hrev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a OH leaf task with HREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) reviews = data["test_task3"].request_review() for r in reviews: r.approve() # task3 should be cmpl assert data["test_task3"].status == data["status_cmpl"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # now continue working on test_task3 data["test_task3"].request_revision(reviewer=data["test_task3"].resources[0]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_drev"] # hold task9 data["test_task9"].hold() assert data["test_task9"].status == data["status_oh"] # resume task9 data["test_task9"].resume() assert data["test_task9"].status == data["status_drev"] # OH: OH dependencies -> DREV def test_resume_in_oh_leaf_task_with_oh_dependencies( setup_task_status_workflow_db_tests, ): """status updated to WIP if the resume action is used in a OH leaf task with OH dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] # finish task3 first now = datetime.datetime.now(pytz.utc) data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + datetime.timedelta(hours=1), ) reviews = data["test_task3"].request_review() for r in reviews: r.approve() data["test_task9"].depends_on = [data["test_task3"]] # start working for task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + datetime.timedelta(hours=1), end=now + datetime.timedelta(hours=2), ) # now request a revision for task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_drev"] # enter a new time log for task3 to make it wip data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + datetime.timedelta(hours=3), end=now + datetime.timedelta(hours=4), ) # and hold task3 and task9 data["test_task9"].hold() data["test_task3"].hold() assert data["test_task3"].status == data["status_oh"] assert data["test_task9"].status == data["status_oh"] data["test_task9"].resume() assert data["test_task9"].status == data["status_drev"] # OH: PREV dependencies -> DREV def test_resume_in_oh_leaf_task_with_prev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a OH leaf task with PREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # hold task9 data["test_task9"].hold() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_oh"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_oh"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_oh"] # request a review for task3 data["test_task3"].request_review() # check statuses assert data["test_task3"].status == data["status_prev"] assert data["test_task9"].status == data["status_oh"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_prev"] assert data["test_task9"].status == data["status_drev"] # OH: WIP dependencies -> DREV def test_resume_in_oh_leaf_task_with_wip_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a OH leaf task with WIP dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # hold task9 data["test_task9"].hold() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_oh"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_oh"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_oh"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_drev"] # STOP: DREV dependencies -> DREV def test_resume_in_stop_leaf_task_with_drev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a STOP leaf task with DREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task6"].status = data["status_rts"] data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] data["test_task3"].depends_on = [data["test_task6"]] # check statuses assert data["test_task6"].status == data["status_rts"] assert data["test_task3"].status == data["status_wfd"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) data["test_task6"].create_time_log( resource=data["test_task6"].resources[0], start=now, end=now + td(hours=1) ) # approve task6 reviews = data["test_task6"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # approve task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # stop task9 data["test_task9"].stop() # check statuses assert data["test_task6"].status == data["status_cmpl"] assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_stop"] # request a revision to task6 data["test_task6"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task6"].status == data["status_hrev"] assert data["test_task3"].status == data["status_drev"] assert data["test_task9"].status == data["status_stop"] # resume task9 data["test_task9"].resume() # check statuses assert data["test_task6"].status == data["status_hrev"] assert data["test_task3"].status == data["status_drev"] assert data["test_task9"].status == data["status_drev"] # STOP: HREV dependencies -> DREV def test_resume_in_stop_leaf_task_with_hrev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a STOP leaf task with HREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # stop task9 data["test_task9"].stop() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_stop"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_stop"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_stop"] # request a review for task3 reviews = data["test_task3"].request_review() # check statuses assert data["test_task3"].status == data["status_prev"] assert data["test_task9"].status == data["status_stop"] # request revisions for r in reviews: r.request_revision() # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_stop"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_drev"] # STOP: OH dependencies -> DREV def test_resume_in_stop_leaf_task_with_oh_dependencies( setup_task_status_workflow_db_tests, ): """status updated to WIP if the resume action is used in a STOP leaf task with OH dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # stop task9 data["test_task9"].stop() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_stop"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_stop"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_stop"] # hold task3 data["test_task3"].hold() # check statuses assert data["test_task3"].status == data["status_oh"] assert data["test_task9"].status == data["status_stop"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_oh"] assert data["test_task9"].status == data["status_drev"] # STOP: PREV dependencies -> DREV def test_resume_in_stop_leaf_task_with_prev_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a STOP leaf task with PREV dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # stop task9 data["test_task9"].stop() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_stop"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_stop"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_stop"] # request a review for task3 data["test_task3"].request_review() # check statuses assert data["test_task3"].status == data["status_prev"] assert data["test_task9"].status == data["status_stop"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_prev"] assert data["test_task9"].status == data["status_drev"] # STOP: WIP dependencies -> DREV def test_resume_in_stop_leaf_task_with_wip_dependencies( setup_task_status_workflow_db_tests, ): """status updated to DREV if the resume action is used in a STOP leaf task with WIP dependencies """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_rts"] data["test_task9"].status = data["status_rts"] data["test_task9"].depends_on = [data["test_task3"]] # check statuses assert data["test_task3"].status == data["status_rts"] assert data["test_task9"].status == data["status_wfd"] dt = datetime.datetime td = datetime.timedelta now = dt.now(pytz.utc) # start working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now, end=now + td(hours=1) ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_wfd"] # complete task3 reviews = data["test_task3"].request_review() for r in reviews: r.approve() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_rts"] # start working on task9 data["test_task9"].create_time_log( resource=data["test_task9"].resources[0], start=now + td(hours=1), end=now + td(hours=2), ) # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_wip"] # stop task9 data["test_task9"].stop() # check statuses assert data["test_task3"].status == data["status_cmpl"] assert data["test_task9"].status == data["status_stop"] # request a revision to task3 data["test_task3"].request_revision(reviewer=data["test_user1"]) # check statuses assert data["test_task3"].status == data["status_hrev"] assert data["test_task9"].status == data["status_stop"] # now continue working on task3 data["test_task3"].create_time_log( resource=data["test_task3"].resources[0], start=now + td(hours=2), end=now + td(hours=3), ) # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_stop"] # now resume task9 data["test_task9"].resume() # check statuses assert data["test_task3"].status == data["status_wip"] assert data["test_task9"].status == data["status_drev"] def test_review_set_method_is_working_as_expected(setup_task_status_workflow_db_tests): """review_set() method is working as expected""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] # request a review reviews = data["test_task3"].request_review() DBSession.add_all(reviews) assert len(reviews) == 2 # check the review_set() method with no review number assert data["test_task3"].review_set() == reviews # now finalize the reviews reviews[0].approve() reviews[1].request_revision() # task should have been set to hrev assert data["status_hrev"] == data["test_task3"].status # set the status to wip again data["test_task3"].status = data["status_wip"] # request a new set of reviews reviews2 = data["test_task3"].request_review() # confirm that they it is a different set of review assert reviews != reviews2 # now check if review_set() will return reviews2 assert data["test_task3"].review_set() == reviews2 # and use a review_number assert data["test_task3"].review_set(1) == reviews assert data["test_task3"].review_set(2) == reviews2 def test_review_set_review_number_is_skipped(setup_task_status_workflow_db_tests): """latest review set returned if the review_number argument is skipped in Task.review_set() method """ data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] # request a review reviews = data["test_task3"].request_review() DBSession.add_all(reviews) assert len(reviews) == 2 # check the review_set() method with no review number assert data["test_task3"].review_set() == reviews # now finalize the reviews reviews[0].approve() reviews[1].request_revision() # task should have been set to hrev assert data["test_task3"].status == data["status_hrev"] # set the status to wip again data["test_task3"].status = data["status_wip"] # request a new set of reviews reviews2 = data["test_task3"].request_review() DBSession.add_all(reviews2) # confirm that it is a different set of review assert reviews != reviews2 # now check if review_set() will return reviews2 assert data["test_task3"].review_set() == reviews2 def test_request_review_version_arg_is_skipped(setup_task_status_workflow_db_tests): """request_review() version arg can be skipped.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] # request a review reviews = data["test_task3"].request_review() # Version arg is skipped assert len(reviews) == 2 def test_request_review_version_arg_is_none(setup_task_status_workflow_db_tests): """request_review() version arg can be None.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] # request a review reviews = data["test_task3"].request_review(version=None) assert len(reviews) == 2 def test_request_review_version_arg_is_not_a_version_instance( setup_task_status_workflow_db_tests, ): """request_review() version arg is not a Version instance raises TypeError.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] # request a review with pytest.raises(TypeError) as cm: _ = data["test_task3"].request_review(version="Not a version instance") assert str(cm.value) == ( "Review.version should be a Version instance, " "not str: 'Not a version instance'" ) def test_request_review_version_arg_is_not_related_to_the_task( setup_task_status_workflow_db_tests, ): """request_review() version arg is not related to the Task raises ValueError.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] version = Version(task=data["test_task2"]) # request a review with pytest.raises(ValueError) as cm: _ = data["test_task3"].request_review(version=version) assert str(cm.value) == ( f"Review.version should be a Version instance related to this Task: {version}" ) def test_request_review_accepts_version_instance(setup_task_status_workflow_db_tests): """request_review() a Version instance can be passed to it.""" data = setup_task_status_workflow_db_tests data["test_task3"].status = data["status_wip"] version = Version(task=data["test_task3"]) # request a review reviews = data["test_task3"].request_review(version=version) assert reviews[0].version == version assert reviews[1].version == version ================================================ FILE: tests/models/test_ticket.py ================================================ # -*- coding: utf-8 -*- """Tests for the Ticket class.""" import logging import sys import pytest from stalker import log from stalker import Asset from stalker import Note from stalker import Project from stalker import Repository from stalker import Status from stalker import Task from stalker import Ticket from stalker import TicketLog from stalker import Type from stalker import User from stalker import Version from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError logger = logging.getLogger("stalker.models.ticket") logger.setLevel(log.logging_level) @pytest.fixture(scope="function") def setup_ticket_tests(setup_postgresql_db): """Set up the tests for the Ticket class.""" data = dict() # create statuses data["test_status1"] = Status(name="N", code="N") data["test_status2"] = Status(name="R", code="R") # get the ticket types ticket_types = Type.query.filter(Type.target_entity_type == "Ticket").all() data["ticket_type_1"] = ticket_types[0] data["ticket_type_2"] = ticket_types[1] # create a User data["test_user"] = User( name="Test User", login="test_user1", email="test1@user.com", password="secret" ) # create a Repository data["test_repo"] = Repository(name="Test Repo", code="TR") # create a Project Type data["test_project_type"] = Type( name="Commercial Project", code="comm", target_entity_type="Project", ) # create a Project StatusList data["test_project_status1"] = Status(name="PrjStat1", code="PrjStat1") data["test_project_status2"] = Status(name="PrjStat2", code="PrjStat2") # create a Project data["test_project"] = Project( name="Test Project 1", code="TEST_PROJECT_1", type=data["test_project_type"], repository=data["test_repo"], ) DBSession.add(data["test_project"]) DBSession.commit() data["test_asset_type"] = Type( name="Character Asset", code="char", target_entity_type="Asset" ) data["test_asset"] = Asset( name="Test Asset", code="ta", project=data["test_project"], type=data["test_asset_type"], ) DBSession.add(data["test_asset"]) DBSession.commit() # create a Task data["test_task"] = Task( name="Modeling of Asset 1", resources=[data["test_user"]], parent=data["test_asset"], ) DBSession.add(data["test_task"]) DBSession.commit() data["test_version"] = Version( name="Test Version", task=data["test_task"], version=1, full_path="some/path" ) # create the Ticket data["kwargs"] = { "project": data["test_project"], "links": [data["test_version"]], "summary": "This is a test ticket", "description": "This is the long description", "priority": "TRIVIAL", "reported_by": data["test_user"], } data["test_ticket"] = Ticket(**data["kwargs"]) DBSession.add(data["test_ticket"]) DBSession.commit() # get the Ticket Statuses data["status_new"] = Status.query.filter_by(name="New").first() data["status_accepted"] = Status.query.filter_by(name="Accepted").first() data["status_assigned"] = Status.query.filter_by(name="Assigned").first() data["status_reopened"] = Status.query.filter_by(name="Reopened").first() data["status_closed"] = Status.query.filter_by(name="Closed").first() return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for Ticket class.""" assert Ticket.__auto_name__ is True def test_name_argument_is_not_used(setup_ticket_tests): """name argument is not used.""" data = setup_ticket_tests test_value = "Test Name" data["kwargs"]["name"] = test_value new_ticket = Ticket(**data["kwargs"]) assert new_ticket.name != test_value def test_name_argument_is_skipped_will_not_raise_error(setup_ticket_tests): """name arg skipped an automatically generated name is used.""" data = setup_ticket_tests if "name" in data["kwargs"]: data["kwargs"].pop("name") # expect no errors Ticket(**data["kwargs"]) def test_number_attribute_is_not_created_per_project(setup_ticket_tests): """number attr is not per project and uniquely increment for every new ticket.""" data = setup_ticket_tests proj1 = Project( name="Test Project 1", code="TP1", repository=data["test_repo"], ) proj2 = Project( name="Test Project 2", code="TP2", repository=data["test_repo"], ) proj3 = Project( name="Test Project 3", code="TP3", repository=data["test_repo"], ) p1_t1 = Ticket(project=proj1) DBSession.add(p1_t1) DBSession.commit() assert p1_t1.number == 2 p1_t2 = Ticket(project=proj1) DBSession.add(p1_t2) DBSession.commit() assert p1_t2.number == 3 p2_t1 = Ticket(project=proj2) DBSession.add(p2_t1) DBSession.commit() assert p2_t1.number == 4 p1_t3 = Ticket(project=proj1) DBSession.add(p1_t3) DBSession.commit() assert p1_t3.number == 5 p3_t1 = Ticket(project=proj3) DBSession.add(p3_t1) DBSession.commit() assert p3_t1.number == 6 p2_t2 = Ticket(project=proj2) DBSession.add(p2_t2) DBSession.commit() assert p2_t2.number == 7 p3_t2 = Ticket(project=proj3) DBSession.add(p3_t2) DBSession.commit() assert p3_t2.number == 8 p2_t3 = Ticket(project=proj2) DBSession.add(p2_t3) DBSession.commit() assert p2_t3.number == 9 def test_number_attribute_is_read_only(setup_ticket_tests): """number attribute is read-only.""" data = setup_ticket_tests with pytest.raises(AttributeError) as cm: data["test_ticket"].number = 234 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Ticket' object has no setter", 12: "property of 'Ticket' object has no setter", }.get( sys.version_info.minor, "property '_number_getter' of 'Ticket' object has no setter", ) assert str(cm.value) == error_message def test_number_attribute_is_automatically_increased(setup_ticket_tests): """number attribute is automatically increased.""" data = setup_ticket_tests # create two new tickets ticket1 = Ticket(**data["kwargs"]) DBSession.add(ticket1) DBSession.commit() ticket2 = Ticket(**data["kwargs"]) DBSession.add(ticket2) DBSession.commit() assert ticket1.number + 1 == ticket2.number assert ticket1.number == 2 assert ticket2.number == 3 def test_links_argument_accepts_anything_derived_from_simple_entity(setup_ticket_tests): """links accepting anything derived from SimpleEntity.""" data = setup_ticket_tests data["kwargs"]["links"] = [ data["test_project"], data["test_project_status1"], data["test_project_status2"], data["test_repo"], data["test_version"], ] new_ticket = Ticket(**data["kwargs"]) assert sorted(data["kwargs"]["links"], key=lambda x: x.name) == sorted( new_ticket.links, key=lambda x: x.name ) def test_links_attribute_accepts_anything_derived_from_simple_entity( setup_ticket_tests, ): """links attribute is accepting anything derived from SimpleEntity.""" data = setup_ticket_tests links = [ data["test_project"], data["test_project_status1"], data["test_project_status2"], data["test_repo"], data["test_version"], ] data["test_ticket"].links = links assert sorted(links, key=lambda x: x.name) == sorted( data["test_ticket"].links, key=lambda x: x.name ) def test_related_tickets_attribute_is_an_empty_list_on_init(setup_ticket_tests): """related_tickets attribute is an empty list on init.""" data = setup_ticket_tests assert data["test_ticket"].related_tickets == [] def test_related_tickets_attribute_is_set_to_something_other_then_a_list_of_tickets( setup_ticket_tests, ): """TypeError raised if the related_tickets attr is not a list of Tickets.""" data = setup_ticket_tests with pytest.raises(TypeError) as cm: data["test_ticket"].related_tickets = ["a ticket"] assert str(cm.value) == ( "Ticket.related_ticket should only contain instances of " "stalker.models.ticket.Ticket, not str: 'a ticket'" ) def test_related_tickets_attribute_accepts_list_of_ticket_instances(setup_ticket_tests): """related tickets attr accepts only list of Ticket instances.""" data = setup_ticket_tests new_ticket1 = Ticket(**data["kwargs"]) DBSession.add(new_ticket1) DBSession.commit() new_ticket2 = Ticket(**data["kwargs"]) DBSession.add(new_ticket2) DBSession.commit() data["test_ticket"].related_tickets = [new_ticket1, new_ticket2] def test_related_ticket_attribute_will_not_accept_self(setup_ticket_tests): """related_tickets attr don't accept the Ticket itself and raises ValueError.""" data = setup_ticket_tests with pytest.raises(CircularDependencyError) as cm: data["test_ticket"].related_tickets = [data["test_ticket"]] assert ( str(cm.value) == "Ticket.related_ticket attribute cannot " "have itself in the list" ) def test_priority_argument_is_skipped_will_set_it_to_zero(setup_ticket_tests): """priority arg is skipped will set the priority of the Ticket to 0 or TRIVIAL.""" data = setup_ticket_tests data["kwargs"].pop("priority") new_ticket = Ticket(**data["kwargs"]) assert new_ticket.priority == "TRIVIAL" def test_comments_attribute_is_synonym_for_notes_attribute(setup_ticket_tests): """comments attr is the synonym for the notes attr.""" data = setup_ticket_tests note1 = Note(name="Test Note 1", content="Test note 1") note2 = Note(name="Test Note 2", content="Test note 2") data["test_ticket"].comments.append(note1) data["test_ticket"].comments.append(note2) assert note1 in data["test_ticket"].notes assert note2 in data["test_ticket"].notes data["test_ticket"].notes.remove(note1) assert note1 not in data["test_ticket"].comments data["test_ticket"].notes.remove(note2) assert note2 not in data["test_ticket"].comments def test_reported_by_attribute_is_synonym_of_created_by(setup_ticket_tests): """reported_by attribute is a synonym for the created_by attribute.""" data = setup_ticket_tests user1 = User(name="user1", login="user1", password="secret", email="user1@test.com") data["test_ticket"].reported_by = user1 assert user1 == data["test_ticket"].created_by def test_status_for_newly_created_tickets_will_be_new_if_skipped(setup_ticket_tests): """status of newly created tickets New.""" data = setup_ticket_tests # get the status NEW from the session new_ticket = Ticket(**data["kwargs"]) assert new_ticket.status == data["status_new"] def test_project_argument_is_skipped(setup_ticket_tests): """TypeError raised if the project argument is skipped.""" data = setup_ticket_tests data["kwargs"].pop("project") with pytest.raises(TypeError) as cm: Ticket(**data["kwargs"]) assert str(cm.value) == ( "Ticket.project should be an instance of " "stalker.models.project.Project, not NoneType: 'None'" ) def test_project_argument_is_none(setup_ticket_tests): """TypeError raised if the project argument is None.""" data = setup_ticket_tests data["kwargs"]["project"] = None with pytest.raises(TypeError) as cm: Ticket(**data["kwargs"]) assert ( str(cm.value) == "Ticket.project should be an instance of " "stalker.models.project.Project, not NoneType: 'None'" ) def test_project_argument_accepts_project_instances_only(setup_ticket_tests): """project argument accepts Project instances only.""" data = setup_ticket_tests data["kwargs"]["project"] = "Not a Project instance" with pytest.raises(TypeError) as cm: Ticket(**data["kwargs"]) assert str(cm.value) == ( "Ticket.project should be an instance of " "stalker.models.project.Project, not str: 'Not a Project instance'" ) def test_project_argument_is_working_as_expected(setup_ticket_tests): """project argument is working as expected.""" data = setup_ticket_tests data["kwargs"]["project"] = data["test_project"] new_ticket = Ticket(**data["kwargs"]) assert new_ticket.project == data["test_project"] def test_project_attribute_is_read_only(setup_ticket_tests): """project attribute is read only.""" data = setup_ticket_tests with pytest.raises(AttributeError) as cm: data["test_ticket"].project = data["test_project"] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Ticket' object has no setter", 12: "property of 'Ticket' object has no setter", }.get( sys.version_info.minor, "property '_project_getter' of 'Ticket' object has no setter", ) assert str(cm.value) == error_message # STATUSES # resolve def test_resolve_method_will_change_the_status_from_new_to_closed_and_creates_a_log( setup_ticket_tests, ): """resolve action changes Ticket status from New to Closed.""" data = setup_ticket_tests assert data["test_ticket"].status == data["status_new"] ticket_log = data["test_ticket"].resolve() assert data["test_ticket"].status == data["status_closed"] assert ticket_log.from_status == data["status_new"] assert ticket_log.to_status == data["status_closed"] assert ticket_log.action == "resolve" def test_resolve_method_will_change_the_status_from_accepted_to_closed( setup_ticket_tests, ): """resolve action changes Ticket status from Accepted to Closed.""" data = setup_ticket_tests data["test_ticket"].status = data["status_accepted"] assert data["test_ticket"].status == data["status_accepted"] ticket_log = data["test_ticket"].resolve() assert data["test_ticket"].status == data["status_closed"] assert ticket_log.from_status == data["status_accepted"] assert ticket_log.to_status == data["status_closed"] assert ticket_log.action == "resolve" def test_resolve_method_will_change_the_status_from_assigned_to_closed( setup_ticket_tests, ): """resolve action changes Ticket status from Assigned to Closed.""" data = setup_ticket_tests data["test_ticket"].status = data["status_assigned"] assert data["test_ticket"].status == data["status_assigned"] ticket_log = data["test_ticket"].resolve() assert data["test_ticket"].status == data["status_closed"] assert ticket_log.from_status == data["status_assigned"] assert ticket_log.to_status == data["status_closed"] assert ticket_log.action == "resolve" def test_resolve_method_will_change_the_status_from_reopened_to_closed( setup_ticket_tests, ): """accept action changes Ticket status from Reopened to closed.""" data = setup_ticket_tests data["test_ticket"].status = data["status_reopened"] assert data["test_ticket"].status == data["status_reopened"] ticket_log = data["test_ticket"].resolve() assert data["test_ticket"].status == data["status_closed"] assert ticket_log.from_status == data["status_reopened"] assert ticket_log.to_status == data["status_closed"] assert ticket_log.action == "resolve" def test_resolve_method_will_not_change_the_status_from_closed_to_anything( setup_ticket_tests, ): """resolve action don't change Ticket status from Closed to anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_closed"] assert data["test_ticket"].status == data["status_closed"] ticket_log = data["test_ticket"].resolve() assert ticket_log is None assert data["test_ticket"].status == data["status_closed"] # reopen def test_reopen_method_will_not_change_the_status_from_new_to_anything( setup_ticket_tests, ): """reopen action will not change Ticket status from New to anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_new"] assert data["test_ticket"].status == data["status_new"] ticket_log = data["test_ticket"].reopen() assert ticket_log is None assert data["test_ticket"].status == data["status_new"] def test_reopen_method_will_not_change_the_status_from_accepted_to_anything( setup_ticket_tests, ): """reopen action will not change Ticket status from Accepted to anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_accepted"] assert data["test_ticket"].status == data["status_accepted"] ticket_log = data["test_ticket"].reopen() assert ticket_log is None assert data["test_ticket"].status == data["status_accepted"] def test_reopen_method_will_not_change_the_status_from_assigned_to_anything( setup_ticket_tests, ): """reopen action will not change Ticket status from Assigned to anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_assigned"] assert data["test_ticket"].status == data["status_assigned"] ticket_log = data["test_ticket"].reopen() assert ticket_log is None assert data["test_ticket"].status == data["status_assigned"] def test_reopen_method_will_not_change_the_status_from_reopened_to_anything( setup_ticket_tests, ): """reopen action will not change Ticket status from Reopened to anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_reopened"] assert data["test_ticket"].status == data["status_reopened"] ticket_log = data["test_ticket"].reopen() assert ticket_log is None assert data["test_ticket"].status == data["status_reopened"] def test_reopen_method_will_change_the_status_from_closed_to_reopened( setup_ticket_tests, ): """reopen action changes Ticket status from Closed to "Reopened".""" data = setup_ticket_tests data["test_ticket"].status = data["status_closed"] assert data["test_ticket"].status == data["status_closed"] ticket_log = data["test_ticket"].reopen() assert data["test_ticket"].status == data["status_reopened"] assert ticket_log.from_status == data["status_closed"] assert ticket_log.to_status == data["status_reopened"] assert ticket_log.action == "reopen" # accept def test_accept_method_will_change_the_status_from_new_to_accepted(setup_ticket_tests): """accept action changes Ticket status from New to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_new"] assert data["test_ticket"].status == data["status_new"] ticket_log = data["test_ticket"].accept() assert data["test_ticket"].status == data["status_accepted"] assert ticket_log.from_status == data["status_new"] assert ticket_log.to_status == data["status_accepted"] assert ticket_log.action == "accept" def test_accept_method_will_change_the_status_from_accepted_to_accepted( setup_ticket_tests, ): """accept action changes Ticket status from Accepted to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_accepted"] assert data["test_ticket"].status == data["status_accepted"] ticket_log = data["test_ticket"].accept() assert data["test_ticket"].status == data["status_accepted"] assert ticket_log.from_status == data["status_accepted"] assert ticket_log.to_status == data["status_accepted"] assert ticket_log.action == "accept" def test_accept_method_will_change_the_status_from_assigned_to_accepted( setup_ticket_tests, ): """accept action changes Ticket status from Assigned to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_assigned"] assert data["test_ticket"].status == data["status_assigned"] ticket_log = data["test_ticket"].accept() assert data["test_ticket"].status == data["status_accepted"] assert ticket_log.from_status == data["status_assigned"] assert ticket_log.to_status == data["status_accepted"] assert ticket_log.action == "accept" def test_accept_method_will_change_the_status_from_reopened_to_accepted( setup_ticket_tests, ): """accept action changes Ticket status from Reopened to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_reopened"] assert data["test_ticket"].status == data["status_reopened"] ticket_log = data["test_ticket"].accept() assert data["test_ticket"].status == data["status_accepted"] assert ticket_log.from_status == data["status_reopened"] assert ticket_log.to_status == data["status_accepted"] assert ticket_log.action == "accept" def test_accept_method_will_not_change_the_status_of_closed_to_anything( setup_ticket_tests, ): """accept action will not change Ticket status from Closed to Anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_closed"] assert data["test_ticket"].status == data["status_closed"] ticket_log = data["test_ticket"].accept() assert ticket_log is None assert data["test_ticket"].status == data["status_closed"] # reassign def test_reassign_method_will_change_the_status_from_new_to_assigned( setup_ticket_tests, ): """reassign action changes Ticket status from New to Assigned.""" data = setup_ticket_tests data["test_ticket"].status = data["status_new"] assert data["test_ticket"].status == data["status_new"] ticket_log = data["test_ticket"].reassign() assert data["test_ticket"].status == data["status_assigned"] assert ticket_log.from_status == data["status_new"] assert ticket_log.to_status == data["status_assigned"] assert ticket_log.action == "reassign" def test_reassign_method_will_change_the_status_from_accepted_to_assigned( setup_ticket_tests, ): """reassign action changes Ticket status from Accepted to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_accepted"] assert data["test_ticket"].status == data["status_accepted"] ticket_log = data["test_ticket"].reassign() assert data["test_ticket"].status == data["status_assigned"] assert ticket_log.from_status == data["status_accepted"] assert ticket_log.to_status == data["status_assigned"] assert ticket_log.action == "reassign" def test_reassign_method_will_change_the_status_from_assigned_to_assigned( setup_ticket_tests, ): """reassign action changes Ticket status from Assigned to Accepted.""" data = setup_ticket_tests data["test_ticket"].status = data["status_assigned"] assert data["test_ticket"].status == data["status_assigned"] ticket_log = data["test_ticket"].reassign() assert data["test_ticket"].status == data["status_assigned"] assert ticket_log.from_status == data["status_assigned"] assert ticket_log.to_status == data["status_assigned"] assert ticket_log.action == "reassign" def test_reassign_method_will_change_the_status_from_reopened_to_assigned( setup_ticket_tests, ): """accept action changes Ticket status from Reopened to Assigned.""" data = setup_ticket_tests data["test_ticket"].status = data["status_reopened"] assert data["test_ticket"].status == data["status_reopened"] ticket_log = data["test_ticket"].reassign() assert data["test_ticket"].status == data["status_assigned"] assert ticket_log.from_status == data["status_reopened"] assert ticket_log.to_status == data["status_assigned"] assert ticket_log.action == "reassign" def test_reassign_method_will_not_change_the_status_of_closed_to_anything( setup_ticket_tests, ): """reassign action will not change Ticket status from Closed to Anything.""" data = setup_ticket_tests data["test_ticket"].status = data["status_closed"] assert data["test_ticket"].status == data["status_closed"] ticket_log = data["test_ticket"].reassign() assert ticket_log is None assert data["test_ticket"].status == data["status_closed"] def test_resolve_method_will_set_the_resolution(setup_ticket_tests): """resolve action changes Ticket status from New to Closed.""" data = setup_ticket_tests assert data["test_ticket"].status == data["status_new"] ticket_log = data["test_ticket"].resolve(resolution="fixed") assert data["test_ticket"].status == data["status_closed"] assert ticket_log.from_status == data["status_new"] assert ticket_log.to_status == data["status_closed"] assert ticket_log.action == "resolve" assert data["test_ticket"].resolution == "fixed" def test_reopen_will_clear_resolution(setup_ticket_tests): """reopen method will clear the timing_resolution.""" data = setup_ticket_tests assert data["test_ticket"].status == data["status_new"] data["test_ticket"].resolve(resolution="fixed") assert data["test_ticket"].resolution == "fixed" ticket_log = data["test_ticket"].reopen() assert isinstance(ticket_log, TicketLog) assert data["test_ticket"].resolution == "" def test_reassign_will_set_the_owner(setup_ticket_tests): """reassign method will set the owner.""" data = setup_ticket_tests assert data["test_ticket"].status == data["status_new"] assert data["test_ticket"].owner != data["test_user"] ticket_log = data["test_ticket"].reassign(assign_to=data["test_user"]) assert isinstance(ticket_log, TicketLog) assert data["test_ticket"].owner == data["test_user"] def test_accept_will_set_the_owner(setup_ticket_tests): """accept method will set the owner.""" data = setup_ticket_tests assert data["test_ticket"].status == data["status_new"] assert data["test_ticket"].owner != data["test_user"] ticket_log = data["test_ticket"].accept(created_by=data["test_user"]) assert isinstance(ticket_log, TicketLog) assert data["test_ticket"].owner == data["test_user"] def test_summary_argument_skipped(setup_ticket_tests): """summary argument can be skipped.""" data = setup_ticket_tests try: data["kwargs"].pop("summary") except KeyError: pass new_ticket = Ticket(**data["kwargs"]) assert new_ticket.summary == "" def test_summary_argument_can_be_none(setup_ticket_tests): """summary argument can be None.""" data = setup_ticket_tests data["kwargs"]["summary"] = None new_ticket = Ticket(**data["kwargs"]) assert new_ticket.summary == "" def test_summary_attribute_can_be_set_to_none(setup_ticket_tests): """summary attribute can be set to None.""" data = setup_ticket_tests data["test_ticket"].summary = None assert data["test_ticket"].summary == "" def test_summary_argument_is_not_a_string(setup_ticket_tests): """TypeError raised if the summary argument value is not a str.""" data = setup_ticket_tests data["kwargs"]["summary"] = ["not a string instance"] with pytest.raises(TypeError) as cm: Ticket(data["kwargs"]) assert str(cm.value) == ( "Ticket.project should be an instance of " "stalker.models.project.Project, not dict: " "'{'project': , " "'links': [], 'summary': ['not a string instance'], 'description': " "'This is the long description', 'priority': 'TRIVIAL', 'reported_by': " "}'" ) def test_summary_attribute_is_set_to_a_value_other_than_a_string(setup_ticket_tests): """summary attribute is set to a value other than a str.""" data = setup_ticket_tests with pytest.raises(TypeError) as cm: data["test_ticket"].summary = ["not a string"] assert str(cm.value) == ( "Ticket.summary should be an instance of str, not list: '['not a string']'" ) def test_summary_argument_is_working_as_expected(setup_ticket_tests): """summary argument value is passed to summary attribute correctly.""" data = setup_ticket_tests test_value = "test summary" data["kwargs"]["summary"] = test_value new_ticket = Ticket(**data["kwargs"]) assert new_ticket.summary == test_value def test_summary_attribute_is_working_as_expected(setup_ticket_tests): """summary attribute is working as expected.""" data = setup_ticket_tests test_value = "test_summary" assert data["test_ticket"].summary != test_value data["test_ticket"].summary = test_value assert data["test_ticket"].summary == test_value def test__hash__is_working_as_expected(setup_ticket_tests): """__hash__ is working as expected.""" data = setup_ticket_tests result = hash(data["test_ticket"]) assert isinstance(result, int) assert result == data["test_ticket"].__hash__() def test__eq__of_two_tickets_true_case(setup_ticket_tests): """__eq__() for two tickets.""" data = setup_ticket_tests ticket1 = data["test_ticket"] ticket2 = Ticket.query.filter_by(name=ticket1.name).first() assert (ticket1 == ticket2) is True def test__eq__of_two_tickets_false_case(setup_ticket_tests): """__eq__() for two tickets.""" data = setup_ticket_tests new_ticket = Ticket(**data["kwargs"]) assert (data["test_ticket"] == new_ticket) is False def test_max_number_returns_0(): """_maximum_number() returns 0 when there is no DB connection.""" assert Ticket._maximum_number() == 0 ================================================ FILE: tests/models/test_time_log.py ================================================ # -*- coding: utf-8 -*- """Tests for the TimeLog class.""" import copy import datetime import pytest import pytz import tzlocal from sqlalchemy.exc import IntegrityError from stalker import Project, Repository, Status, StatusList, Task, TimeLog, User from stalker.db.session import DBSession from stalker.exceptions import DependencyViolationError, OverBookedError, StatusError from stalker.models.enum import TimeUnit from stalker.models.enum import DependencyTarget @pytest.fixture(scope="function") def setup_time_log_db_tests(setup_postgresql_db): """Set up the tests for the TimeLog class.""" data = dict() data["status_wfd"] = Status.query.filter_by(code="WFD").first() data["status_rts"] = Status.query.filter_by(code="RTS").first() data["status_wip"] = Status.query.filter_by(code="WIP").first() data["status_prev"] = Status.query.filter_by(code="PREV").first() data["status_hrev"] = Status.query.filter_by(code="HREV").first() data["status_drev"] = Status.query.filter_by(code="DREV").first() data["status_oh"] = Status.query.filter_by(code="OH").first() data["status_stop"] = Status.query.filter_by(code="STOP").first() data["status_cmpl"] = Status.query.filter_by(code="CMPL").first() # create a resource data["test_resource1"] = User( name="User1", login="user1", email="user1@users.com", password="1234", ) DBSession.add(data["test_resource1"]) data["test_resource2"] = User( name="User2", login="user2", email="user2@users.com", password="1234" ) DBSession.add(data["test_resource2"]) data["test_repo"] = Repository(name="test repository", code="tr") DBSession.add(data["test_repo"]) # create a Project data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") DBSession.add_all( [data["test_status1"], data["test_status2"], data["test_status3"]] ) data["test_project"] = Project( name="test project", code="tp", repository=data["test_repo"], ) DBSession.add(data["test_project"]) # create Tasks data["test_task1"] = Task( name="test task 1", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], ) DBSession.add(data["test_task1"]) data["test_task2"] = Task( name="test task 2", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], ) DBSession.add(data["test_task2"]) data["kwargs"] = { "task": data["test_task1"], "resource": data["test_resource1"], "start": datetime.datetime(2013, 3, 22, 1, 0, tzinfo=pytz.utc), "duration": datetime.timedelta(10), } # create a TimeLog # and test it data["test_time_log"] = TimeLog(**data["kwargs"]) DBSession.add(data["test_time_log"]) DBSession.commit() return data def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for TimeLog class.""" assert TimeLog.__auto_name__ is True def test_task_argument_is_skipped(setup_time_log_db_tests): """TypeError raised if the task argument is skipped.""" data = setup_time_log_db_tests td = datetime.timedelta kwargs = copy.copy(data["kwargs"]) kwargs.pop("task") kwargs["start"] = kwargs["start"] - td(days=100) kwargs["duration"] = td(hours=10) with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == ( "TimeLog.task should be an instance of stalker.models.task.Task, " "not NoneType: 'None'" ) def test_task_argument_is_none(setup_time_log_db_tests): """TypeError raised if the task argument is None.""" data = setup_time_log_db_tests td = datetime.timedelta kwargs = copy.copy(data["kwargs"]) kwargs["task"] = None kwargs["start"] = kwargs["start"] - td(days=100) kwargs["duration"] = td(hours=10) with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == ( "TimeLog.task should be an instance of stalker.models.task.Task, " "not NoneType: 'None'" ) def test_task_attribute_is_none(setup_time_log_db_tests): """TypeError raised if the task attribute is None.""" data = setup_time_log_db_tests with pytest.raises(TypeError) as cm: data["test_time_log"].task = None assert str(cm.value) == ( "TimeLog.task should be an instance of stalker.models.task.Task, " "not NoneType: 'None'" ) def test_task_argument_is_not_a_task_instance(setup_time_log_db_tests): """TypeError raised if the task arg is not a Task instance.""" data = setup_time_log_db_tests td = datetime.timedelta kwargs = copy.copy(data["kwargs"]) kwargs["task"] = "this is a task" kwargs["start"] = kwargs["start"] - td(days=100) kwargs["duration"] = td(hours=10) with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == ( "TimeLog.task should be an instance of stalker.models.task.Task, " "not str: 'this is a task'" ) def test_task_attribute_is_not_a_task_instance(setup_time_log_db_tests): """TypeError raised if the task attribute is not a Task instance.""" data = setup_time_log_db_tests with pytest.raises(TypeError) as cm: data["test_time_log"].task = "this is a task" assert str(cm.value) == ( "TimeLog.task should be an instance of stalker.models.task.Task, " "not str: 'this is a task'" ) def test_task_attribute_is_working_as_expected(setup_time_log_db_tests): """task attribute is working as expected.""" data = setup_time_log_db_tests new_task = Task( name="Test task 2", project=data["test_project"], resources=[data["test_resource1"]], ) assert data["test_time_log"].task != new_task data["test_time_log"].task = new_task assert data["test_time_log"].task == new_task def test_task_argument_updates_backref(setup_time_log_db_tests): """setting Task in TimeLog task arg updates Task.timee_logs attr.""" data = setup_time_log_db_tests new_task = Task( name="Test Task 3", project=data["test_project"], resources=[data["test_resource1"]], ) # now create a new time_log for the new task kwargs = copy.copy(data["kwargs"]) kwargs["task"] = new_task kwargs["start"] = kwargs["start"] + kwargs["duration"] + datetime.timedelta(120) new_time_log = TimeLog(**kwargs) # now check if the new_time_log is in task.time_logs assert new_time_log in new_task.time_logs def test_task_attribute_updates_backref(setup_time_log_db_tests): """setting Task in TimeLog.task attr updates Task.timee_logs attr.""" data = setup_time_log_db_tests new_task = Task( name="Test Task 3", project=data["test_project"], resources=[data["test_resource1"]], ) data["test_time_log"].task = new_task assert data["test_time_log"] in new_task.time_logs def test_resource_argument_is_skipped(setup_time_log_db_tests): """TypeError raised if the resource argument is skipped.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) kwargs.pop("resource") kwargs["start"] -= datetime.timedelta(days=200) kwargs["end"] = kwargs["start"] + datetime.timedelta(days=10) with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == "TimeLog.resource cannot be None" def test_resource_argument_is_none(setup_time_log_db_tests): """TypeError raised if the resource argument is None.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = None with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == "TimeLog.resource cannot be None" def test_resource_attribute_is_none(setup_time_log_db_tests): """TypeError raised if the resource attribute is set to None.""" data = setup_time_log_db_tests with pytest.raises(TypeError) as cm: data["test_time_log"].resource = None assert str(cm.value) == "TimeLog.resource cannot be None" def test_resource_argument_is_not_a_user_instance(setup_time_log_db_tests): """TypeError raised if the resource arg is not a User instance.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = "This is a resource" with pytest.raises(TypeError) as cm: TimeLog(**kwargs) assert str(cm.value) == ( "TimeLog.resource should be a stalker.models.auth.User instance, " "not str: 'This is a resource'" ) def test_resource_attribute_is_not_a_user_instance(setup_time_log_db_tests): """TypeError raised if the resource attr is not a User instance.""" data = setup_time_log_db_tests with pytest.raises(TypeError) as cm: data["test_time_log"].resource = "this is a resource" assert str(cm.value) == ( "TimeLog.resource should be a stalker.models.auth.User instance, " "not str: 'this is a resource'" ) def test_resource_attribute_is_working_as_expected(setup_time_log_db_tests): """resource attribute is working okay.""" data = setup_time_log_db_tests new_resource = User( name="Test Resource", login="test resource 2", email="test@resource2.com", password="1234", ) assert data["test_time_log"].resource != new_resource data["test_time_log"].resource = new_resource assert data["test_time_log"].resource == new_resource def test_resource_argument_updates_backref(setup_time_log_db_tests): """setting User in TimeLog resource arg updates User.timee_logs attr.""" data = setup_time_log_db_tests new_resource = User( name="Test Resource", login="test resource 2", email="test@resource2.com", password="1234", ) kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = new_resource new_time_log = TimeLog(**kwargs) assert new_time_log.resource == new_resource def test_resource_attribute_updates_backref(setup_time_log_db_tests): """setting User in TimeLog.resource attr updates User.timee_logs attr.""" data = setup_time_log_db_tests new_resource = User( name="Test Resource", login="test resource 2", email="test@resource2.com", password="1234", ) assert data["test_time_log"].resource != new_resource data["test_time_log"].resource = new_resource assert data["test_time_log"].resource == new_resource def test_schedule_mixin_initialization(setup_time_log_db_tests): """DateRangeMixin part is initialized correctly.""" data = setup_time_log_db_tests # it should have schedule attributes assert data["test_time_log"].start == data["kwargs"]["start"] assert data["test_time_log"].duration == data["kwargs"]["duration"] data["test_time_log"].start = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) data["test_time_log"].end = data["test_time_log"].start + datetime.timedelta(10) assert data["test_time_log"].duration == datetime.timedelta(10) def test_overbooked_error_1(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ##### ##### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = (datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc),) kwargs["duration"] = datetime.timedelta(10) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( time_log1.start.astimezone(local_tz), time_log1.end.astimezone(local_tz), ) def test_overbooked_error_2(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ##### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(10) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["duration"] = datetime.timedelta(8) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( time_log1.start.astimezone(local_tz), time_log1.end.astimezone(local_tz), ) def test_overbooked_error_3(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ##### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(8) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["duration"] = datetime.timedelta(10) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( time_log1.start.astimezone(local_tz), time_log1.end.astimezone(local_tz), ) def test_overbooked_error_4(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ##### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(2) kwargs["duration"] = datetime.timedelta(12) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(10) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( datetime.datetime(2013, 3, 20, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), ( datetime.datetime(2013, 3, 20, 4, 0, tzinfo=pytz.utc) + datetime.timedelta(12) ).astimezone(local_tz), ) def test_overbooked_error_5(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ##### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(10) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(2) kwargs["duration"] = datetime.timedelta(12) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), datetime.datetime(2013, 4, 1, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), ) def test_overbooked_error_6(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(15) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(5) kwargs["duration"] = datetime.timedelta(15) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), datetime.datetime(2013, 4, 6, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), ) def test_overbooked_error_7(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(5) kwargs["duration"] = datetime.timedelta(15) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(15) with pytest.raises(OverBookedError) as cm: TimeLog(**kwargs) local_tz = tzlocal.get_localzone() assert str(cm.value) == "The resource has another TimeLog between {} and {}".format( datetime.datetime(2013, 3, 17, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), datetime.datetime(2013, 4, 1, 4, 0, tzinfo=pytz.utc).astimezone(local_tz), ) def test_overbooked_error_8(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(5) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) + datetime.timedelta(20) # no warning time_log2 = TimeLog(**kwargs) DBSession.add(time_log2) DBSession.commit() def test_overbooked_error_9(setup_time_log_db_tests): """OverBookedError raised if resource is already booked for the given time period. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) + datetime.timedelta(20) kwargs["duration"] = datetime.timedelta(5) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) DBSession.commit() # time_log2 kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) # no warning time_log2 = TimeLog(**kwargs) DBSession.add(time_log2) DBSession.commit() def test_overbooked_error_10(setup_time_log_db_tests): """no OverBookedError raised for the same TimeLog instance.""" data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime(2013, 5, 6, 14, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(20) time_log1 = TimeLog(**kwargs) # no warning data["test_resource2"].time_logs.append(time_log1) def test_overbooked_error_11(setup_time_log_db_tests): """DB backend raises IntegrityError if the resource is booked for time the period. It is not caught in Python side. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(5) kwargs["duration"] = datetime.timedelta(15) time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) # time_log2 kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(15) time_log2 = TimeLog(**kwargs) DBSession.add(time_log2) # there should be an DatabaseError raised with pytest.raises(IntegrityError) as cm: DBSession.commit() assert ( "(psycopg2.errors.ExclusionViolation) conflicting key value " 'violates exclusion constraint "overlapping_time_logs"' in str(cm.value) ) def test_overbooked_error_12(setup_time_log_db_tests): """DB backend raises IntegrityError if the resource is booked for the time period. It is not caught in Python side. But this one ensures that the error is raised even if the tasks are different. Simple case diagram: ####### ####### """ data = setup_time_log_db_tests # time_log1 kwargs = copy.copy(data["kwargs"]) kwargs["resource"] = data["test_resource2"] kwargs["start"] = datetime.datetime( 2013, 3, 22, 4, 0, tzinfo=pytz.utc ) - datetime.timedelta(5) kwargs["duration"] = datetime.timedelta(15) kwargs["task"] = data["test_task1"] time_log1 = TimeLog(**kwargs) DBSession.add(time_log1) # time_log2 kwargs["start"] = datetime.datetime(2013, 3, 22, 4, 0, tzinfo=pytz.utc) kwargs["duration"] = datetime.timedelta(15) kwargs["task"] = data["test_task2"] time_log2 = TimeLog(**kwargs) DBSession.add(time_log2) # there should be an DatabaseError raised with pytest.raises(IntegrityError) as cm: DBSession.commit() assert ( "(psycopg2.errors.ExclusionViolation) conflicting key value " 'violates exclusion constraint "overlapping_time_logs"' in str(cm.value) ) def tests_overbooked_error_fallback_to_python_if_no_db_is_setup_self(): """_validate_resource() will fallback to Python if no db.""" data = dict() data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stop", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["project_status_list"] = StatusList( name="Project Statuses", statuses=[ data["status_rts"], data["status_wip"], data["status_cmpl"], ], target_entity_type="Project", ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_cmpl"], ], target_entity_type="Task", ) # create a resource data["test_resource1"] = User( name="User1", login="user1", email="user1@users.com", password="1234" ) data["test_resource2"] = User( name="User2", login="user2", email="user2@users.com", password="1234" ) data["test_repo"] = Repository(name="test repository", code="tr") # create a Project data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") data["test_project"] = Project( name="test project", code="tp", repository=data["test_repo"], status_list=data["project_status_list"], ) # create Tasks data["test_task1"] = Task( name="test task 1", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], status_list=data["task_status_list"], ) data["test_task2"] = Task( name="test task 2", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], status_list=data["task_status_list"], ) data["kwargs"] = { "task": data["test_task1"], "resource": data["test_resource1"], "start": datetime.datetime(2013, 3, 22, 1, 0, tzinfo=pytz.utc), "duration": datetime.timedelta(10), } # create a TimeLog # and test it data["test_time_log"] = TimeLog(**data["kwargs"]) # assigning the same resource should skip self while searching for a timelog data["test_time_log"].resource = data["test_resource1"] def tests_overbooked_error_fallback_to_python_if_no_db_is_setup_new_tlog(): """_validate_resource() will fallback to Python if no db.""" data = dict() data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stop", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["project_status_list"] = StatusList( name="Project Statuses", statuses=[ data["status_rts"], data["status_wip"], data["status_cmpl"], ], target_entity_type="Project", ) data["task_status_list"] = StatusList( name="Task Statuses", statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_cmpl"], ], target_entity_type="Task", ) # create a resource data["test_resource1"] = User( name="User1", login="user1", email="user1@users.com", password="1234" ) data["test_resource2"] = User( name="User2", login="user2", email="user2@users.com", password="1234" ) data["test_repo"] = Repository(name="test repository", code="tr") # create a Project data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") data["test_project"] = Project( name="test project", code="tp", repository=data["test_repo"], status_list=data["project_status_list"], ) # create Tasks data["test_task1"] = Task( name="test task 1", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], status_list=data["task_status_list"], ) data["test_task2"] = Task( name="test task 2", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, resources=[data["test_resource1"]], status_list=data["task_status_list"], ) data["kwargs"] = { "task": data["test_task1"], "resource": data["test_resource1"], "start": datetime.datetime(2013, 3, 22, 1, 0, tzinfo=pytz.utc), "duration": datetime.timedelta(10), } # create a TimeLog # and test it data["test_time_log"] = TimeLog(**data["kwargs"]) # creating another time log should raise Overbooked error with pytest.raises(OverBookedError) as cm: _ = TimeLog(**data["kwargs"]) def test_timelog_prevents_auto_flush_if_expanding_task_schedule_timing( setup_time_log_db_tests, ): """timeLog prevents auto flush if expanding task schedule_timing attribute.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["start"] = kwargs["start"] - datetime.timedelta(days=100) tlog1 = TimeLog(**kwargs) DBSession.add(tlog1) DBSession.commit() # create a new time log kwargs["start"] = kwargs["start"] + kwargs["duration"] _ = TimeLog(**kwargs) def test_timelog_creation_for_a_child_task(setup_time_log_db_tests): """TimeLog creation for a child task which has a couple of parent tasks.""" data = setup_time_log_db_tests dt = datetime.datetime parent_task1 = Task( name="Parent Task 1", project=data["test_project"], ) parent_task2 = Task( name="Parent Task 2", project=data["test_project"], ) child_task1 = Task( name="Child Task 1", project=data["test_project"], resources=[data["test_resource1"]], ) child_task2 = Task( name="Child Task 1", project=data["test_project"], resources=[data["test_resource2"]], ) # Task hierarchy # +-> p1 # | | # | +-> p2 # | | | # | | +-> c1 # | | # | +-> c2 # | # +-> data["test_task1"] parent_task2.parent = parent_task1 child_task2.parent = parent_task1 child_task1.parent = parent_task2 assert parent_task1.total_logged_seconds == 0 assert parent_task2.total_logged_seconds == 0 assert child_task1.total_logged_seconds == 0 assert child_task2.total_logged_seconds == 0 # now create a time log for child_task2 tlog1 = TimeLog( task=child_task2, resource=child_task2.resources[0], start=dt(2013, 7, 31, 10, 0, tzinfo=pytz.utc), end=dt(2013, 7, 31, 19, 0, tzinfo=pytz.utc), ) # before commit assert parent_task1.total_logged_seconds == 9 * 3600 assert parent_task2.total_logged_seconds == 0 assert child_task1.total_logged_seconds == 0 assert child_task2.total_logged_seconds == 0 # commit changes DBSession.add(tlog1) DBSession.commit() # after "commit" it should not change assert parent_task1.total_logged_seconds == 9 * 3600 assert parent_task2.total_logged_seconds == 0 assert child_task1.total_logged_seconds == 0 assert child_task2.total_logged_seconds == 9 * 3600 # add a new tlog to child_task2 and commit it # now create a time log for child_task2 tlog2 = TimeLog( task=child_task2, resource=child_task2.resources[0], start=dt(2013, 7, 31, 19, 0, tzinfo=pytz.utc), end=dt(2013, 7, 31, 22, 0, tzinfo=pytz.utc), ) assert parent_task1.total_logged_seconds == 12 * 3600 assert parent_task2.total_logged_seconds == 0 assert child_task1.total_logged_seconds == 0 assert child_task2.total_logged_seconds == 9 * 3600 # commit changes DBSession.add(tlog2) DBSession.commit() assert parent_task1.total_logged_seconds == 12 * 3600 assert parent_task2.total_logged_seconds == 0 assert child_task1.total_logged_seconds == 0 assert child_task2.total_logged_seconds == 12 * 3600 # add a new time log to child_task1 and commit it tlog3 = TimeLog( task=child_task1, resource=child_task1.resources[0], start=dt(2013, 7, 31, 10, 0, tzinfo=pytz.utc), end=dt(2013, 7, 31, 19, 0, tzinfo=pytz.utc), ) # commit changes DBSession.add(tlog3) DBSession.commit() assert parent_task1.total_logged_seconds == 21 * 3600 assert parent_task2.total_logged_seconds == 9 * 3600 assert child_task1.total_logged_seconds == 9 * 3600 assert child_task2.total_logged_seconds == 12 * 3600 # assert parent_task1.total_logged_seconds == 21 * 3600 # assert parent_task2.total_logged_seconds == 9 * 3600 # assert child_task1.total_logged_seconds == 9 * 3600 # assert child_task2.total_logged_seconds == 12 * 3600 def test_time_log_creation_for_a_wfd_leaf_task(setup_time_log_db_tests): """StatusError raised if TimeLog is created for a WFD leaf task.""" data = setup_time_log_db_tests new_task = Task(name="Test Task 2", project=data["test_project"]) new_task.depends_on = [data["test_task1"]] kwargs = copy.copy(data["kwargs"]) kwargs["task"] = new_task with pytest.raises(StatusError) as cm: TimeLog(**kwargs) assert ( str(cm.value) == "Test Task 2 is a WFD task, and it is not allowed to create " "TimeLogs for a WFD task, please supply a RTS, WIP, HREV or " "DREV task!" ) def test_time_log_creation_for_a_rts_leaf_task(setup_time_log_db_tests): """status updated to WIP if a TimeLog instance is created for an RTS leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] kwargs["start"] -= datetime.timedelta(days=100) task.status = data["status_rts"] assert task.status == data["status_rts"] TimeLog(**kwargs) assert task.status == data["status_wip"] def test_time_log_creation_for_a_wip_leaf_task(setup_time_log_db_tests): """status will stay at WIP if a TimeLog instance is created for a WIP leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] kwargs["start"] -= datetime.timedelta(days=10) task.status = data["status_wip"] assert task.status == data["status_wip"] TimeLog(**kwargs) def test_time_log_creation_for_a_prev_leaf_task(setup_time_log_db_tests): """status will stay PREV if a TimeLog instance is created for a PREV leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] kwargs["start"] -= datetime.timedelta(days=100) task.status = data["status_prev"] assert task.status == data["status_prev"] TimeLog(**kwargs) assert task.status == data["status_prev"] def test_time_log_creation_for_a_hrev_leaf_task(setup_time_log_db_tests): """status updated to WIP if a TimeLog instance is created for a HREV leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] kwargs["start"] -= datetime.timedelta(days=100) task.status = data["status_hrev"] assert task.status == data["status_hrev"] TimeLog(**kwargs) def test_time_log_creation_for_a_drev_leaf_task(setup_time_log_db_tests): """status will stay DREV if a TimeLog instance is created for a DREV leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] kwargs["start"] -= datetime.timedelta(days=100) task.status = data["status_drev"] assert task.status == data["status_drev"] TimeLog(**kwargs) def test_time_log_creation_for_a_oh_leaf_task(setup_time_log_db_tests): """StatusError raised if a TimeLog instance is created for a OH leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] task.status = data["status_oh"] assert task.status == data["status_oh"] with pytest.raises(StatusError) as cm: TimeLog(**kwargs) assert ( str(cm.value) == "test task 1 is a OH task, and it is not allowed to create " "TimeLogs for a OH task, please supply a RTS, WIP, HREV or DREV " "task!" ) def test_time_log_creation_for_a_stop_leaf_task(setup_time_log_db_tests): """StatusError raised if a TimeLog instance is created for a STOP leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] task.status = data["status_stop"] assert task.status == data["status_stop"] with pytest.raises(StatusError) as cm: TimeLog(**kwargs) assert ( str(cm.value) == "test task 1 is a STOP task, and it is not allowed to create " "TimeLogs for a STOP task, please supply a RTS, WIP, HREV or " "DREV task!" ) def test_time_log_creation_for_a_cmpl_leaf_task(setup_time_log_db_tests): """StatusError raised if a TimeLog instance is created for a CMPL leaf task.""" data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] task.status = data["status_cmpl"] assert task.status == data["status_cmpl"] with pytest.raises(StatusError) as cm: TimeLog(**kwargs) assert ( str(cm.value) == "test task 1 is a CMPL task, and it is not allowed to create " "TimeLogs for a CMPL task, please supply a RTS, WIP, HREV or " "DREV task!" ) def test_time_log_creation_that_violates_dependency_condition_wip_cmpl_onend( setup_time_log_db_tests, ): """DependencyViolationError raised if the TimeLog violates dependency task relation. +--------+ | Task 1 | ----+ | CMPL | | +--------+ | +--------+ +--->| Task 2 | | WIP | +--------+ """ data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] task.status = data["status_cmpl"] task.start = datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc) task.end = datetime.datetime(2014, 3, 25, 19, 0, tzinfo=pytz.utc) dep_task = Task( name="test task 2", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, depends_on=[task], resources=[data["test_resource2"]], ) # set the dependency target to onend dep_task.task_depends_on[0].dependency_target = "onend" # entering a time log to the dates before 2014-03-25-19-0 should raise # a ValueError with pytest.raises(DependencyViolationError) as cm: dep_task.create_time_log( data["test_resource2"], datetime.datetime(2014, 3, 25, 18, 0, tzinfo=pytz.utc), datetime.datetime(2014, 3, 25, 19, 0, tzinfo=pytz.utc), ) assert str(cm.value) == ( "It is not possible to create a TimeLog before {}, which " 'violates the dependency relation of "{}" to "{}"'.format( datetime.datetime(2014, 3, 25, 19, 0, tzinfo=pytz.utc), dep_task.name, task.name, ) ) # and creating a TimeLog after that is possible dep_task.create_time_log( data["test_resource2"], datetime.datetime(2014, 3, 25, 19, 0, tzinfo=pytz.utc), datetime.datetime(2014, 3, 25, 20, 0, tzinfo=pytz.utc), ) def test_time_log_creation_that_violates_dependency_condition_wip_cmpl_onstart( setup_time_log_db_tests, ): """ValueError raised if the entered TimeLog violates the dependency relation tasks. +--------+ +-| Task 1 | | | CMPL | | +--------+ +--------+ +-------------------->| Task 2 | | WIP | +--------+ """ data = setup_time_log_db_tests kwargs = copy.copy(data["kwargs"]) task = kwargs["task"] task.status = data["status_cmpl"] task.start = datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc) task.end = datetime.datetime(2014, 3, 25, 19, 0, tzinfo=pytz.utc) dep_task = Task( name="test task 2", project=data["test_project"], schedule_timing=10, schedule_unit=TimeUnit.Day, depends_on=[task], resources=[data["test_resource2"]], ) # set the dependency target to onstart dep_task.task_depends_on[0].dependency_target = DependencyTarget.OnStart # entering a time log to the dates before 2014-03-16-10-0 should raise # a ValueError with pytest.raises(DependencyViolationError) as cm: dep_task.create_time_log( data["test_resource2"], datetime.datetime(2014, 3, 16, 9, 0, tzinfo=pytz.utc), datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc), ) assert str(cm.value) == ( "It is not possible to create a TimeLog before {}, which " 'violates the dependency relation of "{}" to "{}"'.format( datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc), dep_task.name, task.name, ) ) # and creating a TimeLog after that is possible dep_task.create_time_log( data["test_resource2"], datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc), datetime.datetime(2014, 3, 16, 10, 0, tzinfo=pytz.utc), ) ================================================ FILE: tests/models/test_time_unit.py ================================================ # -*- coding: utf-8 -*- """TimeUnit related tests are here.""" from enum import Enum import sys import pytest from stalker.models.enum import TimeUnit, TimeUnitDecorator @pytest.mark.parametrize( "unit", [ TimeUnit.Minute, TimeUnit.Hour, TimeUnit.Day, TimeUnit.Week, TimeUnit.Month, TimeUnit.Year, ], ) def test_it_is_an_enum(unit): """TimeUnit is an Enum.""" assert isinstance(unit, Enum) @pytest.mark.parametrize( "unit,expected_value", [ [TimeUnit.Minute, "min"], [TimeUnit.Hour, "h"], [TimeUnit.Day, "d"], [TimeUnit.Week, "w"], [TimeUnit.Month, "m"], [TimeUnit.Year, "y"], ], ) def test_enum_values(unit, expected_value): """Test enum values.""" assert unit.value == expected_value @pytest.mark.parametrize( "unit,expected_name", [ [TimeUnit.Minute, "Minute"], [TimeUnit.Hour, "Hour"], [TimeUnit.Day, "Day"], [TimeUnit.Week, "Week"], [TimeUnit.Month, "Month"], [TimeUnit.Year, "Year"], ], ) def test_enum_names(unit, expected_name): """Test enum names.""" assert unit.name == expected_name @pytest.mark.parametrize( "unit,expected_value", [ [TimeUnit.Minute, "min"], [TimeUnit.Hour, "h"], [TimeUnit.Day, "d"], [TimeUnit.Week, "w"], [TimeUnit.Month, "m"], [TimeUnit.Year, "y"], ], ) def test_enum_as_str(unit, expected_value): """Test enum names.""" assert str(unit) == expected_value def test_to_unit_unit_is_skipped(): """TimeUnit.to_unit() unit is skipped.""" with pytest.raises(TypeError) as cm: _ = TimeUnit.to_unit() py_error_message = { 8: "to_unit() missing 1 required positional argument: 'unit'", 9: "to_unit() missing 1 required positional argument: 'unit'", 10: "TimeUnit.to_unit() missing 1 required positional argument: 'unit'", 11: "TimeUnit.to_unit() missing 1 required positional argument: 'unit'", 12: "TimeUnit.to_unit() missing 1 required positional argument: 'unit'", 13: "TimeUnit.to_unit() missing 1 required positional argument: 'unit'", }[sys.version_info.minor] assert str(cm.value) == py_error_message def test_to_unit_unit_is_none(): """TimeUnit.to_unit() unit is None.""" with pytest.raises(TypeError) as cm: _ = TimeUnit.to_unit(None) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not NoneType: 'None'" ) def test_to_unit_unit_is_not_a_str(): """TimeUnit.to_unit() unit is not a str.""" with pytest.raises(TypeError) as cm: _ = TimeUnit.to_unit(12334.123) assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not float: '12334.123'" ) def test_to_unit_unit_is_not_a_valid_str(): """TimeUnit.to_unit() unit is not a valid str.""" with pytest.raises(ValueError) as cm: _ = TimeUnit.to_unit("not a valid value") assert str(cm.value) == ( "unit should be a TimeUnit enum value or one of ['Minute', 'Hour', " "'Day', 'Week', 'Month', 'Year', 'min', 'h', 'd', 'w', 'm', 'y'], " "not 'not a valid value'" ) @pytest.mark.parametrize( "unit_name,unit", [ # Minute ["Min", TimeUnit.Minute], ["min", TimeUnit.Minute], ["MIN", TimeUnit.Minute], ["MiN", TimeUnit.Minute], ["mIn", TimeUnit.Minute], ["Minute", TimeUnit.Minute], ["minute", TimeUnit.Minute], ["MINUTE", TimeUnit.Minute], ["MiNuTe", TimeUnit.Minute], ["mInUtE", TimeUnit.Minute], # Hour ["H", TimeUnit.Hour], ["h", TimeUnit.Hour], ["Hour", TimeUnit.Hour], ["hour", TimeUnit.Hour], ["HOUR", TimeUnit.Hour], ["HoUr", TimeUnit.Hour], ["hOuR", TimeUnit.Hour], # Day ["D", TimeUnit.Day], ["d", TimeUnit.Day], ["Day", TimeUnit.Day], ["day", TimeUnit.Day], ["DAY", TimeUnit.Day], ["DaY", TimeUnit.Day], ["dAy", TimeUnit.Day], # Week ["W", TimeUnit.Week], ["w", TimeUnit.Week], ["Week", TimeUnit.Week], ["week", TimeUnit.Week], ["WEEK", TimeUnit.Week], ["WeeK", TimeUnit.Week], ["wEEk", TimeUnit.Week], # Month ["M", TimeUnit.Month], ["m", TimeUnit.Month], ["Month", TimeUnit.Month], ["month", TimeUnit.Month], ["MONTH", TimeUnit.Month], ["MoNtH", TimeUnit.Month], ["mOnTh", TimeUnit.Month], # Year ["Y", TimeUnit.Year], ["y", TimeUnit.Year], ["Year", TimeUnit.Year], ["year", TimeUnit.Year], ["YEAR", TimeUnit.Year], ["YeAr", TimeUnit.Year], ["yEaR", TimeUnit.Year], ], ) def test_schedule_unit_to_unit_is_working_properly(unit_name, unit): """TimeUnit can parse schedule unit names.""" assert TimeUnit.to_unit(unit_name) == unit def test_cache_ok_is_true_in_type_decorator(): """TimeUnitDecorator.cache_ok is True.""" assert TimeUnitDecorator.cache_ok is True ================================================ FILE: tests/models/test_traversal_direction.py ================================================ # -*- coding: utf-8 -*- """TraversalDirection related tests are here.""" from enum import IntEnum import sys import pytest from stalker.models.enum import TraversalDirection @pytest.mark.parametrize( "traversal_direction", [ TraversalDirection.DepthFirst, TraversalDirection.BreadthFirst, ], ) def test_it_is_an_int_enum(traversal_direction): """TraversalDirection is an IntEnum.""" assert isinstance(traversal_direction, IntEnum) @pytest.mark.parametrize( "traversal_direction,expected_value", [ [TraversalDirection.DepthFirst, 0], [TraversalDirection.BreadthFirst, 1], ], ) def test_enum_values(traversal_direction, expected_value): """Test enum values.""" assert traversal_direction == expected_value @pytest.mark.parametrize( "traversal_direction,expected_value", [ [TraversalDirection.DepthFirst, "DepthFirst"], [TraversalDirection.BreadthFirst, "BreadthFirst"], ], ) def test_enum_names(traversal_direction, expected_value): """Test enum names.""" assert str(traversal_direction) == expected_value def test_to_direction_direction_is_skipped(): """TraversalDirection.to_direction() direction is skipped.""" with pytest.raises(TypeError) as cm: _ = TraversalDirection.to_direction() py_error_message = { 8: "to_direction() missing 1 required positional argument: 'direction'", 9: "to_direction() missing 1 required positional argument: 'direction'", 10: "TraversalDirection.to_direction() missing 1 required positional argument: 'direction'", 11: "TraversalDirection.to_direction() missing 1 required positional argument: 'direction'", 12: "TraversalDirection.to_direction() missing 1 required positional argument: 'direction'", 13: "TraversalDirection.to_direction() missing 1 required positional argument: 'direction'", }[sys.version_info.minor] assert str(cm.value) == py_error_message def test_to_direction_direction_is_none(): """TraversalDirection.to_direction() direction is None.""" with pytest.raises(TypeError) as cm: _ = TraversalDirection.to_direction(None) assert str(cm.value) == ( "direction should be a TraversalDirection enum value or one " "of ['DepthFirst', 'BreadthFirst', 0, 1], not NoneType: 'None'" ) def test_to_direction_direction_is_not_a_str(): """TraversalDirection.to_direction() direction is not an int or str.""" with pytest.raises(TypeError) as cm: _ = TraversalDirection.to_direction(12334.123) assert str(cm.value) == ( "direction should be a TraversalDirection enum value or one of " "['DepthFirst', 'BreadthFirst', 0, 1], not float: '12334.123'" ) def test_to_direction_direction_is_not_a_valid_str(): """TraversalDirection.to_direction() direction is not a valid str.""" with pytest.raises(ValueError) as cm: _ = TraversalDirection.to_direction("not a valid value") assert str(cm.value) == ( "direction should be a TraversalDirection enum value or one of " "['DepthFirst', 'BreadthFirst', 0, 1], not 'not a valid value'" ) @pytest.mark.parametrize( "direction_name,direction", [ # DepthFirst ["DepthFirst", TraversalDirection.DepthFirst], ["depthfirst", TraversalDirection.DepthFirst], ["DEPTHFIRST", TraversalDirection.DepthFirst], ["DePtHfIrSt", TraversalDirection.DepthFirst], ["dEpThFiRsT", TraversalDirection.DepthFirst], [0, TraversalDirection.DepthFirst], # BreadthFirst ["BreadthFirst", TraversalDirection.BreadthFirst], ["breadthfirst", TraversalDirection.BreadthFirst], ["BREADTHFIRST", TraversalDirection.BreadthFirst], ["BrEaDtHfIrSt", TraversalDirection.BreadthFirst], ["bReAdThFiRsT", TraversalDirection.BreadthFirst], [1, TraversalDirection.BreadthFirst], ], ) def test_to_direction_is_working_properly(direction_name, direction): """TraversalDirection can parse schedule direction names.""" assert TraversalDirection.to_direction(direction_name) == direction ================================================ FILE: tests/models/test_type.py ================================================ # -*- coding: utf-8 -*- """Tests for the Type class.""" import sys import pytest from stalker import Asset, Entity, Type @pytest.fixture(scope="function") def setup_type_tests(): """Set up tests for the Type class.""" data = dict() data["kwargs"] = { "name": "test type", "code": "test", "description": "this is a test type", "target_entity_type": "SimpleEntity", } data["test_type"] = Type(**data["kwargs"]) # create another Entity with the same name of the # test_type for __eq__ and __ne__ tests data["entity1"] = Entity(**data["kwargs"]) return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for Ticket class.""" assert Type.__auto_name__ is False def test_equality(setup_type_tests): """equality operator.""" data = setup_type_tests new_type2 = Type(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "Asset" new_type3 = Type(**data["kwargs"]) data["kwargs"]["name"] = "a different type" data["kwargs"]["description"] = "this is a different type" new_type4 = Type(**data["kwargs"]) assert data["test_type"] == new_type2 assert not data["test_type"] == new_type3 assert not data["test_type"] == new_type4 assert not data["test_type"] == data["entity1"] def test_inequality(setup_type_tests): """inequality operator.""" data = setup_type_tests new_type2 = Type(**data["kwargs"]) data["kwargs"]["target_entity_type"] = "Asset" new_type3 = Type(**data["kwargs"]) data["kwargs"]["name"] = "a different type" data["kwargs"]["description"] = "this is a different type" new_type4 = Type(**data["kwargs"]) assert not data["test_type"] != new_type2 assert data["test_type"] != new_type3 assert data["test_type"] != new_type4 assert data["test_type"] != data["entity1"] def test_plural_class_name(setup_type_tests): """plural name of Type class.""" data = setup_type_tests assert data["test_type"].plural_class_name == "Types" def test_target_entity_type_argument_cannot_be_skipped(setup_type_tests): """TypeError raised if the created Type doesn't have any target_entity_type.""" data = setup_type_tests data["kwargs"].pop("target_entity_type") with pytest.raises(TypeError) as cm: Type(**data["kwargs"]) assert str(cm.value) == "Type.target_entity_type cannot be None" def test_target_entity_type_argument_cannot_be_none(setup_type_tests): """TypeError raised if the target_entity_type argument is None.""" data = setup_type_tests data["kwargs"]["target_entity_type"] = None with pytest.raises(TypeError) as cm: Type(**data["kwargs"]) assert str(cm.value) == "Type.target_entity_type cannot be None" def test_target_entity_type_argument_cannot_be_empty_string(setup_type_tests): """ValueError raised if the target_entity_type argument is an empty string.""" data = setup_type_tests data["kwargs"]["target_entity_type"] = "" with pytest.raises(ValueError) as cm: Type(**data["kwargs"]) assert str(cm.value) == "Type.target_entity_type cannot be empty" def test_target_entity_type_argument_accepts_strings(setup_type_tests): """target_entity_type argument accepts strings.""" data = setup_type_tests data["kwargs"]["target_entity_type"] = "Asset" # no error should be raised Type(**data["kwargs"]) def test_target_entity_type_argument_accepts_python_classes(setup_type_tests): """target_entity_type argument is given as a Python class converted to a string.""" data = setup_type_tests data["kwargs"]["target_entity_type"] = Asset new_type = Type(**data["kwargs"]) assert new_type.target_entity_type == "Asset" def test_target_entity_type_attribute_is_read_only(setup_type_tests): """target_entity_type attribute is read-only.""" data = setup_type_tests with pytest.raises(AttributeError) as cm: data["test_type"].target_entity_type = "Asset" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute", 11: "property of 'Type' object has no setter", 12: "property of 'Type' object has no setter", }.get( sys.version_info.minor, "property '_target_entity_type_getter' of 'Type' object has no setter", ) assert str(cm.value) == error_message def test_target_entity_type_attribute_is_working_as_expected(setup_type_tests): """target_entity_type attribute is working as expected.""" data = setup_type_tests assert data["test_type"].target_entity_type == data["kwargs"]["target_entity_type"] def test__hash__is_working_as_expected(setup_type_tests): """__hash__ is working as expected.""" data = setup_type_tests result = hash(data["test_type"]) assert isinstance(result, int) assert result == data["test_type"].__hash__() ================================================ FILE: tests/models/test_user.py ================================================ # -*- coding: utf-8 -*- """Tests for the User class.""" import copy import datetime import logging import sys import pytest import pytz from stalker import ( Client, Department, Group, Project, Repository, Sequence, Status, StatusList, Task, Ticket, Type, User, Vacation, Version, ) from stalker.db.session import DBSession from stalker.models.ticket import FIXED, CANTFIX, INVALID from sqlalchemy.exc import IntegrityError from tests.utils import get_admin_user logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @pytest.fixture(scope="function") def setup_user_db_tests(setup_postgresql_db): """Set up tests for the User class with a DB.""" data = dict() # need to have some test object for # a department data["test_department1"] = Department(name="Test Department 1") data["test_department2"] = Department(name="Test Department 2") data["test_department3"] = Department(name="Test Department 3") DBSession.add_all( [data["test_department1"], data["test_department2"], data["test_department3"]] ) # a couple of groups data["test_group1"] = Group(name="Test Group 1") data["test_group2"] = Group(name="Test Group 2") data["test_group3"] = Group(name="Test Group 3") DBSession.add_all([data["test_group1"], data["test_group2"], data["test_group3"]]) DBSession.commit() # a couple of statuses data["status_cmpl"] = Status.query.filter(Status.code == "CMPL").first() data["status_wip"] = Status.query.filter(Status.code == "WIP").first() data["status_rts"] = Status.query.filter(Status.code == "RTS").first() data["status_prev"] = Status.query.filter(Status.code == "PREV").first() # a repository type data["test_repository_type"] = Type( name="Test", code="test", target_entity_type="Repository", ) # a repository data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"] ) # a project type data["commercial_project_type"] = Type( name="Commercial Project", code="comm", target_entity_type="Project", ) # a couple of projects data["test_project1"] = Project( name="Test Project 1", code="tp1", type=data["commercial_project_type"], repository=data["test_repository"], ) data["test_project2"] = Project( name="Test Project 2", code="tp2", type=data["commercial_project_type"], repository=data["test_repository"], ) data["test_project3"] = Project( name="Test Project 3", code="tp3", type=data["commercial_project_type"], repository=data["test_repository"], ) DBSession.add_all( [data["test_project1"], data["test_project2"], data["test_project3"]] ) DBSession.commit() # a task status list data["task_status_list"] = StatusList.query.filter_by( target_entity_type="Task" ).first() data["test_lead"] = User( name="lead", login="lead", email="lead@lead.com", password="12345" ) # a couple of tasks data["test_task1"] = Task( name="Test Task 1", status_list=data["task_status_list"], project=data["test_project1"], responsible=[data["test_lead"]], ) data["test_task2"] = Task( name="Test Task 2", status_list=data["task_status_list"], project=data["test_project1"], responsible=[data["test_lead"]], ) data["test_task3"] = Task( name="Test Task 3", status_list=data["task_status_list"], project=data["test_project2"], responsible=[data["test_lead"]], ) data["test_task4"] = Task( name="Test Task 4", status_list=data["task_status_list"], project=data["test_project3"], responsible=[data["test_lead"]], ) DBSession.add_all( [data["test_task1"], data["test_task2"], data["test_task3"], data["test_task4"]] ) DBSession.commit() # for task1 data["test_version1"] = Version(task=data["test_task1"], full_path="some/path") DBSession.add(data["test_version1"]) DBSession.commit() data["test_version2"] = Version(task=data["test_task1"], full_path="some/path") DBSession.add(data["test_version2"]) DBSession.commit() data["test_version3"] = Version(task=data["test_task1"], full_path="some/path") DBSession.add(data["test_version3"]) DBSession.commit() # for task2 data["test_version4"] = Version(task=data["test_task2"], full_path="some/path") DBSession.add(data["test_version4"]) DBSession.commit() data["test_version5"] = Version(task=data["test_task2"], full_path="some/path") DBSession.add(data["test_version5"]) DBSession.commit() data["test_version6"] = Version(task=data["test_task2"], full_path="some/path") DBSession.add(data["test_version6"]) DBSession.commit() # for task3 data["test_version7"] = Version(task=data["test_task3"], full_path="some/path") DBSession.add(data["test_version7"]) DBSession.commit() data["test_version8"] = Version(task=data["test_task3"], full_path="some/path") DBSession.add(data["test_version8"]) DBSession.commit() data["test_version9"] = Version(task=data["test_task3"], full_path="some/path") DBSession.add(data["test_version9"]) DBSession.commit() # for task4 data["test_version10"] = Version(task=data["test_task4"], full_path="some/path") DBSession.add(data["test_version10"]) DBSession.commit() data["test_version11"] = Version(task=data["test_task4"], full_path="some/path") DBSession.add(data["test_version11"]) DBSession.commit() data["test_version12"] = Version(task=data["test_task4"], full_path="some/path") DBSession.add(data["test_version12"]) DBSession.commit() # ********************************************************************* # Tickets # ********************************************************************* # no need to create status list for tickets because we have a database # setup is running, so it will automatically be linked # tickets for version1 data["test_ticket1"] = Ticket( project=data["test_project1"], links=[data["test_version1"]], ) DBSession.add(data["test_ticket1"]) # set it to closed data["test_ticket1"].resolve() DBSession.commit() # create a new ticket and leave it open data["test_ticket2"] = Ticket( project=data["test_project1"], links=[data["test_version1"]], ) DBSession.add(data["test_ticket2"]) DBSession.commit() # create a new ticket and close and then reopen it data["test_ticket3"] = Ticket( project=data["test_project1"], links=[data["test_version1"]], ) DBSession.add(data["test_ticket3"]) data["test_ticket3"].resolve() data["test_ticket3"].reopen() DBSession.commit() # ********************************************************************* # tickets for version2 # create a new ticket and leave it open data["test_ticket4"] = Ticket( project=data["test_project1"], links=[data["test_version2"]], ) DBSession.add(data["test_ticket4"]) DBSession.commit() # create a new Ticket and close it data["test_ticket5"] = Ticket( project=data["test_project1"], links=[data["test_version2"]], ) DBSession.add(data["test_ticket5"]) data["test_ticket5"].resolve() DBSession.commit() # create a new Ticket and close it data["test_ticket6"] = Ticket( project=data["test_project1"], links=[data["test_version3"]], ) DBSession.add(data["test_ticket6"]) data["test_ticket6"].resolve() DBSession.commit() # ********************************************************************* # tickets for version3 # create a new ticket and close it data["test_ticket7"] = Ticket( project=data["test_project1"], links=[data["test_version3"]], ) DBSession.add(data["test_ticket7"]) data["test_ticket7"].resolve() DBSession.commit() # create a new ticket and close it data["test_ticket8"] = Ticket( project=data["test_project1"], links=[data["test_version3"]], ) DBSession.add(data["test_ticket8"]) data["test_ticket8"].resolve() DBSession.commit() # ********************************************************************* # tickets for version4 # create a new ticket and close it data["test_ticket9"] = Ticket( project=data["test_project1"], links=[data["test_version4"]], ) DBSession.add(data["test_ticket9"]) data["test_ticket9"].resolve() DBSession.commit() # no tickets for any other version # ********************************************************************* # a status list for sequence with DBSession.no_autoflush: data["sequence_status_list"] = StatusList.query.filter_by( target_entity_type="Sequence" ).first() # a couple of sequences data["test_sequence1"] = Sequence( name="Test Seq 1", code="ts1", project=data["test_project1"], status_list=data["sequence_status_list"], ) data["test_sequence2"] = Sequence( name="Test Seq 2", code="ts2", project=data["test_project1"], status_list=data["sequence_status_list"], ) data["test_sequence3"] = Sequence( name="Test Seq 3", code="ts3", project=data["test_project1"], status_list=data["sequence_status_list"], ) data["test_sequence4"] = Sequence( name="Test Seq 4", code="ts4", project=data["test_project1"], status_list=data["sequence_status_list"], ) DBSession.add_all( [ data["test_sequence1"], data["test_sequence2"], data["test_sequence3"], data["test_sequence4"], ] ) DBSession.commit() data["test_admin"] = get_admin_user() assert data["test_admin"] is not None # create test company data["test_company"] = Client(name="Test Company") # create the default values for parameters data["kwargs"] = { "name": "Erkan Ozgur Yilmaz", "login": "eoyilmaz", "description": "this is a test user", "password": "hidden", "email": "eoyilmaz@fake.com", "departments": [data["test_department1"]], "groups": [data["test_group1"], data["test_group2"]], "created_by": data["test_admin"], "updated_by": data["test_admin"], "efficiency": 1.0, "companies": [data["test_company"]], } # create a proper user object data["test_user"] = User(**data["kwargs"]) DBSession.add(data["test_user"]) DBSession.commit() # just change the kwargs for other tests data["kwargs"]["name"] = "some other name" data["kwargs"]["email"] = "some@other.email" return data def test___auto_name__class_attribute_is_set_to_false(): """__auto_name__ class attribute is set to False for User class.""" assert User.__auto_name__ is False def test_email_argument_accepting_only_string(setup_user_db_tests): """email argument accepting only string values.""" data = setup_user_db_tests # try to create a new user with wrong attribute data["kwargs"]["email"] = 1.3 with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == "User.email should be an instance of str, not float: '1.3'" def test_email_attribute_accepting_only_string_1(setup_user_db_tests): """email attribute accepting only string values.""" data = setup_user_db_tests # try to assign something else than a string test_value = 1 with pytest.raises(TypeError) as cm: data["test_user"].email = test_value assert str(cm.value) == "User.email should be an instance of str, not int: '1'" def test_email_attribute_accepting_only_string_2(setup_user_db_tests): """email attribute accepting only string values.""" data = setup_user_db_tests # try to assign something else than a string test_value = ["an email"] with pytest.raises(TypeError) as cm: data["test_user"].email = test_value assert str(cm.value) == ( "User.email should be an instance of str, not list: '['an email']'" ) def test_email_argument_format_1(setup_user_db_tests): """Given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of this values should raise a ValueError data["kwargs"]["email"] = "an email in no format" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert str(cm.value) == "check the formatting of User.email, there is no @ sign" def test_email_argument_format_2(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of this values should raise a ValueError data["kwargs"]["email"] = "an_email_with_no_part2" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert str(cm.value) == "check the formatting of User.email, there is no @ sign" def test_email_argument_format_3(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of this values should raise a ValueError data["kwargs"]["email"] = "@an_email_with_only_part2" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert ( str(cm.value) == "check the formatting of User.email, the name part is missing" ) def test_email_argument_format_4(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of this values should raise a ValueError data["kwargs"]["email"] = "@" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert ( str(cm.value) == "check the formatting of User.email, the name part is missing" ) def test_email_attribute_format_1(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "an email in no format" assert str(cm.value) == "check the formatting of User.email, there is no @ sign" def test_email_attribute_format_2(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "an_email_with_no_part2" assert str(cm.value) == "check the formatting of User.email, there is no @ sign" def test_email_attribute_format_3(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "@an_email_with_only_part2" assert ( str(cm.value) == "check the formatting of User.email, the name part is missing" ) def test_email_attribute_format_4(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "@" assert ( str(cm.value) == "check the formatting of User.email, the name part is missing" ) def test_email_attribute_format_5(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "eoyilmaz@" assert ( str(cm.value) == "check the formatting User.email, the domain part is missing" ) def test_email_attribute_format_6(setup_user_db_tests): """given an email in wrong format will raise a ValueError.""" data = setup_user_db_tests # any of these email values should raise a ValueError with pytest.raises(ValueError) as cm: data["test_user"].email = "eoyilmaz@some.compony@com" assert ( str(cm.value) == "check the formatting of User.email, there are more than one @ sign" ) def test_email_argument_should_be_a_unique_value(setup_user_db_tests): """email argument should be a unique value.""" data = setup_user_db_tests # this test should include a database test_email = "test@email.com" data["kwargs"]["login"] = "test_user1" data["kwargs"]["email"] = test_email user1 = User(**data["kwargs"]) DBSession.add(user1) DBSession.commit() data["kwargs"]["login"] = "test_user2" user2 = User(**data["kwargs"]) DBSession.add(user2) with pytest.raises(IntegrityError) as cm: DBSession.commit() assert ( "(psycopg2.errors.UniqueViolation) duplicate key value " 'violates unique constraint "Users_email_key"' in str(cm.value) ) def test_email_attribute_is_working_as_expected(setup_user_db_tests): """email attribute works as expected.""" data = setup_user_db_tests test_email = "eoyilmaz@somemail.com" data["test_user"].email = test_email assert data["test_user"].email == test_email def test_login_argument_conversion_to_strings(setup_user_db_tests): """ValueError raised if login converted to string results an empty string.""" data = setup_user_db_tests data["kwargs"]["login"] = "----++==#@#$" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert str(cm.value) == "User.login cannot be an empty string" def test_login_argument_for_empty_string(setup_user_db_tests): """ValueError raised if trying to assign an empty string to login argument.""" data = setup_user_db_tests data["kwargs"]["login"] = "" with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert str(cm.value) == "User.login cannot be an empty string" def test_login_attribute_for_empty_string(setup_user_db_tests): """ValueError raised if trying to assign an empty string to login attribute.""" data = setup_user_db_tests with pytest.raises(ValueError) as cm: data["test_user"].login = "" assert str(cm.value) == "User.login cannot be an empty string" def test_login_argument_is_skipped(setup_user_db_tests): """TypeError raised if the login argument is skipped.""" data = setup_user_db_tests data["kwargs"].pop("login") with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == "User.login cannot be None" def test_login_argument_is_none(setup_user_db_tests): """TypeError raised if trying to assign None to login argument.""" data = setup_user_db_tests data["kwargs"]["login"] = None with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == "User.login cannot be None" def test_login_attribute_is_none(setup_user_db_tests): """TypeError raised if trying to assign None to login attribute.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].login = None assert str(cm.value) == "User.login cannot be None" @pytest.mark.parametrize( "test_value,expected", [ ("e. ozgur", "eozgur"), ("erkan", "erkan"), ("Ozgur", "ozgur"), ("Erkan ozgur", "erkanozgur"), ("eRKAN", "erkan"), ("eRkaN", "erkan"), (" eRkAn", "erkan"), (" eRkan ozGur", "erkanozgur"), ("213 e.ozgur", "eozgur"), ], ) def test_login_argument_formatted_correctly(test_value, expected, setup_user_db_tests): """login argument formatted correctly.""" data = setup_user_db_tests # set the input and expect the expected output data["kwargs"]["login"] = test_value test_user = User(**data["kwargs"]) assert expected == test_user.login @pytest.mark.parametrize( "test_value,expected", [ ("e. ozgur", "eozgur"), ("erkan", "erkan"), ("Ozgur", "ozgur"), ("Erkan ozgur", "erkanozgur"), ("eRKAN", "erkan"), ("eRkaN", "erkan"), (" eRkAn", "erkan"), (" eRkan ozGur", "erkanozgur"), ], ) def test_login_attribute_formatted_correctly(test_value, expected, setup_user_db_tests): """login attribute formatted correctly.""" data = setup_user_db_tests # set the input and expect the expected output data["test_user"].login = test_value assert expected == data["test_user"].login def test_login_argument_should_be_a_unique_value(setup_user_db_tests): """login argument should be a unique value.""" data = setup_user_db_tests # this test should include a database test_login = "test_user1" data["kwargs"]["login"] = test_login data["kwargs"]["email"] = "test1@email.com" user1 = User(**data["kwargs"]) DBSession.add(user1) DBSession.commit() data["kwargs"]["email"] = "test2@email.com" user2 = User(**data["kwargs"]) DBSession.add(user2) with pytest.raises(IntegrityError) as cm: DBSession.commit() assert ( "(psycopg2.errors.UniqueViolation) duplicate key value " 'violates unique constraint "Users_login_key"' in str(cm.value) ) def test_login_argument_is_working_as_expected(setup_user_db_tests): """login argument is working as expected.""" data = setup_user_db_tests assert data["test_user"].login == data["kwargs"]["login"] def test_login_attribute_is_working_as_expected(setup_user_db_tests): """login attribute is working as expected.""" data = setup_user_db_tests test_value = "newlogin" data["test_user"].login = test_value assert data["test_user"].login == test_value def test_last_login_attribute_none(setup_user_db_tests): """nothing happens if the last login attribute is set to None.""" data = setup_user_db_tests # nothing should happen data["test_user"].last_login = None def test_departments_argument_is_skipped(setup_user_db_tests): """User can be created without a Department instance.""" data = setup_user_db_tests data["kwargs"].pop("departments") new_user = User(**data["kwargs"]) assert new_user.departments == [] def test_departments_argument_is_none(setup_user_db_tests): """User can be created with the departments argument value is to None.""" data = setup_user_db_tests data["kwargs"]["departments"] = None new_user = User(**data["kwargs"]) assert new_user.departments == [] def test_departments_attribute_is_set_none(setup_user_db_tests): """TypeError raised if the User's departments attribute set to None.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].departments = None assert str(cm.value) == "'NoneType' object is not iterable" def test_departments_argument_is_an_empty_list(setup_user_db_tests): """User can be created with the departments argument is an empty list.""" data = setup_user_db_tests data["kwargs"]["departments"] = [] User(**data["kwargs"]) def test_departments_attribute_is_an_empty_list(setup_user_db_tests): """departments attribute can be set to an empty list.""" data = setup_user_db_tests data["test_user"].departments = [] assert data["test_user"].departments == [] def test_departments_argument_only_accepts_list_of_department_objects( setup_user_db_tests, ): """TypeError raised if departments arg is not a Department instance.""" data = setup_user_db_tests # try to assign something other than a department object test_values = ["A department", 1, 1.0, ["a department"], {"a": "department"}] data["kwargs"]["departments"] = test_values with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == ( "DepartmentUser.department should be a " "stalker.models.department.Department instance, not str: 'A department'" ) def test_departments_attribute_only_accepts_department_objects(setup_user_db_tests): """TypeError raised if department attribute is not a Department instance.""" data = setup_user_db_tests # try to assign something other than a department test_value = "a department" with pytest.raises(TypeError) as cm: data["test_user"].departments = test_value assert str(cm.value) == ( "DepartmentUser.department should be a " "stalker.models.department.Department instance, not str: 'a'" ) def test_departments_attribute_works_as_expected(setup_user_db_tests): """departments attribute works as expected.""" data = setup_user_db_tests # try to set and get the same value back data["test_user"].departments = [data["test_department2"]] assert sorted(data["test_user"].departments, key=lambda x: x.name) == sorted( [data["test_department2"]], key=lambda x: x.name ) def test_departments_attribute_supports_appending(setup_user_db_tests): """departments attribute supports appending.""" data = setup_user_db_tests data["test_user"].departments = [] data["test_user"].departments.append(data["test_department1"]) data["test_user"].departments.append(data["test_department2"]) assert sorted(data["test_user"].departments, key=lambda x: x.name) == sorted( [data["test_department1"], data["test_department2"]], key=lambda x: x.name ) def test_password_arg_is_none(setup_user_db_tests): """TypeError raised if password arg value is None.""" data = setup_user_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["password"] = None with pytest.raises(TypeError) as cm: User(**kwargs) assert str(cm.value) == "User.password cannot be None" def test_password_argument_is_an_empty_string(setup_user_db_tests): """ValueError raised the password argument is an empty string.""" data = setup_user_db_tests kwargs = copy.copy(data["kwargs"]) kwargs["password"] = "" with pytest.raises(ValueError) as cm: User(**kwargs) assert str(cm.value) == "User.password cannot be an empty string" def test_password_attribute_being_none(setup_user_db_tests): """TypeError raised if tyring to assign None to the password attribute.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].password = None assert str(cm.value) == "User.password cannot be None" def test_password_attribute_works_as_expected(setup_user_db_tests): """password attribute works as expected.""" data = setup_user_db_tests test_password = "a new test password" data["test_user"].password = test_password assert data["test_user"].password != test_password def test_password_argument_being_scrambled(setup_user_db_tests): """password is scrambled if trying to store it.""" data = setup_user_db_tests test_password = "a new test password" data["kwargs"]["password"] = test_password new_user = User(**data["kwargs"]) assert new_user.password != test_password def test_password_attribute_being_scrambled(setup_user_db_tests): """password is scrambled if trying to store it.""" data = setup_user_db_tests test_password = "a new test password" data["test_user"].password = test_password # test if they are not the same anymore assert data["test_user"].password != test_password def test_check_password_works_as_expected(setup_user_db_tests): """check_password method works as expected.""" data = setup_user_db_tests test_password = "a new test password" data["test_user"].password = test_password # check if it is scrambled assert data["test_user"].password != test_password # check if check_password returns True assert data["test_user"].check_password(test_password) is True # check if check_password returns False assert data["test_user"].check_password("wrong pass") is False def test_groups_argument_for_none(setup_user_db_tests): """groups attribute an empty list if the groups argument is None.""" data = setup_user_db_tests data["kwargs"]["groups"] = None new_user = User(**data["kwargs"]) assert new_user.groups == [] def test_groups_attribute_for_none(setup_user_db_tests): """TypeError raised if groups attribute is set to None.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].groups = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_groups_argument_accepts_only_group_instances(setup_user_db_tests): """TypeError raised if group arg is not a Group instance.""" data = setup_user_db_tests data["kwargs"]["groups"] = "a_group" with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_groups_attribute_accepts_only_group_instances(setup_user_db_tests): """TypeError raised if group attr is not a Group instance.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].groups = "a_group" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_groups_attribute_works_as_expected(setup_user_db_tests): """groups attribute works as expected.""" data = setup_user_db_tests test_pg = [data["test_group3"]] data["test_user"].groups = test_pg assert data["test_user"].groups == test_pg def test_groups_attribute_elements_accepts_group_only_1(setup_user_db_tests): """TypeError raised if groups list appended a non Group object.""" data = setup_user_db_tests # append with pytest.raises(TypeError) as cm: data["test_user"].groups.append(0) assert str(cm.value) == ( "Any group in User.groups should be an instance of " "stalker.models.auth.Group, not int: '0'" ) def test_groups_attribute_elements_accepts_group_only_2(setup_user_db_tests): """TypeError raised if groups list set an item other than a Group instance.""" data = setup_user_db_tests # __setitem__ with pytest.raises(TypeError) as cm: data["test_user"].groups[0] = 0 assert str(cm.value) == ( "Any group in User.groups should be an instance of " "stalker.models.auth.Group, not int: '0'" ) def test_projects_attribute_is_none(setup_user_db_tests): """TypeError raised if the projects attribute is set to None.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].projects = None assert str(cm.value) == "'NoneType' object is not iterable" def test_projects_attribute_is_set_to_a_value_which_is_not_a_list(setup_user_db_tests): """projects attribute is accepting lists only.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].projects = "not a list" assert str(cm.value) == ( "ProjectUser.project should be a stalker.models.project.Project " "instance, not str: 'n'" ) def test_projects_attribute_is_set_to_list_of_other_objects_than_project_instances( setup_user_db_tests, ): """TypeError raised if the projects attr is not all Project instances.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].projects = ["not", "a", "list", "of", "projects", 32] assert ( str(cm.value) == "ProjectUser.project should be a stalker.models.project.Project " "instance, not str: 'not'" ) def test_projects_attribute_is_working_as_expected(setup_user_db_tests): """projects attribute is working as expected.""" data = setup_user_db_tests data["test_user"].rate = 102.0 test_list = [data["test_project1"], data["test_project2"]] data["test_user"].projects = test_list assert sorted(test_list, key=lambda x: x.name) == sorted( data["test_user"].projects, key=lambda x: x.name ) data["test_user"].projects.append(data["test_project3"]) assert data["test_project3"] in data["test_user"].projects # also check the back ref assert data["test_user"] in data["test_project1"].users assert data["test_user"] in data["test_project2"].users assert data["test_user"] in data["test_project3"].users def test_tasks_attribute_none(setup_user_db_tests): """TypeError raised if the tasks attribute is set to None.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].tasks = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_tasks_attribute_accepts_only_list_of_task_objects(setup_user_db_tests): """TypeError raised if tasks arg is not all Task instances.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].tasks = "aTask1" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_tasks_attribute_accepts_an_empty_list(setup_user_db_tests): """nothing happens if trying to assign an empty list to tasks attribute.""" data = setup_user_db_tests # this should work without any error data["test_user"].tasks = [] def test_tasks_attribute_works_as_expected(setup_user_db_tests): """tasks attribute is working as expected.""" data = setup_user_db_tests tasks = [ data["test_task1"], data["test_task2"], data["test_task3"], data["test_task4"], ] data["test_user"].tasks = tasks assert data["test_user"].tasks == tasks def test_tasks_attribute_elements_accepts_tasks_only(setup_user_db_tests): """TypeError raised if tasks is not all Task instances.""" data = setup_user_db_tests # append with pytest.raises(TypeError) as cm: data["test_user"].tasks.append(0) assert str(cm.value) == ( "Any element in User.tasks should be an instance of " "stalker.models.task.Task, not int: '0'" ) def test_equality_operator(setup_user_db_tests): """equality of two users.""" data = setup_user_db_tests data["kwargs"].update( { "name": "Generic User", "description": "this is a different user", "login": "guser", "email": "generic.user@generic.com", "password": "verysecret", } ) new_user = User(**data["kwargs"]) assert not data["test_user"] == new_user def test_inequality_operator(setup_user_db_tests): """inequality of two users.""" data = setup_user_db_tests data["kwargs"].update( { "name": "Generic User", "description": "this is a different user", "login": "guser", "email": "generic.user@generic.com", "password": "verysecret", } ) new_user = User(**data["kwargs"]) assert data["test_user"] != new_user def test___repr__(setup_user_db_tests): """representation.""" data = setup_user_db_tests assert data["test_user"].__repr__() == "<{} ('{}') (User)>".format( data["test_user"].name, data["test_user"].login ) def test_tickets_attribute_is_an_empty_list_by_default(setup_user_db_tests): """User.tickets is an empty list by default.""" data = setup_user_db_tests assert data["test_user"].tickets == [] def test_open_tickets_attribute_is_an_empty_list_by_default(setup_user_db_tests): """User.open_tickets is an empty list by default.""" data = setup_user_db_tests assert data["test_user"].open_tickets == [] def test_tickets_attribute_is_read_only(setup_user_db_tests): """User.tickets attribute is a read only attribute.""" data = setup_user_db_tests with pytest.raises(AttributeError) as cm: data["test_user"].tickets = [] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'tickets'", }.get(sys.version_info.minor, "property 'tickets' of 'User' object has no setter") assert str(cm.value) == error_message def test_open_tickets_attribute_is_read_only(setup_user_db_tests): """User.open_tickets attribute is a read only attribute.""" data = setup_user_db_tests with pytest.raises(AttributeError) as cm: data["test_user"].open_tickets = [] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'open_tickets'", }.get( sys.version_info.minor, "property 'open_tickets' of 'User' object has no setter" ) assert str(cm.value) == error_message def test_tickets_attribute_returns_all_tickets_owned_by_this_user(setup_user_db_tests): """User.tickets returns all the tickets owned by this user.""" data = setup_user_db_tests assert len(data["test_user"].tasks) == 0 # there should be no tickets assigned to this user assert data["test_user"].tickets == [] # be careful not all of these are open tickets data["test_ticket1"].reassign(data["test_user"], data["test_user"]) data["test_ticket2"].reassign(data["test_user"], data["test_user"]) data["test_ticket3"].reassign(data["test_user"], data["test_user"]) data["test_ticket4"].reassign(data["test_user"], data["test_user"]) data["test_ticket5"].reassign(data["test_user"], data["test_user"]) data["test_ticket6"].reassign(data["test_user"], data["test_user"]) data["test_ticket7"].reassign(data["test_user"], data["test_user"]) data["test_ticket8"].reassign(data["test_user"], data["test_user"]) # now we should have some tickets assert len(data["test_user"].tickets) > 0 # now check for exact items assert sorted(data["test_user"].tickets, key=lambda x: x.name) == sorted( [data["test_ticket2"], data["test_ticket3"], data["test_ticket4"]], key=lambda x: x.name, ) def test_open_tickets_attribute_returns_all_open_tickets_owned_by_this_user( setup_user_db_tests, ): """User.open_tickets returns all the open tickets owned by this user.""" data = setup_user_db_tests assert len(data["test_user"].tasks) == 0 # there should be no tickets assigned to this user assert data["test_user"].open_tickets == [] # assign the user to some tickets data["test_ticket1"].reopen(data["test_user"]) data["test_ticket2"].reopen(data["test_user"]) data["test_ticket3"].reopen(data["test_user"]) data["test_ticket4"].reopen(data["test_user"]) data["test_ticket5"].reopen(data["test_user"]) data["test_ticket6"].reopen(data["test_user"]) data["test_ticket7"].reopen(data["test_user"]) data["test_ticket8"].reopen(data["test_user"]) # be careful not all of these are open tickets data["test_ticket1"].reassign(data["test_user"], data["test_user"]) data["test_ticket2"].reassign(data["test_user"], data["test_user"]) data["test_ticket3"].reassign(data["test_user"], data["test_user"]) data["test_ticket4"].reassign(data["test_user"], data["test_user"]) data["test_ticket5"].reassign(data["test_user"], data["test_user"]) data["test_ticket6"].reassign(data["test_user"], data["test_user"]) data["test_ticket7"].reassign(data["test_user"], data["test_user"]) data["test_ticket8"].reassign(data["test_user"], data["test_user"]) # now we should have some open tickets assert len(data["test_user"].open_tickets) > 0 # now check for exact items assert sorted(data["test_user"].open_tickets, key=lambda x: x.name) == sorted( [ data["test_ticket1"], data["test_ticket2"], data["test_ticket3"], data["test_ticket4"], data["test_ticket5"], data["test_ticket6"], data["test_ticket7"], data["test_ticket8"], ], key=lambda x: x.name, ) # close a couple of them data["test_ticket1"].resolve(data["test_user"], FIXED) data["test_ticket2"].resolve(data["test_user"], INVALID) data["test_ticket3"].resolve(data["test_user"], CANTFIX) # new check again assert sorted(data["test_user"].open_tickets, key=lambda x: x.name) == sorted( [ data["test_ticket4"], data["test_ticket5"], data["test_ticket6"], data["test_ticket7"], data["test_ticket8"], ], key=lambda x: x.name, ) def test_tjp_id_is_working_as_expected(setup_user_db_tests): """tjp_id is working as expected.""" data = setup_user_db_tests assert data["test_user"].tjp_id == "User_{}".format(data["test_user"].id) def test_to_tjp_is_working_as_expected(setup_user_db_tests): """to_tjp property is working as expected.""" data = setup_user_db_tests expected_tjp = 'resource User_{} "User_{}" {{\n efficiency 1.0\n}}'.format( data["test_user"].id, data["test_user"].id ) assert data["test_user"].to_tjp == expected_tjp def test_to_tjp_is_working_as_expected_for_a_user_with_vacations(setup_user_db_tests): """to_tjp property is working as expected for a user with vacations.""" data = setup_user_db_tests personal_vacation = Type( name="Personal", code="PERS", target_entity_type="Vacation" ) vac1 = Vacation( user=data["test_user"], type=personal_vacation, start=datetime.datetime(2013, 6, 7, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 21, 0, 0, tzinfo=pytz.utc), ) DBSession.add(vac1) vac2 = Vacation( user=data["test_user"], type=personal_vacation, start=datetime.datetime(2013, 7, 1, 0, 0, tzinfo=pytz.utc), end=datetime.datetime(2013, 7, 15, 0, 0, tzinfo=pytz.utc), ) DBSession.add(vac2) expected_tjp = """resource User_{} "User_{}" {{ efficiency 1.0 vacation 2013-06-07-00:00:00 - 2013-06-21-00:00:00 vacation 2013-07-01-00:00:00 - 2013-07-15-00:00:00 }}""".format( data["test_user"].id, data["test_user"].id, ) # print("Expected:") # print("---------") # print(expected_tjp) # print('---------------') # print("Result:") # print("-------") # print(data["test_user"].to_tjp) assert data["test_user"].to_tjp == expected_tjp def test_vacations_attribute_is_set_to_none(setup_user_db_tests): """TypeError raised if the vacations attribute is set to None.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].vacations = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_vacations_attribute_is_not_a_list(setup_user_db_tests): """TypeError raised if the vacations attr is set to a value other than a list.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].vacations = "not a list of Vacation instances" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_vacations_attribute_is_not_a_list_of_vacation_instances(setup_user_db_tests): """TypeError raised if the vacations attr is not a list of all Vacation objects.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].vacations = ["list of", "other", "instances", 1] assert str(cm.value) == ( "All of the elements in User.vacations should be a " "stalker.models.studio.Vacation instance, not str: 'list of'" ) def test_vacations_attribute_is_working_as_expected(setup_user_db_tests): """vacations attribute is working as expected.""" data = setup_user_db_tests some_other_user = User( name="Some Other User", login="sou", email="some@other.user.com", password="my password", ) personal_vac_type = Type( name="Personal Vacation", code="PERS", target_entity_type="Vacation" ) vac1 = Vacation( user=some_other_user, type=personal_vac_type, start=datetime.datetime(2013, 6, 7, tzinfo=pytz.utc), end=datetime.datetime(2013, 6, 10, tzinfo=pytz.utc), ) assert vac1 not in data["test_user"].vacations data["test_user"].vacations.append(vac1) assert vac1 in data["test_user"].vacations def test_efficiency_argument_skipped(setup_user_db_tests): """efficiency attribute value 1.0 if the efficiency argument is skipped.""" data = setup_user_db_tests data["kwargs"].pop("efficiency") new_user = User(**data["kwargs"]) assert new_user.efficiency == 1.0 def test_efficiency_argument_is_none(setup_user_db_tests): """efficiency attribute value 1.0 if the efficiency argument is None.""" data = setup_user_db_tests data["kwargs"]["efficiency"] = None new_user = User(**data["kwargs"]) assert new_user.efficiency == 1.0 def test_efficiency_attribute_is_set_to_none(setup_user_db_tests): """efficiency attribute value 1.0 if it is set to None.""" data = setup_user_db_tests data["test_user"].efficiency = 4.0 data["test_user"].efficiency = None assert data["test_user"].efficiency == 1.0 def test_efficiency_argument_is_not_a_float_or_integer(setup_user_db_tests): """TypeError raised if the efficiency argument is not a float or integer.""" data = setup_user_db_tests data["kwargs"]["efficiency"] = "not a float or integer" with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == ( "User.efficiency should be a float number greater or equal to 0.0, " "not str: 'not a float or integer'" ) def test_efficiency_attribute_is_not_a_float_or_integer(setup_user_db_tests): """TypeError raised if the efficiency attr is not a float or int.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].efficiency = "not a float or integer" assert str(cm.value) == ( "User.efficiency should be a float number greater or equal to 0.0, " "not str: 'not a float or integer'" ) def test_efficiency_argument_is_a_negative_float_or_integer(setup_user_db_tests): """ValueError raised if the efficiency argument is a negative float or integer.""" data = setup_user_db_tests data["kwargs"]["efficiency"] = -1 with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert ( str(cm.value) == "User.efficiency should be a float number greater or equal to 0.0, not -1" ) def test_efficiency_attribute_is_a_negative_float_or_integer(setup_user_db_tests): """ValueError raised if the efficiency attr is set to a negative float or int.""" data = setup_user_db_tests with pytest.raises(ValueError) as cm: data["test_user"].efficiency = -2.0 assert ( str(cm.value) == "User.efficiency should be a float number greater or equal to 0.0, not -2.0" ) def test_efficiency_argument_is_working_as_expected(setup_user_db_tests): """efficiency argument value is correctly passed to the efficiency attribute.""" data = setup_user_db_tests # integer value data["kwargs"]["efficiency"] = 2 new_user = User(**data["kwargs"]) assert new_user.efficiency == 2.0 # float value data["kwargs"]["efficiency"] = 2.3 new_user = User(**data["kwargs"]) assert new_user.efficiency == 2.3 def test_efficiency_attribute_is_working_as_expected(setup_user_db_tests): """efficiency attribute value can correctly be changed""" data = setup_user_db_tests # integer assert data["test_user"].efficiency != 2 data["test_user"].efficiency = 2 assert data["test_user"].efficiency == 2.0 # float assert data["test_user"].efficiency != 2.3 data["test_user"].efficiency = 2.3 assert data["test_user"].efficiency == 2.3 def test_companies_argument_is_skipped(setup_user_db_tests): """companies attribute set to an empty list if the company argument is skipped.""" data = setup_user_db_tests data["kwargs"].pop("companies") new_user = User(**data["kwargs"]) assert new_user.companies == [] def test_companies_argument_is_none(setup_user_db_tests): """companies argument is set to None the companies attribute an empty list.""" data = setup_user_db_tests data["kwargs"]["companies"] = None new_user = User(**data["kwargs"]) assert new_user.companies == [] def test_companies_attribute_is_set_to_none(setup_user_db_tests): """the companies attribute an empty list if it is set to None.""" data = setup_user_db_tests assert data["test_user"].companies is not None with pytest.raises(TypeError) as cm: data["test_user"].companies = None assert str(cm.value) == "'NoneType' object is not iterable" def test_companies_argument_is_not_a_list(setup_user_db_tests): """TypeError raised if the companies argument is not a list.""" data = setup_user_db_tests data["kwargs"]["companies"] = "not a list of clients" with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == ( "ClientUser.client should be instance of stalker.models.client.Client, " "not str: 'n'" ) def test_companies_argument_is_not_a_list_of_client_instances(setup_user_db_tests): """TypeError raised if the companies argument is not a list of Client instances.""" data = setup_user_db_tests test_value = [1, 1.2, "a user", ["a", "user"], {"a": "user"}] data["kwargs"]["companies"] = test_value with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert ( str(cm.value) == "ClientUser.client should be instance of " "stalker.models.client.Client, not int: '1'" ) def test_companies_attribute_is_set_to_a_value_other_than_a_list_of_client_instances( setup_user_db_tests, ): """TypeError raised if the companies attr is not list of all Client instances.""" data = setup_user_db_tests test_value = [1, 1.2, "a user", ["a", "user"], {"a": "user"}] with pytest.raises(TypeError) as cm: data["test_user"].companies = test_value assert str(cm.value) == ( "ClientUser.client should be instance of stalker.models.client.Client, " "not int: '1'" ) def test_companies_attribute_is_working_as_expected(setup_user_db_tests): """from issue #27.""" new_companies = [] c1 = Client(name="Company X") c2 = Client(name="Company Y") c3 = Client(name="Company Z") DBSession.add_all([c1, c2, c3]) DBSession.commit() c1 = Client.query.filter_by(name="Company X").first() c2 = Client.query.filter_by(name="Company Y").first() c3 = Client.query.filter_by(name="Company Z").first() user = User( name="test_user", password="1234", email="a@a.com", login="test_user", clients=[c3], ) DBSession.commit() new_companies.append(c1) new_companies.append(c2) user.companies = new_companies DBSession.commit() assert c1 in user.companies assert c2 in user.companies assert c3 not in user.companies def test_companies_attribute_is_working_as_expected_2(setup_user_db_tests): """from issue #27.""" c1 = Client(name="Company X") c2 = Client(name="Company Y") c3 = Client(name="Company Z") user = User( name="Fredrik", login="fredrik", email="f@f.f", password="pass", companies=[c1, c2, c3], ) DBSession.add(user) assert c1 in DBSession assert c2 in DBSession assert c3 in DBSession DBSession.commit() c1 = Client(name="New Company X") c2 = Client(name="New Company Y") c3 = Client(name="New Company Z") DBSession.add_all([c1, c2, c3]) DBSession.commit() user = User.query.filter_by(name="Fredrik").first() user.companies = [c1, c2, c3] DBSession.commit() def test_watching_attribute_is_a_list_of_other_values_than_task(setup_user_db_tests): """TypeError raised if the watching attr not a list of all Task instances.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].watching = ["not", 1, "list of tasks"] assert str(cm.value) == ( "Any element in User.watching should be an instance of " "stalker.models.task.Task, not str: 'not'" ) def test_watching_attribute_is_working_as_expected(setup_user_db_tests): """watching attribute is working as expected.""" data = setup_user_db_tests test_value = [data["test_task1"], data["test_task2"]] assert data["test_user"].watching == [] data["test_user"].watching = test_value assert sorted(test_value, key=lambda x: x.name) == sorted( data["test_user"].watching, key=lambda x: x.name ) def test_rate_argument_is_skipped(setup_user_db_tests): """rate attribute 0 if the rate argument is skipped.""" data = setup_user_db_tests if "rate" in data["kwargs"]: data["kwargs"].pop("rate") new_user = User(**data["kwargs"]) assert new_user.rate == 0 def test_rate_argument_is_none(setup_user_db_tests): """rate attribute 0 if the rate argument is None.""" data = setup_user_db_tests data["kwargs"]["rate"] = None new_user = User(**data["kwargs"]) assert new_user.rate == 0 def test_rate_attribute_is_set_to_none(setup_user_db_tests): """rate set to 0 if it is set to None.""" data = setup_user_db_tests assert data["test_user"].rate is not None data["test_user"].rate = None assert data["test_user"].rate == 0 def test_rate_argument_is_not_a_float_or_integer_value(setup_user_db_tests): """TypeError raised if the rate argument is not an integer or float value.""" data = setup_user_db_tests data["kwargs"]["rate"] = "some string" with pytest.raises(TypeError) as cm: User(**data["kwargs"]) assert str(cm.value) == ( "User.rate should be a float number greater or equal to 0.0, " "not str: 'some string'" ) def test_rate_attribute_is_not_a_float_or_integer_value(setup_user_db_tests): """TypeError raised if the rate attr is not an int or float.""" data = setup_user_db_tests with pytest.raises(TypeError) as cm: data["test_user"].rate = "some string" assert str(cm.value) == ( "User.rate should be a float number greater or equal to 0.0, " "not str: 'some string'" ) def test_rate_argument_is_a_negative_number(setup_user_db_tests): """ValueError raised if the rate argument is a negative value.""" data = setup_user_db_tests data["kwargs"]["rate"] = -1 with pytest.raises(ValueError) as cm: User(**data["kwargs"]) assert ( str(cm.value) == "User.rate should be a float number greater or equal to 0.0, not -1" ) def test_rate_attribute_is_set_to_a_negative_number(setup_user_db_tests): """ValueError raised if the rate attribute is set to a negative number.""" data = setup_user_db_tests with pytest.raises(ValueError) as cm: data["test_user"].rate = -1 assert ( str(cm.value) == "User.rate should be a float number greater or equal to 0.0, not -1" ) def test_rate_argument_is_working_as_expected(setup_user_db_tests): """rate argument is working as expected.""" data = setup_user_db_tests test_value = 102.3 data["kwargs"]["rate"] = test_value new_user = User(**data["kwargs"]) assert new_user.rate == test_value def test_rate_attribute_is_working_as_expected(setup_user_db_tests): """rate attribute is working as expected.""" data = setup_user_db_tests test_value = 212.5 assert data["test_user"].rate != test_value data["test_user"].rate = test_value assert data["test_user"].rate == test_value def test_hash_value(): """__hash__ returns the hash of the User instance.""" user = User( name="Erkan Ozgur Yilmaz", login="eoyilmaz", password="hidden", email="eoyilmaz@fake.com", ) result = hash(user) assert isinstance(result, int) ================================================ FILE: tests/models/test_vacation.py ================================================ # -*- coding: utf-8 -*- """Tests for the Vacation class.""" import datetime import sys import pytest import pytz from stalker import Type from stalker import User from stalker import Vacation @pytest.fixture(scope="function") def setup_vacation_tests(): """Set up test for the Vacation class.""" data = dict() # create a user data["test_user"] = User( name="Test User", login="testuser", email="testuser@test.com", password="secret", ) # vacation type data["personal_vacation"] = Type( name="Personal", code="PERS", target_entity_type="Vacation" ) data["studio_vacation"] = Type( name="Studio Wide", code="STD", target_entity_type="Vacation" ) data["kwargs"] = { "user": data["test_user"], "type": data["personal_vacation"], "start": datetime.datetime(2013, 6, 6, 10, 0, tzinfo=pytz.utc), "end": datetime.datetime(2013, 6, 10, 19, 0, tzinfo=pytz.utc), } data["test_vacation"] = Vacation(**data["kwargs"]) return data def test_strictly_typed_is_false(): """__strictly_typed_ attribute is False for Vacation class.""" assert Vacation.__strictly_typed__ is False def test_user_argument_is_skipped(setup_vacation_tests): """user argument can be skipped skipped.""" data = setup_vacation_tests data["kwargs"].pop("user") Vacation(**data["kwargs"]) def test_user_argument_is_none(setup_vacation_tests): """user argument can be set to None.""" data = setup_vacation_tests data["kwargs"]["user"] = None Vacation(**data["kwargs"]) def test_user_attribute_is_none(setup_vacation_tests): """user attribute cat be set to None.""" data = setup_vacation_tests data["test_vacation"].user = None def test_user_argument_is_not_a_user_instance(setup_vacation_tests): """TypeError raised if the user arg is not a User instance.""" data = setup_vacation_tests data["kwargs"]["user"] = "not a user instance" with pytest.raises(TypeError) as cm: Vacation(**data["kwargs"]) assert str(cm.value) == ( "Vacation.user should be an instance of stalker.models.auth.User, " "not str: 'not a user instance'" ) def test_user_attribute_is_not_a_user_instance(setup_vacation_tests): """TypeError raised if the user attr is not a User instance.""" data = setup_vacation_tests with pytest.raises(TypeError) as cm: data["test_vacation"].user = "not a user instance" assert str(cm.value) == ( "Vacation.user should be an instance of stalker.models.auth.User, " "not str: 'not a user instance'" ) def test_user_argument_is_working_as_expected(setup_vacation_tests): """user argument value is correctly passed to the user attribute.""" data = setup_vacation_tests assert data["test_vacation"].user == data["kwargs"]["user"] def test_user_attribute_is_working_as_expected(setup_vacation_tests): """user attribute is working as expected.""" data = setup_vacation_tests new_user = User( name="test user 2", login="testuser2", email="test@user.com", password="secret" ) assert data["test_vacation"].user != new_user data["test_vacation"].user = new_user assert data["test_vacation"].user == new_user def test_user_argument_back_populates_vacations_attribute(setup_vacation_tests): """user argument back populates vacations attribute of the User instance.""" data = setup_vacation_tests assert data["test_vacation"] in data["kwargs"]["user"].vacations def test_user_attribute_back_populates_vacations_attribute(setup_vacation_tests): """user attribute back populates vacations attribute of the User instance.""" data = setup_vacation_tests new_user = User( name="test user 2", login="testuser2", email="test@user.com", password="secret" ) data["test_vacation"].user = new_user assert data["test_vacation"] in new_user.vacations def test_to_tjp_attribute_is_a_read_only_property(setup_vacation_tests): """to_tjp is a read-only attribute.""" data = setup_vacation_tests with pytest.raises(AttributeError) as cm: data["test_vacation"].to_tjp = "some value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'to_tjp'", }.get( sys.version_info.minor, "property 'to_tjp' of 'Vacation' object has no setter" ) assert str(cm.value) == error_message def test_to_tjp_attribute_is_working_as_expected(setup_vacation_tests): """to_tjp attribute is working as expected.""" data = setup_vacation_tests # TODO: Vacation should also use time zone info expected_tjp = "vacation 2013-06-06-10:00:00 - 2013-06-10-19:00:00" assert data["test_vacation"].to_tjp == expected_tjp ================================================ FILE: tests/models/test_variant.py ================================================ # -*- coding: utf-8 -*- """Tests for the Variant class.""" import pytest from stalker.models.auth import User from stalker.models.project import Project from stalker.models.repository import Repository from stalker.models.type import Type from stalker.models.variant import Variant from stalker.models.status import Status, StatusList from stalker.models.task import Task from stalker.models.version import Version @pytest.fixture(scope="function") def setup_variant_tests(): """Setup Variant tests.""" data = dict() data["status_wfd"] = Status(name="Waiting For Dependency", code="WFD") data["status_rts"] = Status(name="Ready To Start", code="RTS") data["status_wip"] = Status(name="Work In Progress", code="WIP") data["status_prev"] = Status(name="Pending Review", code="PREV") data["status_hrev"] = Status(name="Has Revision", code="HREV") data["status_drev"] = Status(name="Dependency Has Revision", code="DREV") data["status_oh"] = Status(name="On Hold", code="OH") data["status_stop"] = Status(name="Stopped", code="STOP") data["status_cmpl"] = Status(name="Completed", code="CMPL") data["task_status_list"] = StatusList( statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Task", ) data["variant_status_list"] = StatusList( statuses=[ data["status_wfd"], data["status_rts"], data["status_wip"], data["status_prev"], data["status_hrev"], data["status_drev"], data["status_oh"], data["status_stop"], data["status_cmpl"], ], target_entity_type="Variant", ) data["test_project_status_list"] = StatusList( name="Project Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type="Project", ) data["test_variant_status_list"] = StatusList( name="Variant Statuses", statuses=[data["status_wip"], data["status_prev"], data["status_cmpl"]], target_entity_type="Variant", ) data["test_movie_project_type"] = Type( name="Movie Project", code="movie", target_entity_type="Project", ) data["test_repository_type"] = Type( name="Test Repository Type", code="test", target_entity_type="Repository", ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["test_repository_type"], linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) data["test_user1"] = User( name="User1", login="user1", email="user1@user1.com", password="1234" ) data["test_user2"] = User( name="User2", login="user2", email="user2@user2.com", password="1234" ) data["test_user3"] = User( name="User3", login="user3", email="user3@user3.com", password="1234" ) data["test_user4"] = User( name="User4", login="user4", email="user4@user4.com", password="1234" ) data["test_user5"] = User( name="User5", login="user5", email="user5@user5.com", password="1234" ) data["test_project1"] = Project( name="Test Project1", code="tp1", type=data["test_movie_project_type"], status_list=data["test_project_status_list"], repositories=[data["test_repository"]], ) data["test_variant"] = Variant( name="Main", project=data["test_project1"], status_list=data["test_variant_status_list"], ) yield data # @pytest.fixture(scope="function") # def setup_variant_db_tests(setup_postgresql_db): # """Setup Variant tests with a test DB.""" # data = dict() def test_variant_is_derived_from_task(): """Variant is deriving from Task class.""" assert Task in Variant.__mro__ def test_variant_entity_type_is_variant(setup_variant_tests): """Variant.entity_type is "Variant".""" data = setup_variant_tests assert data["test_variant"].entity_type == "Variant" def test_variant_is_not_auto_named(): """Variant.__auto_name__ is False.""" assert Variant.__auto_name__ is False def test_variant_can_be_used_in_task_hierarchies(setup_variant_tests): """Variant instances can be used in task hierarchies.""" data = setup_variant_tests task = Task( name="Test Task 1", project=data["test_project1"], status_list=data["task_status_list"], ) # should not raise any errors variant = data["test_variant"] variant.parent = task assert variant.parent == task def test_variant_accepts_version_instances(setup_variant_tests): """Variant instances accepts Version instances.""" data = setup_variant_tests variant = data["test_variant"] version = Version(task=variant) assert version in variant.versions ================================================ FILE: tests/models/test_version.py ================================================ # -*- coding: utf-8 -*- import copy import logging from pathlib import Path import sys import pytest from stalker import ( Asset, FilenameTemplate, File, Project, Repository, Scene, Sequence, Shot, Status, StatusList, Structure, Task, Type, User, Variant, Version, defaults, log, ) from stalker.db.session import DBSession from stalker.exceptions import CircularDependencyError from stalker.models.entity import Entity from tests.utils import PlatformPatcher logger = logging.getLogger("stalker.models.version.Version") logger.setLevel(log.logging_level) @pytest.fixture(scope="function") def setup_version_db_tests(setup_postgresql_db): """Set up the tests for the Version class with a DB.""" data = dict() data["patcher"] = PlatformPatcher() # Users data["test_user1"] = User( name="Test User 1", login="tuser1", email="tuser1@test.com", password="secret", ) data["test_user2"] = User( name="Test User 2", login="tuser2", email="tuser2@test.com", password="secret", ) # statuses data["status_wip"] = Status.query.filter_by(code="WIP").first() # repository data["test_repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) DBSession.add(data["test_repo"]) # a project type data["test_project_type"] = Type( name="Test", code="test", target_entity_type="Project", ) DBSession.add(data["test_project_type"]) # create a filename template for Variants data["test_filename_template"] = FilenameTemplate( name="Variant Filename Template", target_entity_type="Variant", path="{{project.code}}/{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/{%- endfor -%}", filename="{{version.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}' "{{extension}}", ) DBSession.add(data["test_filename_template"]) DBSession.commit() # create a structure data["test_structure"] = Structure( name="Test Project Structure", templates=[data["test_filename_template"]] ) DBSession.add(data["test_structure"]) # create a project data["test_project"] = Project( name="Test Project", code="tp", type=data["test_project_type"], repositories=[data["test_repo"]], structure=data["test_structure"], ) DBSession.add(data["test_project"]) DBSession.commit() # create a sequence data["test_sequence"] = Sequence( name="Test Sequence", code="SEQ1", project=data["test_project"], ) DBSession.add(data["test_sequence"]) DBSession.commit() data["test_scene"] = Scene( name="Test Scene", code="SC001", project=data["test_project"], ) DBSession.add(data["test_scene"]) DBSession.commit() # create a shot data["test_shot1"] = Shot( name="SH001", code="SH001", project=data["test_project"], sequence=data["test_sequence"], scene=data["test_scene"], ) DBSession.add(data["test_shot1"]) DBSession.commit() # create a group of Tasks for the shot data["test_task1"] = Task(name="FX", parent=data["test_shot1"]) DBSession.add(data["test_task1"]) DBSession.commit() data["test_variant1"] = Variant(name="Main", parent=data["test_task1"]) DBSession.add(data["test_variant1"]) DBSession.commit() # a File for the files attribute data["test_file1"] = File( name="File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "SH001_FX_Main_r01_v001.ma", ) DBSession.add(data["test_file1"]) data["test_file2"] = File( name="File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "SH001_FX_Main_r01_v002.ma", ) DBSession.add(data["test_file2"]) # a File for the input file data["test_input_file1"] = File( name="Input File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_beauty_v001.###.exr", ) DBSession.add(data["test_input_file1"]) data["test_input_file2"] = File( name="Input File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_occ_v001.###.exr", ) DBSession.add(data["test_input_file2"]) # a File for the output file data["test_output_file1"] = File( name="Output File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_beauty_v001.###.exr", ) DBSession.add(data["test_output_file1"]) data["test_output_file2"] = File( name="Output File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_occ_v001.###.exr", ) DBSession.add(data["test_output_file2"]) DBSession.commit() # now create a version for the Task data["kwargs"] = { "files": [data["test_file1"], data["test_file2"]], "task": data["test_variant1"], } # and the Version data["test_version"] = Version(**data["kwargs"]) DBSession.add(data["test_version"]) # set the published to False data["test_version"].is_published = False DBSession.commit() yield data # clean up test data["patcher"].restore() def test___auto_name__class_attribute_is_set_to_true(): """__auto_name__ class attribute is set to True for Version class.""" assert Version.__auto_name__ is True def test_version_derives_from_entity(): """Version class derives from Entity.""" assert Entity == Version.__mro__[1] def test_task_argument_is_skipped(setup_version_db_tests): """TypeError raised if the task argument is skipped.""" data = setup_version_db_tests data["kwargs"].pop("task") with pytest.raises(TypeError) as cm: Version(**data["kwargs"]) assert str(cm.value) == "Version.task cannot be None" def test_task_argument_is_none(setup_version_db_tests): """TypeError raised if the task argument is None.""" data = setup_version_db_tests data["kwargs"]["task"] = None with pytest.raises(TypeError) as cm: Version(**data["kwargs"]) assert str(cm.value) == "Version.task cannot be None" def test_task_attribute_is_none(setup_version_db_tests): """TypeError raised if the task attribute is None.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].task = None assert str(cm.value) == "Version.task cannot be None" def test_task_argument_is_not_a_task(setup_version_db_tests): """TypeError raised if the task argument is not a Task instance.""" data = setup_version_db_tests data["kwargs"]["task"] = "a task" with pytest.raises(TypeError) as cm: Version(**data["kwargs"]) assert str(cm.value) == ( "Version.task should be a Task, Asset, Shot, Scene, Sequence or Variant " "instance, not str: 'a task'" ) def test_task_attribute_is_not_a_task(setup_version_db_tests): """TypeError raised if the task attribute is not a Task instance.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].task = "a task" assert str(cm.value) == ( "Version.task should be a Task, Asset, Shot, Scene, Sequence or Variant " "instance, not str: 'a task'" ) def test_task_attribute_is_working_as_expected(setup_version_db_tests): """task attribute is working as expected.""" data = setup_version_db_tests new_task = Variant( name="New Test Variant", parent=data["test_shot1"], ) DBSession.add(new_task) assert data["test_version"].task is not new_task data["test_version"].task = new_task assert data["test_version"].task is new_task def test_revision_number_arg_is_skipped(setup_version_db_tests): """revision_number arg can be skipped.""" data = setup_version_db_tests new_version = Version(**data["kwargs"]) assert isinstance(new_version, Version) assert new_version.revision_number == 1 def test_revision_number_arg_is_none(setup_version_db_tests): """revision_number arg can be None.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = None new_version = Version(**data["kwargs"]) assert isinstance(new_version, Version) assert new_version.revision_number == 1 def test_revision_number_attr_cannot_be_set_to_none(setup_version_db_tests): """revision_number can be None.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 12 new_version = Version(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_version.revision_number = None assert str(cm.value) == ( "Version.revision_number should be a positive integer, not NoneType: 'None'" ) def test_revision_number_arg_is_not_an_integer(setup_version_db_tests): """revision_number arg is not an integer raises TypeError.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = "not an integer" with pytest.raises(TypeError) as cm: _ = Version(**data["kwargs"]) assert str(cm.value) == ( "Version.revision_number should be a positive integer, " "not str: 'not an integer'" ) def test_revision_number_attr_is_not_an_integer(setup_version_db_tests): """revision_number attr is not an integer raises TypeError.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 14 new_version = Version(**data["kwargs"]) with pytest.raises(TypeError) as cm: new_version.revision_number = "not an integer" assert str(cm.value) == ( "Version.revision_number should be a positive integer, " "not str: 'not an integer'" ) def test_revision_number_arg_is_not_a_positive_integer(setup_version_db_tests): """revision_number arg is not a positive integer raises ValueError.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = -109 with pytest.raises(ValueError) as cm: _ = Version(**data["kwargs"]) assert str(cm.value) == ( "Version.revision_number should be a positive integer, " "not int: '-109'" ) def test_revision_number_attr_is_not_a_positive_integer(setup_version_db_tests): """revision_number attr is not a positive integer raises ValueError.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 153 new_version = Version(**data["kwargs"]) with pytest.raises(ValueError) as cm: new_version.revision_number = -109 assert str(cm.value) == ( "Version.revision_number should be a positive integer, " "not int: '-109'" ) def test_revision_number_arg_can_be_non_sequential(setup_version_db_tests): """revision_number arg can be set to any positive number.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 21 new_version = Version(**data["kwargs"]) assert isinstance(new_version, Version) assert new_version.revision_number == 21 def test_revision_number_attr_can_be_non_sequential(setup_version_db_tests): """revision_number attr can be set to any positive number.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 21 new_version = Version(**data["kwargs"]) assert new_version.revision_number != 13 new_version.revision_number = 13 assert new_version.revision_number == 13 def test_revision_number_attr_changed_will_reset_version_number(setup_version_db_tests): """revision_number attr can be set to any positive number.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 21 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.version_number == 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.version_number == 2 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.version_number == 3 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.version_number == 4 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.version_number == 5 assert new_version.revision_number != 13 new_version.revision_number = 13 DBSession.save(new_version) assert new_version.revision_number == 13 assert new_version.version_number == 1 new_version.revision_number = 21 assert new_version.version_number == 5 def test_revision_number_attr_not_changed_will_not_reset_version_number( setup_version_db_tests, ): """revision_number attr can be set to any positive number.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 21 new_version = Version(**data["kwargs"]) DBSession.save(new_version) new_version = Version(**data["kwargs"]) DBSession.save(new_version) new_version = Version(**data["kwargs"]) DBSession.save(new_version) new_version = Version(**data["kwargs"]) DBSession.save(new_version) new_version = Version(**data["kwargs"]) DBSession.save(new_version) new_version.revision_number = 13 DBSession.save(new_version) new_version.revision_number = 21 DBSession.save(new_version) new_version.revision_number = 21 assert new_version.version_number == 5 def test_revision_number_arg_value_is_passed_to_revision_number_attr( setup_version_db_tests, ): """revision_number arg value is passed to revision_number attr.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 21 new_version = Version(**data["kwargs"]) assert isinstance(new_version, Version) assert new_version.revision_number == 21 def test_revision_number_arg_effects_version_number(setup_version_db_tests): """revision_number arg effects version_number value.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 1 assert new_version.version_number == 2 # second version new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 1 assert new_version.version_number == 3 # third version new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 1 assert new_version.version_number == 4 # new revision_number series data["kwargs"]["revision_number"] = 2 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 2 assert new_version.version_number == 1 # second version new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 2 assert new_version.version_number == 2 # back to revision_number 1 data["kwargs"]["revision_number"] = 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert isinstance(new_version, Version) assert new_version.revision_number == 1 assert new_version.version_number == 5 def test_max_revision_number_returns_the_maximum_revision_number_in_the_db( setup_version_db_tests, ): """max_revision_number returns the maximum value of the revision_number in the db.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 1 assert new_version.version_number == 2 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 1 assert new_version.version_number == 3 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 1 assert new_version.version_number == 4 # new revision_number series data["kwargs"]["revision_number"] = 2 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 2 assert new_version.version_number == 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 2 assert new_version.version_number == 2 # back to revision_number 1 data["kwargs"]["revision_number"] = 1 new_version = Version(**data["kwargs"]) DBSession.save(new_version) assert new_version.revision_number == 1 assert new_version.max_revision_number == 2 def test_max_revision_number_returns_the_maximum_revision_number_in_the_db_when_no_version( setup_version_db_tests, ): """max_revision_number returns the maximum value of the revision_number in the db when no version is created.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 1 new_version = Version(**data["kwargs"]) assert new_version.max_revision_number == 1 def test_version_number_attribute_is_automatically_generated(setup_version_db_tests): """version_number attribute is automatically generated.""" data = setup_version_db_tests assert data["test_version"].version_number == 1 DBSession.add(data["test_version"]) DBSession.commit() new_version = Version(**data["kwargs"]) DBSession.add(new_version) DBSession.commit() assert data["test_version"].task == new_version.task assert new_version.version_number == 2 new_version = Version(**data["kwargs"]) DBSession.add(new_version) DBSession.commit() assert data["test_version"].task == new_version.task assert new_version.version_number == 3 new_version = Version(**data["kwargs"]) DBSession.add(new_version) DBSession.commit() assert data["test_version"].task == new_version.task assert new_version.version_number == 4 def test_version_number_attribute_is_starting_from_1(setup_version_db_tests): """version_number attribute is starting from 1.""" data = setup_version_db_tests assert data["test_version"].version_number == 1 def test_version_number_attribute_is_set_to_a_lower_then_it_should_be( setup_version_db_tests, ): """version_number attr is set to unique number if it smaller than what it should be.""" data = setup_version_db_tests data["test_version"].version_number = -1 assert data["test_version"].version_number == 1 data["test_version"].version_number = -10 assert data["test_version"].version_number == 1 DBSession.add(data["test_version"]) DBSession.commit() data["test_version"].version_number = -100 # it should be 1 again assert data["test_version"].version_number == 1 new_version = Version(**data["kwargs"]) assert new_version.version_number == 2 new_version.version_number = 1 assert new_version.version_number == 2 new_version.version_number = 100 assert new_version.version_number == 100 def test_files_argument_is_skipped(setup_version_db_tests): """files attribute an empty list if the files argument is skipped.""" data = setup_version_db_tests data["kwargs"].pop("files") new_version = Version(**data["kwargs"]) assert new_version.files == [] def test_files_argument_is_none(setup_version_db_tests): """files attribute an empty list if the files argument is None.""" data = setup_version_db_tests data["kwargs"]["files"] = None new_version = Version(**data["kwargs"]) assert new_version.files == [] def test_files_attribute_is_none(setup_version_db_tests): """TypeError raised if the files argument is set to None.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].files = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_files_argument_is_not_a_list_of_file_instances(setup_version_db_tests): """TypeError raised if the files attr is not a list of File instances.""" data = setup_version_db_tests test_value = [132, "231123"] data["kwargs"]["files"] = test_value with pytest.raises(TypeError) as cm: Version(**data["kwargs"]) assert ( str(cm.value) == "Version.files should only contain instances of " "stalker.models.file.File, not int: '132'" ) def test_files_attribute_is_not_a_list_of_file_instances(setup_version_db_tests): """TypeError raised if the files attr is set to something other than a File.""" data = setup_version_db_tests test_value = [132, "231123"] with pytest.raises(TypeError) as cm: data["test_version"].files = test_value assert ( str(cm.value) == "Version.files should only contain instances of " "stalker.models.file.File, not int: '132'" ) def test_files_attribute_is_working_as_expected(setup_version_db_tests): """files attribute is working as expected.""" data = setup_version_db_tests data["kwargs"].pop("files") new_version = Version(**data["kwargs"]) assert data["test_file1"] not in new_version.files assert data["test_file2"] not in new_version.files new_version.files = [data["test_file1"], data["test_file2"]] assert data["test_file1"] in new_version.files assert data["test_file2"] in new_version.files def test_is_published_attribute_is_false_by_default(setup_version_db_tests): """is_published attribute is False by default.""" data = setup_version_db_tests assert data["test_version"].is_published is False def test_is_published_attribute_is_working_as_expected(setup_version_db_tests): """is_published attribute is working as expected.""" data = setup_version_db_tests data["test_version"].is_published = True assert data["test_version"].is_published is True data["test_version"].is_published = False assert data["test_version"].is_published is False def test_parent_argument_is_skipped(setup_version_db_tests): """parent attribute None if the parent argument is skipped.""" data = setup_version_db_tests try: data["kwargs"].pop("parent") except KeyError: pass new_version = Version(**data["kwargs"]) assert new_version.parent is None def test_parent_argument_is_none(setup_version_db_tests): """parent attribute None if the parent argument is skipped.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version = Version(**data["kwargs"]) assert new_version.parent is None def test_parent_attribute_is_none(setup_version_db_tests): """parent attribute value None if it is set to None.""" data = setup_version_db_tests data["test_version"].parent = None assert data["test_version"].parent is None def test_parent_argument_is_not_a_version_instance(setup_version_db_tests): """TypeError raised if the parent argument is not a Version instance.""" data = setup_version_db_tests data["kwargs"]["parent"] = "not a version instance" with pytest.raises(TypeError) as cm: Version(**data["kwargs"]) assert str(cm.value) == ( "Version.parent should be an instance of Version class or " "derivative, not str: 'not a version instance'" ) def test_parent_attribute_is_not_set_to_a_version_instance(setup_version_db_tests): """TypeError raised if the parent attribute is not set to a Version instance.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].parent = "not a version instance" assert str(cm.value) == ( "Version.parent should be an instance of Version class or " "derivative, not str: 'not a version instance'" ) def test_parent_argument_is_working_as_expected(setup_version_db_tests): """parent argument is working as expected.""" data = setup_version_db_tests data["kwargs"]["parent"] = data["test_version"] new_version = Version(**data["kwargs"]) assert new_version.parent == data["test_version"] def test_parent_attribute_is_working_as_expected(setup_version_db_tests): """parent attribute is working as expected.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version = Version(**data["kwargs"]) assert new_version.parent != data["test_version"] new_version.parent = data["test_version"] assert new_version.parent == data["test_version"] def test_parent_argument_updates_the_children_attribute(setup_version_db_tests): """parent argument updates the children attribute of the parent Version.""" data = setup_version_db_tests data["kwargs"]["parent"] = data["test_version"] new_version = Version(**data["kwargs"]) DBSession.add(new_version) assert new_version in data["test_version"].children def test_parent_attribute_updates_the_children_attribute(setup_version_db_tests): """parent attr updates the children attribute of the parent Version.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version = Version(**data["kwargs"]) DBSession.add(new_version) assert new_version.parent != data["test_version"] new_version.parent = data["test_version"] assert new_version in data["test_version"].children def test_parent_attribute_will_not_allow_circular_dependencies(setup_version_db_tests): """CircularDependency raised if parent attr is a child of the current Version.""" data = setup_version_db_tests data["kwargs"]["parent"] = data["test_version"] version1 = Version(**data["kwargs"]) DBSession.add(version1) with pytest.raises(CircularDependencyError) as cm: data["test_version"].parent = version1 assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_parent_attribute_will_not_allow_deeper_circular_dependencies( setup_version_db_tests, ): """CircularDependency raised if the Version is a parent of the current parent.""" data = setup_version_db_tests data["kwargs"]["parent"] = data["test_version"] version1 = Version(**data["kwargs"]) DBSession.add(version1) data["kwargs"]["parent"] = version1 version2 = Version(**data["kwargs"]) DBSession.add(version2) # now create circular dependency with pytest.raises(CircularDependencyError) as cm: data["test_version"].parent = version2 assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_children_attribute_is_set_to_none(setup_version_db_tests): """TypeError raised if the children attribute is set to None.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].children = None assert str(cm.value) == "Incompatible collection type: None is not list-like" def test_children_attribute_is_not_set_to_a_list(setup_version_db_tests): """TypeError raised if the children attribute is not set to a list.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].children = "not a list of Version instances" assert str(cm.value) == "Incompatible collection type: str is not list-like" def test_children_attribute_is_not_set_to_a_list_of_version_instances( setup_version_db_tests, ): """TypeError raised if the children attr is not all Version instances.""" data = setup_version_db_tests with pytest.raises(TypeError) as cm: data["test_version"].children = ["not a Version instance", 3] assert str(cm.value) == ( "Version.children should only contain instances of " "Version (or derivative), not str: 'not a Version instance'" ) def test_children_attribute_is_working_as_expected(setup_version_db_tests): """children attribute is working as expected.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) data["test_version"].children = [new_version1] assert new_version1 in data["test_version"].children new_version2 = Version(**data["kwargs"]) data["test_version"].children.append(new_version2) assert new_version2 in data["test_version"].children def test_children_attribute_updates_parent_attribute(setup_version_db_tests): """children attribute updates the parent attribute of the children Versions.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) data["test_version"].children = [new_version1] assert new_version1.parent == data["test_version"] new_version2 = Version(**data["kwargs"]) data["test_version"].children.append(new_version2) assert new_version2.parent == data["test_version"] def test_children_attribute_will_not_allow_circular_dependencies( setup_version_db_tests, ): """CircularDependency error raised if a parent is set as a child to its child.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() new_version2 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() new_version1.parent = new_version2 with pytest.raises(CircularDependencyError) as cm: new_version1.children.append(new_version2) assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_children_attribute_will_not_allow_deeper_circular_dependencies( setup_version_db_tests, ): """CircularDependency error raised if a parent Version of a parent Version is set as a children to its grand child.""" data = setup_version_db_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() new_version2 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() new_version3 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() new_version1.parent = new_version2 new_version2.parent = new_version3 with pytest.raises(CircularDependencyError) as cm: new_version1.children.append(new_version3) assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_generate_path_extension_can_be_skipped(setup_version_db_tests): """generate_path() extension can be skipped.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() # extension can be skipped _ = new_version1.generate_path() def test_generate_path_extension_can_be_None(setup_version_db_tests): """generate_path() extension can be None.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() # extension can be skipped path = new_version1.generate_path(extension=None) assert path.suffix == "" def test_generate_path_extension_is_not_a_str(setup_version_db_tests): """generate_path() extension is not a str will raise a TypeError.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() # extension is not str raises TypeError with pytest.raises(TypeError) as cm: _ = new_version1.generate_path(extension=1234) assert str(cm.value) == "extension should be a str, not int: '1234'" def test_generate_path_extension_can_be_an_empty_str(setup_version_db_tests): """generate_path() extension can be an empty str.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() # extension can be an empty string path = new_version1.generate_path(extension="") assert path.suffix == "" def test_generate_path_will_render_the_appropriate_template_from_the_related_project( setup_version_db_tests, ): """generate_path() generates a Path by rendering the related Project FilenameTemplate.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() path = new_version1.generate_path() assert isinstance(path, Path) assert str(path.parent) == "tp/SH001/FX/Main" path = path.with_suffix(".ma") assert str(path.name) == "SH001_FX_Main_r01_v002.ma" def test_generate_path_will_use_the_given_extension(setup_version_db_tests): """generate_path method uses the given extension.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() path = new_version1.generate_path(extension=".ma") assert isinstance(path, Path) assert str(path.parent) == "tp/SH001/FX/Main" assert str(path.name) == "SH001_FX_Main_r01_v002.ma" def test_generate_path_will_raise_a_runtime_error_if_there_is_no_suitable_filename_template( setup_version_db_tests, ): """generate_path method raises a RuntimeError if there is no suitable FilenameTemplate instance found.""" data = setup_version_db_tests data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.commit() DBSession.delete(data["test_filename_template"]) DBSession.commit() data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) with pytest.raises(RuntimeError) as cm: new_version1.generate_path() assert ( str(cm.value) == "There are no suitable FilenameTemplate (target_entity_type == " "'Variant') defined in the Structure of the related Project " "instance, please create a new " "stalker.models.template.FilenameTemplate instance with its " "'target_entity_type' attribute is set to 'Variant' and add it " "to the `templates` attribute of the structure of the project" ) def test_template_variables_project(setup_version_db_tests): """project in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["project"] == data["test_version"].task.project def test_template_variables_sequence(setup_version_db_tests): """sequence in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["sequence"] == data["test_sequence"] def test_template_variables_scene(setup_version_db_tests): """scene in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["scene"] == data["test_scene"] def test_template_variables_shot(setup_version_db_tests): """shot in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["shot"] is None def test_template_variables_asset(setup_version_db_tests): """asset in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["asset"] is None def test_template_variables_task(setup_version_db_tests): """task in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["task"] == data["test_version"].task def test_template_variables_parent_tasks(setup_version_db_tests): """parent_tasks in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() parents = data["test_version"].task.parents parents.append(data["test_version"].task) assert kwargs["parent_tasks"] == parents def test_template_variables_version(setup_version_db_tests): """version in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["version"] == data["test_version"] def test_template_variables_type(setup_version_db_tests): """type in template variables is correct.""" data = setup_version_db_tests kwargs = data["test_version"]._template_variables() assert kwargs["type"] == data["test_version"].type def test_template_variables_for_a_shot_version_contains_scene(setup_version_db_tests): """template_variables for a Shot version contains scene.""" data = setup_version_db_tests v = Version(task=data["test_shot1"]) template_variables = v._template_variables() assert data["test_shot1"].scene is not None assert "scene" in template_variables assert template_variables["scene"] == data["test_shot1"].scene def test_template_variables_for_a_shot_version_contains_sequence( setup_version_db_tests, ): """template_variables for a Shot version contains sequence.""" data = setup_version_db_tests v = Version(task=data["test_shot1"]) template_variables = v._template_variables() assert data["test_shot1"].sequence is not None assert "sequence" in template_variables assert template_variables["sequence"] == data["test_shot1"].sequence def test_absolute_path_works_as_expected(setup_version_db_tests): """absolute_path attribute works as expected.""" data = setup_version_db_tests # data["patcher"].patch("Linux") data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.delete(data["test_filename_template"]) DBSession.commit() ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Variant", path="$REPO{{project.repositories[0].code}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) data["test_project"].structure.templates.append(ft) new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() repo_path = data["test_repo"].path assert new_version1.absolute_path == Path(f"{repo_path}/tp/SH001/FX/Main") def test_absolute_full_path_works_as_expected(setup_version_db_tests): """absolute_full_path attribute works as expected.""" data = setup_version_db_tests # data["patcher"].patch("Linux") data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.delete(data["test_filename_template"]) DBSession.commit() ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Variant", path="$REPO{{project.repositories[0].code}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) data["test_project"].structure.templates.append(ft) new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() repo_path = data["test_repo"].path assert new_version1.absolute_full_path == Path( f"{repo_path}/tp/SH001/FX/Main/Main_r01_v002" ) def test_path_works_as_expected(setup_version_db_tests): """path attribute works as expected.""" data = setup_version_db_tests data["patcher"].patch("Linux") data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.delete(data["test_filename_template"]) DBSession.commit() ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Variant", path="$REPO{{project.repositories[0].code}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) data["test_project"].structure.templates.append(ft) new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() assert new_version1.path == Path("$REPOTR/tp/SH001/FX/Main") def test_full_path_works_as_expected(setup_version_db_tests): """full_path attribute works as expected.""" data = setup_version_db_tests data["patcher"].patch("Linux") data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.delete(data["test_filename_template"]) DBSession.commit() ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Variant", path="$REPO{{project.repositories[0].code}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) data["test_project"].structure.templates.append(ft) new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() assert new_version1.full_path == Path("$REPOTR/tp/SH001/FX/Main/Main_r01_v002") def test_filename_works_as_expected(setup_version_db_tests): """filename attribute works as expected.""" data = setup_version_db_tests data["patcher"].patch("Linux") data["test_structure"].templates.remove(data["test_filename_template"]) DBSession.delete(data["test_filename_template"]) DBSession.commit() ft = FilenameTemplate( name="Task Filename Template", target_entity_type="Variant", path="$REPO{{project.repositories[0].code}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", filename="{{task.nice_name}}" '_r{{"%02d"|format(version.revision_number)}}' '_v{{"%03d"|format(version.version_number)}}', ) data["test_project"].structure.templates.append(ft) new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() assert new_version1.filename == "Main_r01_v002" assert isinstance(new_version1.filename, str) def test_latest_published_version_is_read_only(setup_version_db_tests): """latest_published_version is a read only attribute.""" data = setup_version_db_tests with pytest.raises(AttributeError) as cm: data["test_version"].latest_published_version = True error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'latest_published_version'", }.get( sys.version_info.minor, "property 'latest_published_version' of 'Version' object has no setter", ) assert str(cm.value) == error_message def test_latest_published_version_is_working_as_expected(setup_version_db_tests): """is_latest_published_version is working as expected.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.save(new_version1) new_version2 = Version(**data["kwargs"]) DBSession.save(new_version2) new_version3 = Version(**data["kwargs"]) DBSession.save(new_version3) new_version4 = Version(**data["kwargs"]) DBSession.save(new_version4) new_version5 = Version(**data["kwargs"]) DBSession.save(new_version5) # with new revision number data["kwargs"]["revision_number"] = 2 new_version6 = Version(**data["kwargs"]) DBSession.save(new_version6) new_version7 = Version(**data["kwargs"]) DBSession.save(new_version7) new_version8 = Version(**data["kwargs"]) DBSession.save(new_version8) new_version1.is_published = True new_version3.is_published = True new_version4.is_published = True new_version7.is_published = True assert new_version1.latest_published_version == new_version4 assert new_version2.latest_published_version == new_version4 assert new_version3.latest_published_version == new_version4 assert new_version4.latest_published_version == new_version4 assert new_version5.latest_published_version == new_version4 assert new_version6.latest_published_version == new_version7 assert new_version7.latest_published_version == new_version7 assert new_version8.latest_published_version == new_version7 def test_is_latest_published_version_is_working_as_expected(setup_version_db_tests): """is_latest_published_version is working as expected.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.save(new_version1) new_version2 = Version(**data["kwargs"]) DBSession.save(new_version2) new_version3 = Version(**data["kwargs"]) DBSession.save(new_version3) new_version4 = Version(**data["kwargs"]) DBSession.save(new_version4) new_version5 = Version(**data["kwargs"]) DBSession.save(new_version5) # with new revision number data["kwargs"]["revision_number"] = 2 new_version6 = Version(**data["kwargs"]) DBSession.save(new_version6) new_version7 = Version(**data["kwargs"]) DBSession.save(new_version7) new_version8 = Version(**data["kwargs"]) DBSession.save(new_version8) new_version1.is_published = True new_version3.is_published = True new_version4.is_published = True new_version7.is_published = True assert new_version1.is_latest_published_version() is False assert new_version2.is_latest_published_version() is False assert new_version3.is_latest_published_version() is False assert new_version4.is_latest_published_version() is True assert new_version5.is_latest_published_version() is False assert new_version6.is_latest_published_version() is False assert new_version7.is_latest_published_version() is True assert new_version8.is_latest_published_version() is False def test_equality_operator(setup_version_db_tests): """equality of two Version instances.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.save(new_version1) new_version2 = Version(**data["kwargs"]) DBSession.save(new_version2) new_version3 = Version(**data["kwargs"]) DBSession.save(new_version3) new_version4 = Version(**data["kwargs"]) DBSession.save(new_version4) # new_version5 = Version(**data["kwargs"]) DBSession.save(new_version5) # with new revision number data["kwargs"]["revision_number"] = 2 new_version6 = Version(**data["kwargs"]) DBSession.save(new_version6) new_version7 = Version(**data["kwargs"]) DBSession.save(new_version7) new_version8 = Version(**data["kwargs"]) DBSession.save(new_version8) new_version1.is_published = True new_version3.is_published = True new_version4.is_published = True new_version7.is_published = True assert (new_version1 == new_version1) is True assert (new_version1 == new_version2) is False assert (new_version1 == new_version3) is False assert (new_version1 == new_version4) is False assert (new_version1 == new_version5) is False assert (new_version1 == new_version6) is False assert (new_version1 == new_version7) is False assert (new_version1 == new_version8) is False assert (new_version2 == new_version2) is True assert (new_version2 == new_version3) is False assert (new_version2 == new_version4) is False assert (new_version2 == new_version5) is False assert (new_version2 == new_version6) is False assert (new_version2 == new_version7) is False assert (new_version2 == new_version8) is False assert (new_version3 == new_version3) is True assert (new_version3 == new_version4) is False assert (new_version3 == new_version5) is False assert (new_version3 == new_version6) is False assert (new_version3 == new_version7) is False assert (new_version3 == new_version8) is False assert (new_version4 == new_version4) is True assert (new_version4 == new_version5) is False assert (new_version4 == new_version6) is False assert (new_version4 == new_version7) is False assert (new_version4 == new_version8) is False assert (new_version5 == new_version5) is True assert (new_version5 == new_version6) is False assert (new_version5 == new_version7) is False assert (new_version5 == new_version8) is False assert (new_version6 == new_version6) is True assert (new_version6 == new_version7) is False assert (new_version6 == new_version8) is False assert (new_version7 == new_version7) is True assert (new_version6 == new_version8) is False assert (new_version8 == new_version8) is True def test_inequality_operator(setup_version_db_tests): """inequality of two Version instances.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.save(new_version1) new_version2 = Version(**data["kwargs"]) DBSession.save(new_version2) new_version3 = Version(**data["kwargs"]) DBSession.save(new_version3) new_version4 = Version(**data["kwargs"]) DBSession.save(new_version4) new_version5 = Version(**data["kwargs"]) DBSession.save(new_version5) # with new revision number data["kwargs"]["revision_number"] = 2 new_version6 = Version(**data["kwargs"]) DBSession.save(new_version6) new_version7 = Version(**data["kwargs"]) DBSession.save(new_version7) new_version8 = Version(**data["kwargs"]) DBSession.save(new_version8) new_version1.is_published = True new_version3.is_published = True new_version4.is_published = True new_version7.is_published = True assert (new_version1 != new_version1) is False assert (new_version1 != new_version2) is True assert (new_version1 != new_version3) is True assert (new_version1 != new_version4) is True assert (new_version1 != new_version5) is True assert (new_version1 != new_version6) is True assert (new_version1 != new_version7) is True assert (new_version1 != new_version8) is True assert (new_version2 != new_version2) is False assert (new_version2 != new_version3) is True assert (new_version2 != new_version4) is True assert (new_version2 != new_version5) is True assert (new_version2 != new_version6) is True assert (new_version2 != new_version7) is True assert (new_version2 != new_version8) is True assert (new_version3 != new_version3) is False assert (new_version3 != new_version4) is True assert (new_version3 != new_version5) is True assert (new_version3 != new_version6) is True assert (new_version3 != new_version7) is True assert (new_version3 != new_version8) is True assert (new_version4 != new_version4) is False assert (new_version4 != new_version5) is True assert (new_version4 != new_version6) is True assert (new_version4 != new_version7) is True assert (new_version4 != new_version8) is True assert (new_version5 != new_version5) is False assert (new_version5 != new_version6) is True assert (new_version5 != new_version7) is True assert (new_version5 != new_version8) is True assert (new_version6 != new_version6) is False assert (new_version6 != new_version7) is True assert (new_version6 != new_version8) is True assert (new_version7 != new_version7) is False assert (new_version6 != new_version8) is True assert (new_version8 != new_version8) is False def test_max_version_number_attribute_is_read_only(setup_version_db_tests): """max_version_number attribute is read only.""" data = setup_version_db_tests with pytest.raises(AttributeError) as cm: data["test_version"].max_version_number = 20 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'max_version_number'", }.get( sys.version_info.minor, "property 'max_version_number' of 'Version' object has no setter", ) assert str(cm.value) == error_message def test_max_version_number_attribute_is_working_as_expected(setup_version_db_tests): """max_version_number attribute is working as expected.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() new_version2 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() new_version3 = Version(**data["kwargs"]) DBSession.add(new_version3) DBSession.commit() new_version4 = Version(**data["kwargs"]) DBSession.add(new_version4) DBSession.commit() new_version5 = Version(**data["kwargs"]) DBSession.add(new_version5) DBSession.commit() assert new_version5.version_number == 6 assert new_version1.max_version_number == 6 assert new_version2.max_version_number == 6 assert new_version3.max_version_number == 6 assert new_version4.max_version_number == 6 assert new_version5.max_version_number == 6 def test_latest_version_attribute_is_read_only(setup_version_db_tests): """latest_version attribute is a read only attribute.""" data = setup_version_db_tests with pytest.raises(AttributeError) as cm: data["test_version"].latest_version = 3453 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'latest_version'", }.get( sys.version_info.minor, "property 'latest_version' of 'Version' object has no setter", ) assert str(cm.value) == error_message def test_latest_version_attribute_is_working_as_expected(setup_version_db_tests): """latest_version attribute is working as expected.""" data = setup_version_db_tests new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() new_version2 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() new_version3 = Version(**data["kwargs"]) DBSession.add(new_version3) DBSession.commit() new_version4 = Version(**data["kwargs"]) DBSession.add(new_version4) DBSession.commit() new_version5 = Version(**data["kwargs"]) DBSession.add(new_version5) DBSession.commit() assert new_version5.version_number == 6 assert new_version1.latest_version == new_version5 assert new_version2.latest_version == new_version5 assert new_version3.latest_version == new_version5 assert new_version4.latest_version == new_version5 assert new_version5.latest_version == new_version5 def test_latest_version_attribute_is_working_as_expected_for_different_revision_numbers( setup_version_db_tests, ): """latest_version attribute is working as expected for different revision_numbers.""" data = setup_version_db_tests data["kwargs"]["revision_number"] = 1 new_version1 = Version(**data["kwargs"]) DBSession.add(new_version1) DBSession.commit() new_version2 = Version(**data["kwargs"]) DBSession.add(new_version2) DBSession.commit() data["kwargs"]["revision_number"] = 2 new_version3 = Version(**data["kwargs"]) DBSession.add(new_version3) DBSession.commit() new_version4 = Version(**data["kwargs"]) DBSession.add(new_version4) DBSession.commit() data["kwargs"]["revision_number"] = 3 new_version5 = Version(**data["kwargs"]) DBSession.add(new_version5) DBSession.commit() assert new_version5.version_number == 1 assert new_version1.latest_version == new_version2 assert new_version2.latest_version == new_version2 assert new_version3.latest_version == new_version4 assert new_version4.latest_version == new_version4 assert new_version5.latest_version == new_version5 def test_naming_parents_attribute_is_a_read_only_property(setup_version_db_tests): """naming_parents attribute is a read only property.""" data = setup_version_db_tests with pytest.raises(AttributeError) as cm: data["test_version"].naming_parents = [data["test_task1"]] error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'naming_parents'", }.get( sys.version_info.minor, "property 'naming_parents' of 'Version' object has no setter", ) assert str(cm.value) == error_message def test_naming_parents_attribute_is_working_as_expected(setup_version_db_tests): """naming_parents attribute is working as expected.""" data = setup_version_db_tests # for data["test_version"] assert data["test_version"].naming_parents == [ data["test_shot1"], data["test_task1"], data["test_variant1"], ] # for a new version of a task task1 = Task( name="Test Task 1", project=data["test_project"], ) task2 = Task( name="Test Task 2", parent=task1, ) task3 = Task( name="Test Task 3", parent=task2, ) DBSession.add_all([task1, task2, task3]) DBSession.commit() version1 = Version(task=task3) DBSession.add(version1) DBSession.commit() assert version1.naming_parents == [task1, task2, task3] # for an asset version character_type = Type(target_entity_type="Asset", name="Character", code="Char") asset1 = Asset(name="Asset1", code="Asset1", parent=task1, type=character_type) DBSession.add(asset1) DBSession.commit() version2 = Version(task=asset1) assert version2.naming_parents == [asset1] # for a version of a task of a shot shot2 = Shot( name="SH002", code="SH002", parent=task3, ) DBSession.add(shot2) DBSession.commit() task4 = Task( name="Test Task 4", parent=shot2, ) DBSession.add(task4) DBSession.commit() version3 = Version(task=task4) assert version3.naming_parents == [shot2, task4] # for an asset of a shot asset2 = Asset(name="Asset2", code="Asset2", parent=shot2, type=character_type) DBSession.add(asset2) DBSession.commit() version4 = Version(task=asset2) assert version4.naming_parents == [asset2] def test_nice_name_attribute_is_working_as_expected(setup_version_db_tests): """nice_name attribute is working as expected.""" data = setup_version_db_tests # for data["test_version"] assert data["test_version"].naming_parents == [ data["test_shot1"], data["test_task1"], data["test_variant1"], ] # for a new version of a task task1 = Task( name="Test Task 1", project=data["test_project"], ) task2 = Task( name="Test Task 2", parent=task1, ) task3 = Task( name="Test Task 3", parent=task2, ) DBSession.add_all([task1, task2, task3]) DBSession.commit() version1 = Version(task=task3) DBSession.add(version1) DBSession.commit() assert version1.nice_name == "{}_{}_{}".format( task1.nice_name, task2.nice_name, task3.nice_name, ) # for an asset version character_type = Type(target_entity_type="Asset", name="Character", code="Char") asset1 = Asset(name="Asset1", code="Asset1", parent=task1, type=character_type) DBSession.add(asset1) DBSession.commit() version2 = Version(task=asset1) assert version2.nice_name == "{}".format(asset1.nice_name) # for a version of a task of a shot shot2 = Shot( name="SH002", code="SH002", parent=task3, ) DBSession.add(shot2) DBSession.commit() task4 = Task( name="Test Task 4", parent=shot2, ) DBSession.add(task4) DBSession.commit() version3 = Version(task=task4) assert version3.nice_name == "{}_{}".format( shot2.nice_name, task4.nice_name, ) # for an asset of a shot asset2 = Asset(name="Asset2", code="Asset2", parent=shot2, type=character_type) DBSession.add(asset2) DBSession.commit() version4 = Version(task=asset2) assert version4.nice_name == "{}".format(asset2.nice_name) def test_string_representation_is_a_little_bit_meaningful(setup_version_db_tests): """__str__ or __repr__ result is meaningful.""" data = setup_version_db_tests assert "" == f'{data["test_version"]}' def test_walk_hierarchy_is_working_as_expected_in_dfs_mode(setup_version_db_tests): """walk_hierarchy() method is working in DFS mode correctly.""" data = setup_version_db_tests v1 = Version(task=data["test_task1"]) v2 = Version(task=data["test_task1"], parent=v1) v3 = Version(task=data["test_task1"], parent=v2) v4 = Version(task=data["test_task1"], parent=v3) v5 = Version(task=data["test_task1"], parent=v1) expected_result = [v1, v2, v3, v4, v5] visited_versions = [] for v in v1.walk_hierarchy(): visited_versions.append(v) assert expected_result == visited_versions # def test_path_attribute_value_is_calculated_on_init(setup_version_db_tests): # """path attribute value is automatically calculated on # Version instance initialize # """ # ft = FilenameTemplate( # name='Task Filename Template', # target_entity_type='Task', # path='{{project.code}}/{%- for p in parent_tasks -%}' # '{{p.nice_name}}/{%- endfor -%}', # filename='{{version.nice_name}}_v{{"%03d"|format(version.version_number)}}{{extension}}' # ) # data["test_project"].structure.templates.append(ft) # DBSession.add(data["test_project"]) # DBSession.commit() # # print('entity_type: {}'.format(data["test_task1"].entity_type)) # # # v1 = Version(task=data["test_task1"]) # # assert 'tp/SH001/task1/task1_Main_v001' == v1.path # data["fail"]() def test_reviews_attribute_is_a_list_of_reviews(setup_version_db_tests): """Version.reviews attribute is filled with Review instances.""" data = setup_version_db_tests data["test_variant1"].status = data["status_wip"] data["test_variant1"].responsible = [data["test_user1"], data["test_user2"]] version = Version(task=data["test_variant1"]) # request a review reviews = data["test_variant1"].request_review(version=version) assert reviews[0].version == version assert reviews[1].version == version assert isinstance(version.reviews, list) assert len(version.reviews) == 2 assert version.reviews == reviews @pytest.fixture(scope="function") def setup_version_tests(): """Set up non-DB related tests of Version class.""" data = dict() data["patcher"] = PlatformPatcher() # users # test users data["test_user1"] = User( name="Test User 1", login="tuser1", email="tuser1@test.com", password="secret", ) data["test_user2"] = User( name="Test User 2", login="tuser2", email="tuser2@test.com", password="secret", ) # statuses data["test_status1"] = Status(name="Status1", code="STS1") data["test_status2"] = Status(name="Status2", code="STS2") data["test_status3"] = Status(name="Status3", code="STS3") data["test_status4"] = Status(name="Status4", code="STS4") data["test_status5"] = Status(name="Status5", code="STS5") # status lists data["test_task_status_list"] = StatusList( name="Task Status List", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="Task", ) data["test_asset_status_list"] = StatusList( name="Asset Status List", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="Asset", ) data["test_shot_status_list"] = StatusList( name="Shot Status List", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="Shot", ) data["test_sequence_status_list"] = StatusList( name="Sequence Status List", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="Sequence", ) data["test_project_status_list"] = StatusList( name="Project Status List", statuses=[ data["test_status1"], data["test_status2"], data["test_status3"], data["test_status4"], data["test_status5"], ], target_entity_type="Project", ) # repository data["test_repo"] = Repository( name="Test Repository", code="TR", linux_path="/mnt/T/", windows_path="T:/", macos_path="/Volumes/T/", ) # a project type data["test_project_type"] = Type( name="Test", code="test", target_entity_type="Project", ) # create a structure data["test_structure"] = Structure(name="Test Project Structure") # create a project data["test_project"] = Project( name="Test Project", code="tp", type=data["test_project_type"], status_list=data["test_project_status_list"], repositories=[data["test_repo"]], structure=data["test_structure"], ) # create a sequence data["test_sequence"] = Sequence( name="Test Sequence", code="SEQ1", project=data["test_project"], status_list=data["test_sequence_status_list"], ) # create a shot data["test_shot1"] = Shot( name="SH001", code="SH001", project=data["test_project"], sequence=data["test_sequence"], status_list=data["test_shot_status_list"], ) # create a group of Tasks for the shot data["test_task1"] = Task( name="Task1", parent=data["test_shot1"], status_list=data["test_task_status_list"], ) data["test_task2"] = Task( name="Task2", parent=data["test_shot1"], status_list=data["test_task_status_list"], ) # a File for the input file data["test_input_file1"] = File( name="Input File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_beauty_v001.###.exr", ) data["test_input_file2"] = File( name="Input File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_occ_v001.###.exr", ) # a File for the output file data["test_output_file1"] = File( name="Output File 1", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_beauty_v001.###.exr", ) data["test_output_file2"] = File( name="Output File 2", full_path="/mnt/M/JOBs/TestProj/Seqs/TestSeq/Shots/SH001/FX/" "Outputs/SH001_occ_v001.###.exr", ) # now create a version for the Task data["kwargs"] = { "task": data["test_task1"], } # and the Version data["test_version"] = Version(**data["kwargs"]) # set the published to False data["test_version"].is_published = False yield data # clean up test data["patcher"].restore() def test_children_attribute_will_not_allow_circular_dependencies_2( setup_version_tests, ): """CircularDependency error raised if a parent is set as a child to its child.""" data = setup_version_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) new_version2 = Version(**data["kwargs"]) new_version1.parent = new_version2 with pytest.raises(CircularDependencyError) as cm: new_version1.children.append(new_version2) assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_children_attribute_will_not_allow_deeper_circular_dependencies_2( setup_version_tests, ): """CircularDependency error raised if the parent of a parent Version is set as a children to its grand child.""" data = setup_version_tests data["kwargs"]["parent"] = None new_version1 = Version(**data["kwargs"]) new_version2 = Version(**data["kwargs"]) new_version3 = Version(**data["kwargs"]) new_version1.parent = new_version2 new_version2.parent = new_version3 with pytest.raises(CircularDependencyError) as cm: new_version1.children.append(new_version3) assert ( str(cm.value) == " (Version) and " " (Version) are in a " 'circular dependency in their "children" attribute' ) def test_version_number_without_a_db(setup_version_tests): """version_number without a db is not None.""" data = setup_version_tests v = Version(task=data["test_task2"]) assert v.version_number is not None def test_version_number_without_a_db(setup_version_tests): """version_number without a db is not None.""" data = setup_version_tests v1 = Version(task=data["test_task2"]) assert v1.version_number == 1 v2 = Version(task=data["test_task2"]) assert v2.version_number == 2 v3 = Version(task=data["test_task2"]) assert v3.version_number == 3 def test_latest_version_without_a_db(setup_version_tests): """latest_version without a db returns self.""" data = setup_version_tests v = Version(task=data["test_task2"]) assert v.latest_version is v def test_max_version_number_without_a_db(setup_version_tests): """max_version_number without a db returns self.version_number.""" data = setup_version_tests v = Version(task=data["test_task2"]) assert v.max_version_number == v.version_number def test__hash__is_working_as_expected(setup_version_tests): """__hash__ is working as expected.""" data = setup_version_tests v = Version(task=data["test_task2"]) result = hash(v) assert isinstance(result, int) assert result == v.__hash__() def test_request_review_method_calls_task_request_review_method( setup_version_tests, monkeypatch ): """request_review() calls Task.request_review() method.""" data = setup_version_tests called = [] def patched_request_review(self, version=None): """Patch the request review method.""" called.append(version) data["test_task2"].responsible = [ data["test_user1"], data["test_user2"], ] monkeypatch.setattr( "stalker.models.version.Task.request_review", patched_request_review ) v = Version(task=data["test_task2"]) assert len(called) == 0 _ = v.request_review() assert len(called) == 1 assert called[0] == v def test_request_review_method_returns_reviews(setup_version_db_tests): """request_review() returns Reviews.""" data = setup_version_db_tests task = data["test_variant1"] task.responsible = [ data["test_user1"], data["test_user2"], ] task.status = data["status_wip"] v = Version(task=task) reviews = v.request_review() assert len(reviews) == 2 from stalker.models.review import Review assert isinstance(reviews[0], Review) assert isinstance(reviews[1], Review) def test_variant_name_attr_does_not_exist(setup_version_tests): """Version.variant_name does not exist anymore.""" data = setup_version_tests assert hasattr(data["test_version"], "variant_name") is False ================================================ FILE: tests/models/test_wiki.py ================================================ # -*- coding: utf-8 -*- """Tests related to the Wiki class.""" import pytest from stalker import Page, Project, Repository, Status, StatusList, Type @pytest.fixture(scope="function") def setup_page_tests(): """Set up tests for the Page class.""" data = dict() # create a repository data["repository_type"] = Type( name="Test Repository Type", code="test_repo", target_entity_type="Repository" ) data["test_repository"] = Repository( name="Test Repository", code="TR", type=data["repository_type"], ) # statuses data["status1"] = Status(name="Status1", code="STS1") data["status2"] = Status(name="Status2", code="STS2") data["status3"] = Status(name="Status3", code="STS3") # project status list data["project_status_list"] = StatusList( name="Project Status List", statuses=[ data["status1"], data["status2"], data["status3"], ], target_entity_type="Project", ) # project type data["test_project_type"] = Type( name="Test Project Type", code="testproj", target_entity_type="Project", ) # create projects data["test_project1"] = Project( name="Test Project 1", code="tp1", type=data["test_project_type"], status_list=data["project_status_list"], repository=data["test_repository"], ) data["kwargs"] = { "title": "Test Page Title", "content": "Test content", "project": data["test_project1"], } data["test_page"] = Page(**data["kwargs"]) return data def test_title_argument_is_skipped(setup_page_tests): """ValueError is raised if the title argument is skipped.""" data = setup_page_tests data["kwargs"].pop("title") with pytest.raises(ValueError) as cm: Page(**data["kwargs"]) assert str(cm.value) == "Page.title cannot be empty" def test_title_argument_is_none(setup_page_tests): """TypeError is raised if the title argument is None.""" data = setup_page_tests data["kwargs"]["title"] = None with pytest.raises(TypeError) as cm: Page(**data["kwargs"]) assert str(cm.value) == "Page.title should be a string, not NoneType: 'None'" def test_title_attribute_is_set_to_none(setup_page_tests): """TypeError is raised if the title attribute is set to None.""" data = setup_page_tests with pytest.raises(TypeError) as cm: data["test_page"].title = None assert str(cm.value) == "Page.title should be a string, not NoneType: 'None'" def test_title_argument_is_an_empty_string(setup_page_tests): """ValueError is raised if the title argument is an empty string.""" data = setup_page_tests data["kwargs"]["title"] = "" with pytest.raises(ValueError) as cm: Page(**data["kwargs"]) assert str(cm.value) == "Page.title cannot be empty" def test_title_attribute_is_set_to_empty_string(setup_page_tests): """ValueError is raised if the title attribute is set to empty string.""" data = setup_page_tests with pytest.raises(ValueError) as cm: data["test_page"].title = "" assert str(cm.value) == "Page.title cannot be empty" def test_title_argument_is_not_a_string(setup_page_tests): """TypeError is raised if the title argument is not a string.""" data = setup_page_tests data["kwargs"]["title"] = 2165 with pytest.raises(TypeError) as cm: Page(**data["kwargs"]) assert str(cm.value) == "Page.title should be a string, not int: '2165'" def test_title_attribute_is_not_a_string(setup_page_tests): """TypeError is raised if the title is set to a value other than a string.""" data = setup_page_tests with pytest.raises(TypeError) as cm: data["test_page"].title = 2135 assert str(cm.value) == "Page.title should be a string, not int: '2135'" def test_title_argument_is_working_as_expected(setup_page_tests): """title argument value is correctly passed to title attribute.""" data = setup_page_tests assert data["test_page"].title == data["kwargs"]["title"] def test_title_attribute_is_working_as_expected(setup_page_tests): """title attribute is working as expected.""" data = setup_page_tests test_value = "Test Title 2" data["test_page"].title = test_value assert data["test_page"].title == test_value def test_content_argument_skipped(setup_page_tests): """content attr value is an empty str if the content argument is skipped.""" data = setup_page_tests data["kwargs"].pop("content") new_page = Page(**data["kwargs"]) assert new_page.content == "" def test_content_argument_is_None(setup_page_tests): """content attribute value is an empty string if the content argument is None.""" data = setup_page_tests data["kwargs"]["content"] = None new_page = Page(**data["kwargs"]) assert new_page.content == "" def test_content_attribute_is_set_to_None(setup_page_tests): """content attr value is an empty string if the content attribute is set to None.""" data = setup_page_tests assert data["test_page"].content != "" data["test_page"].content = None assert data["test_page"].content == "" def test_content_argument_is_empty_string(setup_page_tests): """content attr value is an empty string if the content arg is an empty string.""" data = setup_page_tests data["kwargs"]["content"] = "" new_page = Page(**data["kwargs"]) assert new_page.content == "" def test_content_attribute_is_set_to_an_empty_string(setup_page_tests): """content attribute can be set to an empty string.""" data = setup_page_tests data["test_page"].content = "" assert data["test_page"].content == "" def test_content_argument_is_not_a_string(setup_page_tests): """TypeError is raised if the content argument is not a str.""" data = setup_page_tests data["kwargs"]["content"] = 1234 with pytest.raises(TypeError) as cm: Page(**data["kwargs"]) assert str(cm.value) == "Page.content should be a string, not int: '1234'" def test_content_attribute_is_set_to_a_value_other_than_a_string(setup_page_tests): """TypeError is raised if the content attr is not a str.""" data = setup_page_tests with pytest.raises(TypeError) as cm: data["test_page"].content = ["not", "a", "string"] assert str(cm.value) == ( "Page.content should be a string, not list: '['not', 'a', 'string']'" ) def test_content_argument_is_working_as_expected(setup_page_tests): """content argument value is correctly passed to the content attribute.""" data = setup_page_tests assert data["test_page"].content == data["kwargs"]["content"] def test_content_attribute_is_working_as_expected(setup_page_tests): """content attribute value can be correctly set.""" data = setup_page_tests test_value = "This is a test content" assert data["test_page"].content != test_value data["test_page"].content = test_value assert data["test_page"].content == test_value ================================================ FILE: tests/models/test_working_hours.py ================================================ # -*- coding: utf-8 -*- """Tests related to the WorkingHours class.""" import copy import datetime import sys import pytest import pytz from stalker import defaults from stalker.models.studio import WorkingHours def test___auto_name___is_true(): """WorkingHours.__auto_name__ is True""" assert WorkingHours.__auto_name__ is True def test_working_hours_argument_is_skipped(): """WorkingHours is created with the default settings by default.""" wh = WorkingHours() assert wh.working_hours == defaults.working_hours def test_working_hours_argument_is_none(): """WorkingHours created with default settings if the working_hours arg is None.""" wh = WorkingHours(working_hours=None) assert wh.working_hours == defaults.working_hours def test_working_hours_argument_is_not_a_dictionary(): """TypeError is raised if the working_hours argument value is not a dictionary.""" with pytest.raises(TypeError) as cm: WorkingHours(working_hours="not a dictionary of proper values") assert str(cm.value) == ( "WorkingHours.working_hours should be a dictionary, " "not str: 'not a dictionary of proper values'" ) def test_working_hours_attribute_is_not_a_dictionary(): """TypeError raised if the working_hours attr is not a dictionary.""" wh = WorkingHours() with pytest.raises(TypeError) as cm: wh.working_hours = "not a dictionary of proper values" assert str(cm.value) == ( "WorkingHours.working_hours should be a dictionary, " "not str: 'not a dictionary of proper values'" ) def test_working_hours_argument_value_is_dictionary_of_other_formatted_data(): """TypeError raised if the working_hours arg is not a dict of list of two int.""" with pytest.raises(TypeError) as cm: WorkingHours(working_hours={"not": "properly valued"}) assert str(cm.value) == ( "WorkingHours.working_hours should be a dictionary with keys " '"mon, tue, wed, thu, fri, sat, sun" and the values should a list ' "of lists of two integers like [[540, 720], [800, 1080]], " "not str: 'properly valued'" ) def test_working_hours_attribute_is_set_to_a_dictionary_of_other_formatted_data(): """TypeError raised if the working hours attr is a dict of some other value.""" wh = WorkingHours() with pytest.raises(TypeError) as cm: wh.working_hours = {"not": "properly valued"} assert ( str(cm.value) == "WorkingHours.working_hours should be a dictionary with keys " '"mon, tue, wed, thu, fri, sat, sun" and the values should a ' "list of lists of two integers like [[540, 720], [800, 1080]], " "not str: 'properly valued'" ) @pytest.mark.parametrize( "test_key, test_value", [ ["sun", [[-10, 1000]]], ["sat", [[900, 1080], [1090, 1500]]], ], ) def test_working_hours_argument_data_is_not_in_correct_range1(test_key, test_value): """ValueError raised if the time values are not correct in the working_hours arg.""" wh = copy.copy(defaults.working_hours) wh[test_key] = test_value with pytest.raises(ValueError) as cm: WorkingHours(working_hours=wh) assert str(cm.value) == ( "WorkingHours.working_hours value should be a list of lists of " "two integers and the range of integers should be between 0-1440, " f"not list: '{test_value}'" ) @pytest.mark.parametrize( "test_key, test_value", [ ["sun", [[-10, 1000]]], ["sat", [[900, 1080], [1090, 1500]]], ], ) def test_working_hours_attribute_data_is_not_in_correct_range1(test_key, test_value): """ValueError raised if the times are not correct in the working_hours attr.""" wh = copy.copy(defaults.working_hours) wh[test_key] = test_value wh_ins = WorkingHours() with pytest.raises(ValueError) as cm: wh_ins.working_hours = wh assert str(cm.value) == ( "WorkingHours.working_hours value should be a list of lists of " "two integers and the range of integers should be between 0-1440, " f"not list: '{test_value}'" ) def test_working_hours_argument_value_is_not_complete(): """default values are used for missing days in the given working_hours arg.""" working_hours = {"sat": [[900, 1080]], "sun": [[900, 1080]]} wh = WorkingHours(working_hours=working_hours) assert wh["mon"] == defaults.working_hours["mon"] assert wh["tue"] == defaults.working_hours["tue"] assert wh["wed"] == defaults.working_hours["wed"] assert wh["thu"] == defaults.working_hours["thu"] assert wh["fri"] == defaults.working_hours["fri"] def test_working_hours_attribute_value_is_not_complete(): """default values are used for missing days in the given working_hours attr.""" working_hours = {"sat": [[900, 1080]], "sun": [[900, 1080]]} wh = WorkingHours() wh.working_hours = working_hours assert wh["mon"] == defaults.working_hours["mon"] assert wh["tue"] == defaults.working_hours["tue"] assert wh["wed"] == defaults.working_hours["wed"] assert wh["thu"] == defaults.working_hours["thu"] assert wh["fri"] == defaults.working_hours["fri"] def test_working_hours_can_be_indexed_with_day_number(): """working hours for a day can be reached by an index.""" wh = WorkingHours() assert wh[6] == defaults.working_hours["sun"] # this should not raise any errors wh[6] = [[540, 1080]] def test_working_hours_day_0_is_monday(): """day zero is monday.""" wh = WorkingHours() wh[0] = [[270, 980]] assert wh["mon"] == wh[0] def test_working_hours_can_be_string_indexed_with_the_date_short_name(): """working hours info can be reached by using the short date name as the index.""" wh = WorkingHours() assert wh["sun"] == defaults.working_hours["sun"] # this should not raise any errors wh["sun"] = [[540, 1080]] @pytest.mark.parametrize( "test_key, test_value, error_type", [ [0, "not a proper data", TypeError], ["sun", "not a proper data", TypeError], [0, ["no proper data"], TypeError], ["sun", ["no proper data"], TypeError], [0, [["no proper data"]], ValueError], ["sun", [["no proper data"]], ValueError], [0, [[3]], ValueError], [2, [[2, "a"]], TypeError], [1, [[20, 10], ["a", 300]], TypeError], [5, [[323, 1344], [2, "d"]], TypeError], [0, [[4, 100, 3]], ValueError], ["mon", [[3]], ValueError], ["mon", [[2, "a"]], TypeError], ["tue", [[20, 10], ["a", 300]], TypeError], ["fri", [[323, 1344], [2, "d"]], TypeError], ["sat", [[4, 100, 3]], ValueError], ["sun", [[-10, 100]], ValueError], ["sat", [[0, 1800]], ValueError], [7, [[32, 23], [233, 324]], IndexError], [7, [[32, 23], [233, 324]], IndexError], ["zon", [[32, 23], [233, 324]], KeyError], ], ) def test___setitem__checks_the_given_data(test_key, test_value, error_type): """__setitem__ checks the given data format.""" wh = WorkingHours() with pytest.raises(error_type) as cm: wh[test_key] = test_value error_message = { TypeError: ( "WorkingHours.working_hours value should be a list of lists of " "two integers and the range of integers should be between 0-1440, " f"not {test_value.__class__.__name__}: '{test_value}'" ), ValueError: ( "WorkingHours.working_hours value should be a list of lists of " "two integers and the range of integers should be between 0-1440, " f"not {test_value.__class__.__name__}: '{test_value}'" ), IndexError: "list index out of range", KeyError: ( "\"WorkingHours accepts only ['mon', 'tue', 'wed', 'thu', " "'fri', 'sat', 'sun'] as key, not 'zon'\"" ), }[error_type] assert str(cm.value) == error_message def test_working_hours_argument_is_working_as_expected(): """working_hours argument is working as expected,""" working_hours = copy.copy(defaults.working_hours) working_hours["sun"] = [[540, 1000]] working_hours["sat"] = [[500, 800], [900, 1440]] wh = WorkingHours(working_hours=working_hours) assert wh.working_hours == working_hours assert wh.working_hours["sun"] == working_hours["sun"] assert wh.working_hours["sat"] == working_hours["sat"] def test_working_hours_attribute_is_working_as_expected(): """working_hours attribute is working as expected.""" working_hours = copy.copy(defaults.working_hours) working_hours["sun"] = [[540, 1000]] working_hours["sat"] = [[500, 800], [900, 1440]] wh = WorkingHours() wh.working_hours = working_hours assert wh.working_hours == working_hours assert wh.working_hours["sun"] == working_hours["sun"] assert wh.working_hours["sat"] == working_hours["sat"] def test_to_tjp_attribute_is_read_only(): """to_tjp attribute is read only.""" wh = WorkingHours() with pytest.raises(AttributeError) as cm: wh.to_tjp = "some value" error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'to_tjp'", }.get( sys.version_info.minor, "property 'to_tjp' of 'WorkingHours' object has no setter", ) assert str(cm.value) == error_message def test_to_tjp_attribute_is_working_as_expected(): """to_tjp property is working as expected.""" wh = WorkingHours() wh["mon"] = [[570, 1110]] wh["tue"] = [[570, 1110]] wh["wed"] = [[570, 1110]] wh["thu"] = [[570, 1110]] wh["fri"] = [[570, 1110]] wh["sat"] = [] wh["sun"] = [] expected_tjp = """workinghours mon 09:30 - 18:30 workinghours tue 09:30 - 18:30 workinghours wed 09:30 - 18:30 workinghours thu 09:30 - 18:30 workinghours fri 09:30 - 18:30 workinghours sat off workinghours sun off""" # print("Expected:") # print("---------") # print(expected_tjp) # print("--------------------") # print("Result:") # print("-------") # print(wh.to_tjp) assert wh.to_tjp == expected_tjp def test_to_tjp_attribute_is_working_as_expected_for_multiple_work_hour_ranges(): """to_tjp property is working as expected.""" wh = WorkingHours() wh["mon"] = [[570, 720], [780, 1110]] wh["tue"] = [[570, 720], [780, 1110]] wh["wed"] = [[570, 720], [780, 1110]] wh["thu"] = [[570, 720], [780, 1110]] wh["fri"] = [[570, 720], [780, 1110]] wh["sat"] = [[570, 720]] wh["sun"] = [] expected_tjp = """workinghours mon 09:30 - 12:00, 13:00 - 18:30 workinghours tue 09:30 - 12:00, 13:00 - 18:30 workinghours wed 09:30 - 12:00, 13:00 - 18:30 workinghours thu 09:30 - 12:00, 13:00 - 18:30 workinghours fri 09:30 - 12:00, 13:00 - 18:30 workinghours sat 09:30 - 12:00 workinghours sun off""" # print("Expected:") # print("---------") # print(expected_tjp) # print("--------------------") # print("Result:") # print("-------") # print(wh.to_tjp) assert wh.to_tjp == expected_tjp def test_weekly_working_hours_attribute_is_read_only(): """weekly_working_hours is a read-only attribute.""" wh = WorkingHours() with pytest.raises(AttributeError) as cm: wh.weekly_working_hours = 232 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'weekly_working_hours'", }.get( sys.version_info.minor, "property 'weekly_working_hours' of 'WorkingHours' object has no setter", ) assert str(cm.value) == error_message def test_weekly_working_hours_attribute_is_working_as_expected(): """weekly_working_hours attribute is working as expected.""" wh = WorkingHours() wh["mon"] = [[570, 720], [780, 1110]] # 480 wh["tue"] = [[570, 720], [780, 1110]] # 480 wh["wed"] = [[570, 720], [780, 1110]] # 480 wh["thu"] = [[570, 720], [780, 1110]] # 480 wh["fri"] = [[570, 720], [780, 1110]] # 480 wh["sat"] = [[570, 720]] # 150 wh["sun"] = [] # 0 expected_value = 42.5 assert wh.weekly_working_hours == expected_value def test_is_working_hour_is_working_as_expected(): """is_working_hour method is working as expected.""" wh = WorkingHours() wh["mon"] = [[570, 720], [780, 1110]] wh["tue"] = [[570, 720], [780, 1110]] wh["wed"] = [[570, 720], [780, 1110]] wh["thu"] = [[570, 720], [780, 1110]] wh["fri"] = [[570, 720], [780, 1110]] wh["sat"] = [[570, 720]] wh["sun"] = [] # monday check_date = datetime.datetime(2013, 4, 8, 13, 55, tzinfo=pytz.utc) assert wh.is_working_hour(check_date) is True # sunday check_date = datetime.datetime(2013, 4, 14, 13, 55, tzinfo=pytz.utc) assert wh.is_working_hour(check_date) is False def test_day_numbers_are_correct(): """day numbers are correct.""" wh = WorkingHours() wh["mon"] = [[1, 2]] wh["tue"] = [[3, 4]] wh["wed"] = [[5, 6]] wh["thu"] = [[7, 8]] wh["fri"] = [[9, 10]] wh["sat"] = [[11, 12]] wh["sun"] = [[13, 14]] assert defaults.day_order[0] == "mon" assert defaults.day_order[1] == "tue" assert defaults.day_order[2] == "wed" assert defaults.day_order[3] == "thu" assert defaults.day_order[4] == "fri" assert defaults.day_order[5] == "sat" assert defaults.day_order[6] == "sun" assert wh["mon"] == wh[0] assert wh["tue"] == wh[1] assert wh["wed"] == wh[2] assert wh["thu"] == wh[3] assert wh["fri"] == wh[4] assert wh["sat"] == wh[5] assert wh["sun"] == wh[6] def test_weekly_working_days_is_a_read_only_attribute(): """weekly working days is a read-only attribute.""" wh = WorkingHours() with pytest.raises(AttributeError) as cm: wh.weekly_working_days = 6 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'weekly_working_days'", }.get( sys.version_info.minor, "property 'weekly_working_days' of 'WorkingHours' object has no setter", ) assert str(cm.value) == error_message @pytest.mark.parametrize( "test_data, expected_result", [ [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [], "sun": [], }, 5, ], [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [[11, 12]], "sun": [], }, 6, ], [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [[11, 12]], "sun": [[13, 14]], }, 7, ], ], ) def test_weekly_working_days_is_calculated_correctly(test_data, expected_result): """weekly working days are calculated correctly.""" wh = WorkingHours() for day in test_data: wh[day] = test_data[day] assert wh.weekly_working_days == expected_result def test_yearly_working_days_is_a_read_only_attribute(): """yearly_working_days attribute is a read only attribute.""" wh = WorkingHours() with pytest.raises(AttributeError) as cm: wh.yearly_working_days = 260.1 error_message = { 8: "can't set attribute", 9: "can't set attribute", 10: "can't set attribute 'yearly_working_days'", }.get( sys.version_info.minor, "property 'yearly_working_days' of 'WorkingHours' object has no setter", ) assert str(cm.value) == error_message @pytest.mark.parametrize( "test_data, expected_result", [ [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [], "sun": [], }, 261, ], [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [[11, 12]], "sun": [], }, 313, ], [ { "mon": [[1, 2]], "tue": [[3, 4]], "wed": [[5, 6]], "thu": [[7, 8]], "fri": [[9, 10]], "sat": [[11, 12]], "sun": [[13, 14]], }, 365, ], ], ) def test_yearly_working_days_is_calculated_correctly(test_data, expected_result): """yearly_working_days is calculated correctly.""" wh = WorkingHours() for day in test_data: wh[day] = test_data[day] assert wh.yearly_working_days == pytest.approx(expected_result) def test_daily_working_hours_argument_is_skipped(): """daily_working_hours arg is skipped, daily_working_hours attr is equal to the default settings.""" wh = WorkingHours() assert wh.daily_working_hours == defaults.daily_working_hours def test_daily_working_hours_argument_is_none(): """daily_working_hours attr is equal to the default settings value if the daily_working_hours argument is None.""" kwargs = dict() kwargs["daily_working_hours"] = None wh = WorkingHours(**kwargs) assert wh.daily_working_hours == defaults.daily_working_hours def test_daily_working_hours_attribute_is_none(): """daily_working_hours attr is set to default if it is set to None.""" wh = WorkingHours() wh.daily_working_hours = None assert wh.daily_working_hours == defaults.daily_working_hours def test_daily_working_hours_argument_is_not_integer(): """TypeError raised if the daily_working_hours argument is not an integer.""" kwargs = dict() kwargs["daily_working_hours"] = "not an integer" with pytest.raises(TypeError) as cm: WorkingHours(**kwargs) assert str(cm.value) == ( "WorkingHours.daily_working_hours should be an integer, " "not str: 'not an integer'" ) def test_daily_working_hours_attribute_is_not_an_integer(): """TypeError raised if the daily_working hours attr is not an integer.""" wh = WorkingHours() with pytest.raises(TypeError) as cm: wh.daily_working_hours = "not an integer" assert str(cm.value) == ( "WorkingHours.daily_working_hours should be an integer, " "not str: 'not an integer'" ) def test_daily_working_hours_argument_is_working_fine(): """daily working hours arg is correctly passed to daily_working_hours attr.""" kwargs = dict() kwargs["daily_working_hours"] = 12 wh = WorkingHours(**kwargs) assert wh.daily_working_hours == 12 def test_daily_working_hours_attribute_is_working_as_expected(): """daily_working_hours attribute is working as expected.""" wh = WorkingHours() wh.daily_working_hours = 23 assert wh.daily_working_hours == 23 def test_daily_working_hours_argument_is_zero(): """ValueError is raised if the daily_working_hours argument value is zero.""" kwargs = dict() kwargs["daily_working_hours"] = 0 with pytest.raises(ValueError) as cm: WorkingHours(**kwargs) assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_daily_working_hours_attribute_is_zero(): """ValueError is raised if the daily_working_hours attribute is set to zero.""" wh = WorkingHours() with pytest.raises(ValueError) as cm: wh.daily_working_hours = 0 assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_daily_working_hours_argument_is_a_negative_number(): """ValueError is raised if the daily_working_hours argument value is negative.""" kwargs = dict() kwargs["daily_working_hours"] = -10 with pytest.raises(ValueError) as cm: WorkingHours(**kwargs) assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_daily_working_hours_attribute_is_a_negative_number(): """ValueError raised if the daily_working_hours attr is set to a negative value.""" wh = WorkingHours() with pytest.raises(ValueError) as cm: wh.daily_working_hours = -10 assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_daily_working_hours_argument_is_set_to_a_number_bigger_than_24(): """ValueError is raised if the daily working hours argument is bigger than 24.""" kwargs = dict() kwargs["daily_working_hours"] = 25 with pytest.raises(ValueError) as cm: WorkingHours(**kwargs) assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_daily_working_hours_attribute_is_set_to_a_number_bigger_than_24(): """ValueError is raised if the daily working hours attr is bigger than 24.""" wh = WorkingHours() with pytest.raises(ValueError) as cm: wh.daily_working_hours = 25 assert ( str(cm.value) == "WorkingHours.daily_working_hours should be a positive integer " "value greater than 0 and smaller than or equal to 24" ) def test_split_in_to_working_hours_is_not_implemented_yet(): """NotimplementedError is raised if the split_in_to_working_hours() is called.""" with pytest.raises(NotImplementedError): wh = WorkingHours() start = datetime.datetime.now(pytz.utc) end = start + datetime.timedelta(days=10) wh.split_in_to_working_hours(start, end) ================================================ FILE: tests/test_exceptions.py ================================================ # -*- coding: utf-8 -*- """Tests for the exceptions module.""" import pytest from stalker.exceptions import ( CircularDependencyError, DependencyViolationError, LoginError, OverBookedError, StatusError, ) def test_login_error_is_working_as_expected(): """LoginError is working as expected.""" test_message = "testing LoginError" with pytest.raises(LoginError) as cm: raise LoginError(test_message) assert str(cm.value) == test_message def test_circular_dependency_error_is_working_as_expected(): """CircularDependencyError is working as expected.""" test_message = "testing CircularDependencyError" with pytest.raises(CircularDependencyError) as cm: raise CircularDependencyError(test_message) assert str(cm.value) == test_message def test_over_booked_error_is_working_as_expected(): """OverBookedError is working as expected.""" test_message = "testing OverBookedError" with pytest.raises(OverBookedError) as cm: raise OverBookedError(test_message) assert str(cm.value) == test_message def test_status_error_is_working_as_expected(): """StatusError is working as expected.""" test_message = "testing StatusError" with pytest.raises(StatusError) as cm: raise StatusError(test_message) assert str(cm.value) == test_message def test_dependency_violation_error_is_working_as_expected(): """DependencyViolationError is working as expected.""" test_message = "testing DependencyViolationError" with pytest.raises(DependencyViolationError) as cm: raise DependencyViolationError(test_message) assert str(cm.value) == test_message ================================================ FILE: tests/test_logging.py ================================================ # -*- coding: utf-8 -*- import logging import pytest from stalker import log @pytest.fixture(scope="function") def setup_logging(): """Set up stalker log module.""" log.loggers = [] yield # clean loggers list after every test log.loggers = [] def test_register_logger_simple(setup_logging): """register logger adds the given logger to the list.""" logger = logging.getLogger("test_logger") assert logger not in log.loggers log.register_logger(logger) assert logger in log.loggers def test_register_logger_called_multiple_times(setup_logging): """register logger adds the logger only once.""" logger = logging.getLogger("test_logger") assert logger not in log.loggers assert 0 == len(log.loggers) log.register_logger(logger) assert 1 == len(log.loggers) log.register_logger(logger) assert 1 == len(log.loggers) log.register_logger(logger) assert 1 == len(log.loggers) log.register_logger(logger) assert 1 == len(log.loggers) assert logger in log.loggers def test_register_logger_only_accept_loggers(setup_logging): """register_logger raise a TypeError if the logger is not a Logger instance.""" with pytest.raises(TypeError) as cm: log.register_logger("not a logger") assert str(cm.value) == ( "logger should be a logging.Logger instance, not str: 'not a logger'" ) def test_register_logger_sets_the_level_to_the_default_level(setup_logging): """register_logger set the level to the default level.""" logger = logging.getLogger("logger1") logger.setLevel(logging.WARNING) assert log.logging_level != logging.WARNING log.register_logger(logger) assert logger.level == log.logging_level def test_set_level_sets_all_logger_levels(setup_logging): """set_level sets all logger levels all together.""" logger1 = logging.getLogger("test_logger1") logger2 = logging.getLogger("test_logger2") logger3 = logging.getLogger("test_logger3") logger4 = logging.getLogger("test_logger4") logger1.setLevel(logging.DEBUG) logger2.setLevel(logging.DEBUG) logger3.setLevel(logging.DEBUG) logger4.setLevel(logging.DEBUG) log.register_logger(logger1) log.register_logger(logger2) log.register_logger(logger3) log.register_logger(logger4) assert logger1.level != logging.WARNING assert logger2.level != logging.WARNING assert logger3.level != logging.WARNING assert logger4.level != logging.WARNING log.set_level(logging.WARNING) assert logger1.level == logging.WARNING assert logger2.level == logging.WARNING assert logger3.level == logging.WARNING assert logger4.level == logging.WARNING def test_set_level_level_is_not_an_integer(setup_logging): """TypeError raised if the logging level is not an integer.""" with pytest.raises(TypeError) as cm: log.set_level("not a logging level") assert str(cm.value) == ( "level should be an integer value one of [0, 10, 20, 30, 40, 50] or " "[NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, FATAL, CRITICAL] of the " "logging library, not str: 'not a logging level'" ) def test_set_level_level_is_not_a_proper_logging_level(setup_logging): """ValueError raised if the logging level is not in correct value.""" with pytest.raises(ValueError) as cm: log.set_level(1000) assert str(cm.value) == ( "level should be an integer value one of [0, 10, 20, 30, 40, 50] or " "[NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, FATAL, CRITICAL] of the " "logging library, not 1000." ) def test_get_logger_name_is_not_a_string(setup_logging): """stalker.get_logger() raises a TypeError if the name attribute is not a str.""" with pytest.raises(TypeError) as cm: log.get_logger(2123) assert str(cm.value) == "A logger name must be a string" def test_get_logger_creates_a_logger(setup_logging): """stalker.log.get_logger() returns a Logger instance.""" logger = log.get_logger("logger") assert isinstance(logger, logging.Logger) def test_get_logger_registers_the_new_logger_already(setup_logging): """stalker.log.get_logger() registers the new logger.""" logger = log.get_logger("logger") assert logger in log.loggers def test_get_logger_sets_the_logging_level_to_the_default_one(setup_logging): """stalker.log.get_logger() sets the logging level to the default one.""" logger = log.get_logger("logger") assert logger.level == log.logging_level ================================================ FILE: tests/test_readme_tutorial.py ================================================ # -*- coding: utf-8 -*- import stalker.db.setup from stalker import db from stalker.db.session import DBSession from stalker import ( Asset, FilenameTemplate, ImageFormat, Repository, Project, Shot, Structure, Task, Type, User, Version, ) from stalker.models.enum import TimeUnit def test_readme_tutorial_code(setup_sqlite3): """the tutorial code in README.rst.""" stalker.db.setup.setup() stalker.db.setup.init() assert str(DBSession.connection().engine.url) == "sqlite://" me = User( name="Erkan Ozgur Yilmaz", login="erkanozgur", email="my_email@gmail.com", password="secretpass", ) # Save the user to database DBSession.save(me) repo = Repository( name="Commercial Projects Repository", code="CPR", windows_path="Z:/Projects", linux_path="/mnt/Z/Projects", macos_path="/Volumes/Z/Projects", ) task_template = FilenameTemplate( name="Standard Task Filename Template", target_entity_type="Task", # This is for files saved for Tasks path="{{project.repository.path}}/{{project.code}}/" "{%- for parent_task in parent_tasks -%}" "{{parent_task.nice_name}}/" "{%- endfor -%}", # This is Jinja2 template code filename='{{version.nice_name}}_v{{"%03d"|format(version.version_number)}}', ) standard_folder_structure = Structure( name="Standard Project Folder Structure", templates=[task_template], custom_template="{{project.code}}/References", # If you need extra folders ) new_project = Project( name="Test Project", code="TP", structure=standard_folder_structure, repositories=[repo], # if you have more than one repository you can do it ) hd1080 = ImageFormat(name="1080p", width=1920, height=1080) new_project.image_format = hd1080 # Save the project and all the other data it is connected to it DBSession.save(new_project) # define Character asset type char_type = Type(name="Character", code="CHAR", target_entity_type="Asset") character1 = Asset( name="Character 1", code="CHAR1", type=char_type, project=new_project ) # Save the Asset DBSession.save(character1) model = Task(name="Model", parent=character1) rigging = Task( name="Rig", parent=character1, depends_on=[model], # For project management, define that Rig cannot start # before Model ends. ) # Save the new tasks DBSession.save([model, rigging]) # A shot and some tasks for it shot = Shot(name="SH001", code="SH001", project=new_project) # Save the Shot DBSession.save(shot) animation = Task( name="Animation", parent=shot, ) lighting = Task( name="Lighting", parent=shot, depends_on=[animation], # Lighting cannot start before Animation ends, schedule_timing=1, schedule_unit=TimeUnit.Day, # The task expected to take 1 day to complete resources=[me], ) DBSession.save([animation, lighting]) new_version = Version(task=animation) new_version.generate_path() # to render the naming convention template new_version.extension = ".ma" # let's say that we have created under Maya DBSession.save(new_version) path = new_version.generate_path(extension=".ma") assert str(path) == f"{repo.path}TP/SH001/Animation/SH001_Animation_v001.ma" assert new_version.version_number == 1 new_version2 = Version(task=animation) DBSession.save(new_version2) # to render the naming convention template # let's say that we have created under Maya # path = new_version2.generate_path(extension = ".ma") assert new_version2.version_number == 2 ================================================ FILE: tests/test_testing.py ================================================ # -*- coding: utf-8 -*- """stalker.testing module.""" import pytest from tests.utils import get_server_details_from_url @pytest.mark.parametrize( "url,expected", [ ( "postgresql://postgres:postgres@localhost:5432/stalker_test_e0b9bc6a", { "dialect": "postgresql", "username": "postgres", "password": "postgres", "hostname": "localhost", "port": "5432", "database_name": "stalker_test_e0b9bc6a", }, ), ( "postgresql://postgres:postgres@localhost/stalker_test_e0b9bc6a", { "dialect": "postgresql", "username": "postgres", "password": "postgres", "hostname": "localhost", "port": "", "database_name": "stalker_test_e0b9bc6a", }, ), ], ) def test_get_server_details_from_url(url, expected): """get_server_details_from_url.""" assert get_server_details_from_url(url) == expected ================================================ FILE: tests/test_version.py ================================================ # -*- coding: utf-8 -*- import os # Local Imports import stalker from stalker import version def test_version_number_is_correct(): """version.VERSION is correct.""" version_file_path = os.path.join(os.path.dirname(stalker.__file__), "VERSION") with open(version_file_path) as f: expected_version = f.read().strip() assert expected_version == version.__version__ def test_version_number_as_a_module_level_variable(): """stalker.__version__ exists and value is correct.""" assert version.__version__ == stalker.__version__ ================================================ FILE: tests/utils.py ================================================ # -*- coding: utf-8 -*- import datetime import os import platform import re import subprocess import uuid from sqlalchemy.exc import OperationalError from sqlalchemy.orm import close_all_sessions from stalker import log, defaults, User from stalker.db.declarative import Base from stalker.db.session import DBSession logger = log.get_logger(__name__) logger.setLevel(log.logging_level) # {dialect}://{username}:{password}@{address}/{database_name} DB_REGEX = re.compile( r"(?P\w+)" r"://" r"(?P\w+)" r":" r"(?P[\w\s#?!$%^&*\-]+)" r"@*" r"(?P[\w.]+)" r":*" r"(?P\d*)" r"/*" r"(?P[\w_\-]*)" ) def run_db_command( database_name="testdb", dialect="postgresql", hostname="localhost", port=5432, username="postgres", password="postgres", command="", ): """Run db command on a Postgres database. Args: database_name (str): The database name to create. dialect (str): The database dialect, default is postgresql and currently nothing else is supported. hostname (str): The DB server hostname, default is 'localhost'. port (int): The port number, default is 5432. username (str): The username, default is 'postgres'. password (str): The password, default is 'postgres'. command (str): The command to run. Returns: str: The database url. """ if port == "": port = 5432 psql_command = [ "psql", "--host", hostname, "--port", str(port), "--username", username, "--no-password", "--command", command, ] proc = subprocess.Popen( psql_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={"PGPASSWORD": password}.update(os.environ), ) stdout_buffer = [] stderr_buffer = [] while True: stdout = proc.stdout.readline().strip() stderr = proc.stderr.readline().strip() if not isinstance(stdout, str): stdout = stdout.decode("utf-8", "replace") if not isinstance(stderr, str): stderr = stderr.decode("utf-8", "replace") if stdout == "" and stderr == "" and proc.poll() is not None: break if stdout != "": stdout_buffer.append(stdout) if stderr != "": stderr_buffer.append(stderr) logger.debug("STDOUT BUFFER") logger.debug("=============") for line in stdout_buffer: logger.debug(line) logger.debug("STDERR BUFFER") logger.debug("=============") for line in stderr_buffer: logger.debug(line) return stdout_buffer, stderr_buffer def create_db( database_name="testdb", dialect="postgresql", hostname="localhost", port=5432, username="postgres", password="postgres", ): """Create a new Postgres database. Args: database_name (str): The database name to create. dialect (str): The database dialect, default is postgresql and currently nothing else is supported. hostname (str): The DB server hostname, default is 'localhost'. port (int): The port number, default is 5432. username (str): The username, default is 'postgres'. password (str): The password, default is 'postgres'. Returns: str: The database url. """ logger.debug("Creating Database: {}".format(database_name)) if port == "": port = 5432 # use default database_url = ( f"{dialect}://{username}:{password}@{hostname}:{port}/{database_name}" ) command = "CREATE DATABASE {};".format(database_name) run_db_command( database_name=database_name, dialect=dialect, hostname=hostname, port=port, username=username, password=password, command=command, ) return database_url def get_server_details_from_url(url): """Return database details from the given url. Args: url (str): Database url in dialect}://{user_name}:{password}@{address}/{database_name} format. Returns: dict: Returns a dictionary with "dialect", "user_name", "password", "address", "database_name" keys. """ return_val = dict() match = DB_REGEX.match(url) if match: return_val = match.groupdict() return return_val def create_random_db(): """creates a random named Postgres database :returns (str): db_url """ # create a new database for this test only database_url = os.environ.get( "STALKER_TEST_DB", "postgresql://postgres:postgres@localhost/testdb" ) database_name = "stalker_test_{}".format(uuid.uuid4().hex[:8]) # get server details db_kwargs = get_server_details_from_url(database_url) # replace database name db_kwargs["database_name"] = database_name return create_db(**db_kwargs) def drop_db( database_name="testdb", dialect="postgresql", hostname="localhost", port=5432, username="postgres", password="postgres", ): """Drop the given Postgres database. Args: database_name (str): The database name to create. dialect (str): The database dialect, default is postgresql and currently nothing else is supported. hostname (str): The DB server hostname, default is 'localhost'. port (Union[str, int]): The port number, default is 5432. username (str): The username, default is 'postgres'. password (str): The password, default is 'postgres'. """ logger.debug("Dropping Database: {}".format(database_name)) command = "DROP DATABASE {};".format(database_name) run_db_command( database_name=database_name, dialect=dialect, hostname=hostname, port=port, username=username, password=password, command=command, ) class PlatformPatcher(object): """patches given callable""" def __init__(self): self.callable = None self.original = None def patch(self, desired_result): """Patch platform.""" self.original = platform.system def f(): return desired_result platform.system = f def restore(self): """restores the given callable_""" if self.original: platform.system = self.original def tear_down_db(data): """Utility function to tear a test setup down.""" # clean up test database DBSession.rollback() connection = DBSession.connection() engine = connection.engine connection.close() try: Base.metadata.drop_all(engine, checkfirst=True) DBSession.remove() close_all_sessions() db_kwargs = get_server_details_from_url(data.get("database_url", "")) drop_db(**db_kwargs) except OperationalError: pass finally: defaults["timing_resolution"] = datetime.timedelta(hours=1) def get_admin_user(): """Return admin user from database. Returns: stalker.User: The admin user """ with DBSession.no_autoflush: return User.query.filter(User.login == defaults.admin_login).first() ================================================ FILE: whitelist.txt ================================================ autoflush autogenerate BFS CMPL CodeMixin CRUDL csv DBSession DDL DEFERRABLE DFS DREV expandvars fetchall FilenameTemplates formatter HREV Lite3 macOS minallocated mixin Mixins myapp mymodel normpath nullable num OH onend onstart oy oyProjectManager Postgre PostgreSQL preliminarily PREV repo RREV RTS sessionmaker SOM sqlalchemy SQLite3 StatusList STOP tablename Taskable TimeLog TJ tj3 tjp UniqueConstraint unmanaged WFD WIP WorkingHours