diff --git a/Makefile b/Makefile index 9d70f86..445981f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint mypy test sync +.PHONY: lint mypy test sync migrate revision pyinstaller clean-pyinstaller all: lint mypy test @@ -10,10 +10,25 @@ mypy: uv run mypy src/ test: - PYTHONPATH=$(PWD) uv run pytest --cov --cov-report term-missing:skip-covered + PYTHONPATH=$(PWD) uv run pytest --cov --cov-report term-missing:skip-covered --tb=short sync: uv sync --all-groups dump: ./dump.sh src src.dump + +# Create a new migration revision +revision: + @read -p "Enter migration message: " message; \ + python -m scripts.migration_manager --revision "$$message" + +# Run database migrations +migrate: + python -m scripts.migration_manager + +pyinstaller: + pyinstaller ./JobTrackr.spec +clean-pyinstaller: + rm -rf build dist + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0fbe2c2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a1fc7fe --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,87 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +import os +import sys + +# Add the project root to the path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from src.db.settings import Settings +from src.db.models 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. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# Get database URL from settings +settings = Settings() +db_path = settings.get("database_path") +config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + + +def run_migrations_offline() -> None: + """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, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0d60e646518c_.py b/alembic/versions/0d60e646518c_.py new file mode 100644 index 0000000..24247bc --- /dev/null +++ b/alembic/versions/0d60e646518c_.py @@ -0,0 +1,62 @@ +"""empty message + +Revision ID: 0d60e646518c +Revises: 5391b8a670c1 +Create Date: 2025-05-09 14:33:50.858489 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0d60e646518c' +down_revision: Union[str, None] = '5391b8a670c1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('applications', schema=None) as batch_op: + batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True)) + batch_op.alter_column('position', + existing_type=sa.VARCHAR(length=255), + nullable=False) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + batch_op.alter_column('applied_date', + existing_type=sa.DATETIME(), + nullable=False) + batch_op.drop_column('remote') + batch_op.drop_column('job_url') + batch_op.drop_column('status_details') + batch_op.drop_column('application_method') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('applications', schema=None) as batch_op: + batch_op.add_column(sa.Column('application_method', sa.VARCHAR(length=50), nullable=True)) + batch_op.add_column(sa.Column('status_details', sa.TEXT(), nullable=True)) + batch_op.add_column(sa.Column('job_url', sa.VARCHAR(length=255), nullable=True)) + batch_op.add_column(sa.Column('remote', sa.VARCHAR(length=50), nullable=True)) + batch_op.alter_column('applied_date', + existing_type=sa.DATETIME(), + nullable=True) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + batch_op.alter_column('position', + existing_type=sa.VARCHAR(length=255), + nullable=True) + batch_op.drop_column('notes') + + # ### end Alembic commands ### diff --git a/alembic/versions/5391b8a670c1_add_link_to_applications.py b/alembic/versions/5391b8a670c1_add_link_to_applications.py new file mode 100644 index 0000000..f1403d6 --- /dev/null +++ b/alembic/versions/5391b8a670c1_add_link_to_applications.py @@ -0,0 +1,30 @@ +"""add_link_to_applications + +Revision ID: 5391b8a670c1 +Revises: b610c5984d3c +Create Date: 2025-05-09 14:31:34.028056 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5391b8a670c1' +down_revision: Union[str, None] = 'b610c5984d3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Add link column to applications table + op.add_column('applications', sa.Column('link', sa.String(255), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + # Remove link column from applications table + op.drop_column('applications', 'link') diff --git a/alembic/versions/7cb7e29ef42a_.py b/alembic/versions/7cb7e29ef42a_.py new file mode 100644 index 0000000..79bb032 --- /dev/null +++ b/alembic/versions/7cb7e29ef42a_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 7cb7e29ef42a +Revises: 88926e4364a0 +Create Date: 2025-05-09 15:06:42.153 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7cb7e29ef42a' +down_revision: Union[str, None] = '88926e4364a0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('change_records', schema=None) as batch_op: + batch_op.alter_column('change_type', + existing_type=sa.VARCHAR(length=50), + nullable=False) + batch_op.create_foreign_key('fk_change_records_application_id_applications', 'applications', ['application_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('change_records', schema=None) as batch_op: + batch_op.drop_constraint('fk_change_records_application_id_applications', type_='foreignkey') + batch_op.alter_column('change_type', + existing_type=sa.VARCHAR(length=50), + nullable=True) + + # ### end Alembic commands ### diff --git a/alembic/versions/88926e4364a0_.py b/alembic/versions/88926e4364a0_.py new file mode 100644 index 0000000..fe7ddcd --- /dev/null +++ b/alembic/versions/88926e4364a0_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 88926e4364a0 +Revises: 0d60e646518c +Create Date: 2025-05-09 14:34:34.440339 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '88926e4364a0' +down_revision: Union[str, None] = '0d60e646518c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/abe249e110f8_init.py b/alembic/versions/abe249e110f8_init.py new file mode 100644 index 0000000..07935ca --- /dev/null +++ b/alembic/versions/abe249e110f8_init.py @@ -0,0 +1,127 @@ +"""init + +Revision ID: abe249e110f8 +Revises: +Create Date: 2025-05-09 00:30:59.487485 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'abe249e110f8' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('companies', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('industry', sa.String(length=255), nullable=True), + sa.Column('website', sa.String(length=255), nullable=True), + sa.Column('type', sa.String(length=50), nullable=True), + sa.Column('size', sa.String(length=50), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('applications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('job_title', sa.String(length=255), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('position', sa.String(length=255), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('salary_min', sa.Integer(), nullable=True), + sa.Column('salary_max', sa.Integer(), nullable=True), + sa.Column('location', sa.String(length=255), nullable=True), + sa.Column('remote', sa.String(length=50), nullable=True), + sa.Column('application_method', sa.String(length=50), nullable=True), + sa.Column('job_url', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=50), nullable=True), + sa.Column('status_details', sa.Text(), nullable=True), + sa.Column('applied_date', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], name='fk_applications_company_id_companies'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('company_relationships', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('source_company_id', sa.Integer(), nullable=False), + sa.Column('related_company_id', sa.Integer(), nullable=False), + sa.Column('relationship_type', sa.String(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['related_company_id'], ['companies.id'], name='fk_company_relationships_related_company_id_companies'), + sa.ForeignKeyConstraint(['source_company_id'], ['companies.id'], name='fk_company_relationships_source_company_id_companies'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('contacts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('company_id', sa.Integer(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], name='fk_contacts_company_id_companies'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('change_records', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('application_id', sa.Integer(), nullable=False), + sa.Column('change_type', sa.String(length=50), nullable=True), + sa.Column('old_value', sa.String(length=255), nullable=True), + sa.Column('new_value', sa.String(length=255), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['application_id'], ['applications.id'], name='fk_change_records_application_id_applications'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('contact_applications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('contact_id', sa.Integer(), nullable=False), + sa.Column('application_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['application_id'], ['applications.id'], name='fk_contact_applications_application_id_applications'), + sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], name='fk_contact_applications_contact_id_contacts'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('interactions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('contact_id', sa.Integer(), nullable=False), + sa.Column('application_id', sa.Integer(), nullable=True), + sa.Column('interaction_type', sa.String(length=50), nullable=True), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['application_id'], ['applications.id'], name='fk_interactions_application_id_applications'), + sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], name='fk_interactions_contact_id_contacts'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('interactions') + op.drop_table('contact_applications') + op.drop_table('change_records') + op.drop_table('contacts') + op.drop_table('company_relationships') + op.drop_table('applications') + op.drop_table('companies') + # ### end Alembic commands ### diff --git a/alembic/versions/b610c5984d3c_add_subject_to_interactions.py b/alembic/versions/b610c5984d3c_add_subject_to_interactions.py new file mode 100644 index 0000000..48c2879 --- /dev/null +++ b/alembic/versions/b610c5984d3c_add_subject_to_interactions.py @@ -0,0 +1,28 @@ +"""add_subject_to_interactions + +Revision ID: b610c5984d3c +Revises: abe249e110f8 +Create Date: 2025-05-09 14:18:11.909276 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b610c5984d3c' +down_revision: Union[str, None] = 'abe249e110f8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('interactions', sa.Column('subject', sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('interactions', 'subject') diff --git a/dump.sh b/dump.sh new file mode 100755 index 0000000..fc23fc5 --- /dev/null +++ b/dump.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Check if at least one directory is provided +if [ $# -lt 1 ]; then + echo "Usage: $0 [output_file]" + echo " If output_file is not provided, project_dump.txt will be used" + exit 1 +fi + +# Get the directory to search +directory="$1" + +# Set the output file (default to project_dump.txt if not provided) +output_file="${2:-project_dump.txt}" + +# Create or clear the output file +> "$output_file" + +echo "Dumping all Python files from $directory to $output_file..." + +# Find all .py files in the directory and its subdirectories +find "$directory" -type f \( -name "*.py" -o -name "*.tcss" \) | sort | while read -r file; do + # Add file path as header with a clear format + echo "############################################################" >> "$output_file" + echo "# FILE: $file" >> "$output_file" + echo "############################################################" >> "$output_file" + echo "" >> "$output_file" # Empty line for better readability + + # Append file content + cat "$file" >> "$output_file" + + # Add a separator for better readability + echo "" >> "$output_file" + echo "" >> "$output_file" +done + +echo "Done! Project code has been dumped to $output_file" diff --git a/pyproject.toml b/pyproject.toml index f11a733..21d776f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,17 +8,21 @@ license = {text = "MIT"} requires-python = ">=3.13" dependencies = [ - "plotext>=5.3.2", + "alembic>=1.15.2", + "matplotlib>=3.10.1", + "networkx>=3.4.2", + "pyqt6>=6.9.0", "sqlalchemy>=2.0.40", - "textual>=3.1.1", ] [dependency-groups] dev = [ "mypy>=1.15.0", + "pyinstaller>=6.13.0", "pytest>=8.3.5", + "pytest-cov>=6.1.1", + "pytest-qt>=4.4.0", "ruff>=0.11.7", - "textual-dev>=1.7.0", ] [tool.ruff] @@ -79,9 +83,19 @@ skip-magic-trailing-comma = false [tool.pytest] -python_files = "tests.py test_*.py *_tests.py" -norecursedirs = ".git .env venv" - +testpaths = "test" +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::UserWarning" + ] +markers = [ + "gui: marks tests that require GUI interaction", + "slow: marks tests that are slow to run" + ] [tool.mypy] python_version = "3.13" @@ -113,7 +127,7 @@ relative_files = true show_missing = true [project.scripts] -jobtrackr = "src.tui.app:app" +jobtrackr = "src.main:main" [build-system] requires = ["hatchling"] diff --git a/src/config/__init__.py b/src/config/__init__.py index 029ee32..7cfbb2c 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -16,12 +16,7 @@ DEFAULT_EXPORT_DIR = DEFAULT_DATA_DIR / "exports" # Application settings defaults -DEFAULT_SETTINGS = { - "database_path": str(DEFAULT_DB_PATH), - "export_directory": str(DEFAULT_EXPORT_DIR), - "check_updates": True, - "save_window_size": True, -} +DEFAULT_SETTINGS = {"database_path": str(DEFAULT_DB_PATH), "check_updates": True} # Ensure required directories exist CONFIG_DIR.mkdir(exist_ok=True) @@ -71,6 +66,7 @@ class ChangeType(enum.Enum): STATUS_CHANGE = "STATUS_CHANGE" INTERACTION_ADDED = "INTERACTION_ADDED" CONTACT_ADDED = "CONTACT_ADDED" + CONTACT_REMOVED = "CONTACT_REMOVED" APPLICATION_UPDATED = "APPLICATION_UPDATED" NOTE_ADDED = "NOTE_ADDED" DOCUMENT_ADDED = "DOCUMENT_ADDED" @@ -86,3 +82,51 @@ class ChangeType(enum.Enum): "partner", "other", ] + +# UI colors +UI_COLORS = { + "primary": "#2C7BE5", # Primary brand color + "secondary": "#6B7280", # Secondary text/elements + "success": "#00B860", # Success states + "warning": "#F59E0B", # Warning states + "danger": "#E11D48", # Error states + "info": "#0EA5E9", # Info states + "light": "#F3F4F6", # Light backgrounds + "dark": "#1F2937", # Dark text/elements + "background": "#FFFFFF", # Main background + "card": "#F9FAFB", # Card background +} + +# Status colors for application states +STATUS_COLORS = { + "SAVED": "#6B7280", # Gray + "APPLIED": "#0EA5E9", # Blue + "PHONE_SCREEN": "#F59E0B", # Orange + "INTERVIEW": "#F59E0B", # Orange + "TECHNICAL_INTERVIEW": "#F59E0B", # Orange + "OFFER": "#00B860", # Green + "ACCEPTED": "#059669", # Darker Green + "REJECTED": "#E11D48", # Red + "WITHDRAWN": "#6B7280", # Gray +} + +# Font sizes +FONT_SIZES = { + "xs": 8, + "sm": 10, + "md": 12, + "lg": 14, + "xl": 16, + "2xl": 20, + "3xl": 24, +} + +# Spacing constants +SPACING = { + "xs": 4, + "sm": 8, + "md": 12, + "lg": 16, + "xl": 24, + "2xl": 32, +} diff --git a/src/db/database.py b/src/db/database.py index d8a4515..51f3abf 100644 --- a/src/db/database.py +++ b/src/db/database.py @@ -1,97 +1,51 @@ -import os +"""Database connection and session management.""" from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import Session, sessionmaker from src.db.settings import Settings -from src.utils.logging import get_logger -# Set up module logger -logger = get_logger(__name__) - -# Create base model class -Base = declarative_base() - -# Global variables -engine = None -SessionLocal = None +# Create engine once at module level settings = Settings() +engine = create_engine(f"sqlite:///{settings.get('database_path')}") +# Create sessionmaker +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -def init_db(db_path=None): - """Initialize the database, creating tables if they don't exist.""" - global engine, SessionLocal - - try: - # Use specified path or get from settings - if db_path is None: - db_path = settings.get_database_path() - - # Ensure directory exists - db_dir = os.path.dirname(db_path) - os.makedirs(db_dir, exist_ok=True) - - # Log database location - logger.info(f"Initializing database at {db_path}") - # Create database engine - engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) - - # Create session factory - session_factory = sessionmaker(bind=engine) - SessionLocal = scoped_session(session_factory) - - # Create tables if they don't exist - Base.metadata.create_all(engine) - logger.info("Database tables created/verified successfully") - return True - - except Exception as e: - logger.error(f"Error initializing database: {e}", exc_info=True) - return False - - -def get_session(): - """Get a database session.""" - if SessionLocal is None: - logger.debug("No active session, initializing database") - init_db() +def get_session() -> Session: + """Get a database session. + Returns: + Session: A SQLAlchemy session object + """ session = SessionLocal() - logger.debug("Database session created") try: return session - except Exception as e: - logger.error(f"Error creating session: {e}", exc_info=True) - session.close() + except Exception: + session.rollback() raise -def change_database(new_path): - """Change the database location.""" - global engine, SessionLocal +def change_database(db_path: str) -> None: + """Change the database connection to use a different database file. - logger.info(f"Changing database to {new_path}") + This should be called after settings are updated with a new database path. - # Close existing connections - if SessionLocal: - logger.debug("Closing existing session") - SessionLocal.remove() + Args: + db_path (str): Path to the new database file. + """ + global engine, SessionLocal - if engine: - logger.debug("Disposing engine") - engine.dispose() + # Create a new engine with the updated path + engine = create_engine(f"sqlite:///{db_path}") - # Update settings - settings.set("database_path", new_path) + # Update the sessionmaker to use the new engine + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - # Reinitialize with new path - result = init_db(new_path) + # No need to call init_db() as Alembic will handle migrations + # the next time the application starts - if result: - logger.info(f"Database changed successfully to {new_path}") - else: - logger.error(f"Failed to change database to {new_path}") - return result +# The init_db function is no longer needed as migrations are now handled by Alembic +# Migration-related functions are now in scripts/migration_manager.py diff --git a/src/db/manager.py b/src/db/manager.py new file mode 100644 index 0000000..eeeb6d7 --- /dev/null +++ b/src/db/manager.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +"""Database management for JobTrackr.""" + +import os +import sys # Added for sys.frozen and sys._MEIPASS +from pathlib import Path + +from PyQt6.QtWidgets import QApplication, QMessageBox +from sqlalchemy import create_engine, inspect +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.sql import text + +from alembic import command +from alembic.config import Config +from alembic.script import ScriptDirectory +from src.db.settings import Settings +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +def get_resource_path(relative_path): + """Get absolute path to resource, works for dev and for PyInstaller""" + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + else: + # Not bundled, use the script's directory or a known project root + # In this case, for alembic.ini, the project root is two levels up from src/db/ + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + return os.path.join(base_path, relative_path) + + +def ensure_db_directory(db_path: str) -> None: + """Ensure the database directory exists.""" + db_dir = os.path.dirname(db_path) + if db_dir: + Path(db_dir).mkdir(parents=True, exist_ok=True) + + +def show_migration_dialog() -> bool: + """Show a dialog asking the user if they want to run migrations. + + Returns: + bool: True if the user chooses to run migrations, False otherwise. + """ + # Ensure a QApplication exists + app = QApplication.instance() + if app is None: + app = QApplication([]) + created_app = True + else: + created_app = False + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.setWindowTitle("Database Update") + msg_box.setText( + "Database schema updates are available. Do you want to update the database now?\n\n" + "Choosing 'No' may cause the application to malfunction." + ) + msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + result = msg_box.exec() + + if created_app: + app.quit() + + return result == QMessageBox.StandardButton.Yes + + +def run_migrations() -> bool: + """Run database migrations. + + Returns: + bool: True if migrations were successful, False otherwise. + """ + try: + # Get the path to alembic.ini + alembic_ini_path = get_resource_path("alembic.ini") + + # Create Alembic configuration + alembic_cfg = Config(alembic_ini_path) + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + # In a PyInstaller bundle, script_location needs to point to where the 'alembic' dir is. + # Our spec file bundles the 'alembic' directory preserving its name. + bundled_script_location = os.path.join(sys._MEIPASS, "alembic") + alembic_cfg.set_main_option("script_location", bundled_script_location) + # If 'src' or other modules needed by alembic/env.py are not found: + # if sys._MEIPASS not in sys.path: + # sys.path.insert(0, sys._MEIPASS) # sys._MEIPASS is the root of bundled files + # To make 'from src.db.models import Base' in env.py work if src is also at root: + # sys.path.insert(0, os.path.join(sys._MEIPASS, "src")) # If 'src' is a top-level dir in bundle + + # Run migrations + command.upgrade(alembic_cfg, "head") + logger.info("Database migrations completed successfully") + return True + + except Exception as e: + logger.error(f"Failed to run migrations: {e}") + return False + + +def check_and_run_migrations() -> bool: + """Check if migrations are needed and run them if necessary. + + Returns: + bool: True if the application should continue, False if it should exit. + """ + try: + # Get database path from settings + settings = Settings() + db_path = settings.get("database_path") + + # Ensure database directory exists + ensure_db_directory(db_path) + + # Create database engine + engine = create_engine(f"sqlite:///{db_path}") + + # Check if database exists and has tables + inspector = inspect(engine) + tables = inspector.get_table_names() + + if not tables: + # Database is empty, run migrations to create schema + logger.info("Database is empty. Running initial migration...") + return run_migrations() + + # Database exists, check if it needs updates + # Get the path to alembic.ini + alembic_ini_path = get_resource_path("alembic.ini") + + # Create Alembic configuration + alembic_cfg = Config(alembic_ini_path) + + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + # In a PyInstaller bundle, script_location needs to point to where the 'alembic' dir is. + bundled_script_location = os.path.join(sys._MEIPASS, "alembic") + alembic_cfg.set_main_option("script_location", bundled_script_location) + # If 'src' or other modules needed by alembic/env.py are not found: + # if sys._MEIPASS not in sys.path: + # sys.path.insert(0, sys._MEIPASS) + # sys.path.insert(0, os.path.join(sys._MEIPASS, "src")) + + # Get current database revision + with engine.connect() as conn: + try: + current_rev = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() + except Exception: + # If alembic_version table doesn't exist, we need to run migrations + current_rev = None + + # Get latest revision from migrations + script = ScriptDirectory.from_config(alembic_cfg) + head_revision = script.get_current_head() + + # Only show dialog if migrations are needed + if current_rev != head_revision: + if show_migration_dialog(): + return run_migrations() + logger.warning("User chose not to run migrations") + return False + + return True + + except SQLAlchemyError as e: + logger.error(f"Database error: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error: {e}") + return False diff --git a/src/db/models.py b/src/db/models.py index 44c3652..3a06ba7 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -9,163 +9,241 @@ Table, Text, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship -from src.config import CompanyType -from src.db.database import Base +Base = declarative_base() -# Association tables for many-to-many relationships -application_contact = Table( - "application_contact", +# Association table for contacts and applications +contact_applications = Table( + "contact_applications", Base.metadata, - Column("application_id", ForeignKey("applications.id"), primary_key=True), - Column("contact_id", ForeignKey("contacts.id"), primary_key=True), + Column("id", Integer, primary_key=True), + Column("contact_id", ForeignKey("contacts.id", name="fk_contact_applications_contact_id_contacts"), nullable=False), + Column( + "application_id", + ForeignKey("applications.id", name="fk_contact_applications_application_id_applications"), + nullable=False, + ), + Column("created_at", DateTime, default=datetime.utcnow), ) -interaction_contact = Table( - "interaction_contact", - Base.metadata, - Column("interaction_id", ForeignKey("interactions.id"), primary_key=True), - Column("contact_id", ForeignKey("contacts.id"), primary_key=True), -) - - -class CompanyRelationship(Base): - __tablename__ = "company_relationships" - - id = Column(Integer, primary_key=True) - source_company_id = Column(Integer, ForeignKey("companies.id"), nullable=False) - target_company_id = Column(Integer, ForeignKey("companies.id"), nullable=False) - relationship_type = Column(String, nullable=False) # e.g., "recruits_for", "parent_company", etc. - notes = Column(Text) - - # Relationships - source_company = relationship( - "Company", - foreign_keys=[source_company_id], - back_populates="outgoing_relationships", - ) - target_company = relationship( - "Company", - foreign_keys=[target_company_id], - back_populates="incoming_relationships", - ) - - def __repr__(self): - return f"" - class Company(Base): + """Company model.""" + __tablename__ = "companies" id = Column(Integer, primary_key=True) - name = Column(String, nullable=False, index=True) - website = Column(String) - industry = Column(String, index=True) - size = Column(String) - type = Column(String, default=CompanyType.DIRECT_EMPLOYER.value) # Default to direct employer + name = Column(String(255), nullable=False) + industry = Column(String(255)) + website = Column(String(255)) + type = Column(String(50)) # DIRECT_EMPLOYER, STAFFING_AGENCY, etc + size = Column(String(50)) # S, M, L, XL etc notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships applications = relationship("Application", back_populates="company") contacts = relationship("Contact", back_populates="company") outgoing_relationships = relationship( - "CompanyRelationship", - foreign_keys=[CompanyRelationship.source_company_id], - back_populates="source_company", - cascade="all, delete-orphan", + "CompanyRelationship", foreign_keys="CompanyRelationship.source_company_id", back_populates="source_company" ) incoming_relationships = relationship( - "CompanyRelationship", - foreign_keys=[CompanyRelationship.target_company_id], - back_populates="target_company", - cascade="all, delete-orphan", + "CompanyRelationship", foreign_keys="CompanyRelationship.related_company_id", back_populates="related_company" ) + def to_dict(self): + """Convert to dictionary.""" + return { + "id": self.id, + "name": self.name, + "industry": self.industry, + "website": self.website, + "type": self.type, + "size": self.size, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + class Application(Base): + """Application model.""" + __tablename__ = "applications" id = Column(Integer, primary_key=True) - job_title = Column(String, nullable=False, index=True) - position = Column(String, nullable=False, index=True) - location = Column(String) - salary = Column(String) - status = Column(String, nullable=False, index=True) - applied_date = Column(DateTime, nullable=False, index=True) - link = Column(String) + job_title = Column(String(255), nullable=False) + position = Column(String(255), nullable=False) + location = Column(String(255)) + salary_min = Column(Integer) + salary_max = Column(Integer) + status = Column(String(50), nullable=False) + applied_date = Column(DateTime, nullable=False) + link = Column(String(255)) description = Column(Text) notes = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow, index=True) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, onupdate=datetime.utcnow) # Foreign keys company_id = Column(Integer, ForeignKey("companies.id")) - - # Relationships company = relationship("Company", back_populates="applications") - contacts = relationship("Contact", secondary=application_contact, back_populates="applications") interactions = relationship("Interaction", back_populates="application", cascade="all, delete-orphan") change_records = relationship("ChangeRecord", back_populates="application", cascade="all, delete-orphan") - - def __repr__(self): - return f"" + contacts = relationship("Contact", secondary=contact_applications, back_populates="applications") + + def to_dict(self): + """Convert to dictionary.""" + return { + "id": self.id, + "job_title": self.job_title, + "position": self.position, + "location": self.location, + "salary_min": self.salary_min, + "salary_max": self.salary_max, + "status": self.status, + "applied_date": self.applied_date.isoformat() if self.applied_date else None, + "link": self.link, + "description": self.description, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } class Contact(Base): + """Contact model.""" + __tablename__ = "contacts" id = Column(Integer, primary_key=True) - name = Column(String, nullable=False, index=True) - title = Column(String) - email = Column(String, index=True) - phone = Column(String) + name = Column(String(255), nullable=False) + title = Column(String(255)) + email = Column(String(255)) + phone = Column(String(50)) + company_id = Column(Integer, ForeignKey("companies.id", name="fk_contacts_company_id_companies")) notes = Column(Text) - - # Foreign keys - company_id = Column(Integer, ForeignKey("companies.id")) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships company = relationship("Company", back_populates="contacts") - applications = relationship("Application", secondary=application_contact, back_populates="contacts") - interactions = relationship("Interaction", secondary=interaction_contact, back_populates="contacts") - - def __repr__(self): - return f"" + applications = relationship("Application", secondary=contact_applications, back_populates="contacts") + interactions = relationship("Interaction", back_populates="contact") + + def to_dict(self): + """Convert to dictionary.""" + return { + "id": self.id, + "name": self.name, + "title": self.title, + "email": self.email, + "phone": self.phone, + "company_id": self.company_id, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } class Interaction(Base): + """Interaction model for tracking communication with contacts.""" + __tablename__ = "interactions" id = Column(Integer, primary_key=True) - type = Column(String, nullable=False, index=True) - date = Column(DateTime, nullable=False, index=True) + contact_id = Column(Integer, ForeignKey("contacts.id", name="fk_interactions_contact_id_contacts"), nullable=False) + application_id = Column(Integer, ForeignKey("applications.id", name="fk_interactions_application_id_applications")) + interaction_type = Column(String(50)) # EMAIL, CALL, MEETING, etc + date = Column(DateTime, nullable=False) + subject = Column(String(255)) # Subject line for the interaction notes = Column(Text) - - # Foreign keys - application_id = Column(Integer, ForeignKey("applications.id")) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships - application = relationship("Application", back_populates="interactions") - contacts = relationship("Contact", secondary=interaction_contact, back_populates="interactions") - - def __repr__(self): - return f"" + contact = relationship("Contact", back_populates="interactions") + application = relationship("Application") + + def to_dict(self): + """Convert to dictionary.""" + return { + "id": self.id, + "contact_id": self.contact_id, + "application_id": self.application_id, + "interaction_type": self.interaction_type, + "date": self.date.isoformat() if self.date else None, + "subject": self.subject, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } class ChangeRecord(Base): + """Model for tracking changes to applications.""" + __tablename__ = "change_records" id = Column(Integer, primary_key=True) - application_id = Column(Integer, ForeignKey("applications.id"), nullable=False) - timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) - change_type = Column(String, nullable=False, index=True) - old_value = Column(String) - new_value = Column(String) + application_id = Column(Integer, ForeignKey("applications.id", ondelete="CASCADE"), nullable=False) + change_type = Column(String(50), nullable=False) # e.g., STATUS_CHANGE, CONTACT_ADDED, etc. + old_value = Column(String(255)) + new_value = Column(String(255)) notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) # Relationships application = relationship("Application", back_populates="change_records") - def __repr__(self): - return f"" + def to_dict(self): + """Convert the change record to a dictionary.""" + return { + "id": self.id, + "application_id": self.application_id, + "change_type": self.change_type, + "old_value": self.old_value, + "new_value": self.new_value, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +class CompanyRelationship(Base): + """Company relationship model.""" + + __tablename__ = "company_relationships" + + id = Column(Integer, primary_key=True) + source_company_id = Column( + Integer, ForeignKey("companies.id", name="fk_company_relationships_source_company_id_companies"), nullable=False + ) + related_company_id = Column( + Integer, + ForeignKey("companies.id", name="fk_company_relationships_related_company_id_companies"), + nullable=False, + ) + relationship_type = Column(String, nullable=False) + notes = Column(Text) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationship definitions + source_company = relationship("Company", foreign_keys=[source_company_id], back_populates="outgoing_relationships") + related_company = relationship( + "Company", foreign_keys=[related_company_id], back_populates="incoming_relationships" + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + "id": self.id, + "source_company_id": self.source_company_id, + "related_company_id": self.related_company_id, + "relationship_type": self.relationship_type, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/src/db/settings.py b/src/db/settings.py index 3c87db3..3313a14 100644 --- a/src/db/settings.py +++ b/src/db/settings.py @@ -1,101 +1,123 @@ import json import os +from pathlib import Path from typing import Any -from src.config import CONFIG_DIR, DEFAULT_SETTINGS from src.utils.logging import get_logger -# Set up module logger logger = get_logger(__name__) class Settings: - """Class to handle application settings.""" - - def __init__(self) -> None: - # Define settings file location from central config - self.config_dir = CONFIG_DIR - self.config_file = self.config_dir / "config.json" - self._config = {} - - # Load settings (or create default) - self.load() - - def load(self) -> None: - """Load settings from config file or create default if not exists.""" + """ + Handle application settings including database configuration. + + This class manages reading, writing, and accessing application settings + that are stored in a JSON file. + """ + + def __init__(self, settings_file: str | None = None): + """ + Initialize settings from the settings file. + + Args: + settings_file: Optional path to settings file. If None, uses default location. + """ + # Set default settings + self._settings = { + "database_path": str(Path.home() / ".jobtrackr" / "jobtrackr.db"), + "log_level": "INFO", + "theme": "system", + "default_view": "dashboard", + "save_window_state": True, + "auto_backup": True, + "backup_frequency_days": 7, + "check_updates": True, + } + + # Determine settings file location + if settings_file is None: + self._settings_dir = Path.home() / ".jobtrackr" + self._settings_file = self._settings_dir / "settings.json" + else: + self._settings_file = Path(settings_file) + self._settings_dir = self._settings_file.parent + + # Ensure settings directory exists + os.makedirs(self._settings_dir, exist_ok=True) + + # Load existing settings + self._load_settings() + + def _load_settings(self) -> None: + """Load settings from file, creating default file if it doesn't exist.""" try: - if self.config_file.exists(): - logger.debug(f"Loading settings from {self.config_file}") - with open(self.config_file) as f: - self._config = json.load(f) - - # Check for missing keys and apply defaults - for key, default_value in DEFAULT_SETTINGS.items(): - if key not in self._config: - logger.debug(f"Adding missing setting: {key} = {default_value}") - self._config[key] = default_value + if self._settings_file.exists(): + with open(self._settings_file) as f: + loaded_settings = json.load(f) + # Update default settings with loaded values + self._settings.update(loaded_settings) + logger.debug(f"Settings loaded from {self._settings_file}") else: - # Create default config - logger.info(f"No settings file found, creating defaults at {self.config_file}") - self._config = DEFAULT_SETTINGS.copy() - self.save() - - # Expand paths with ~ to user's home directory - for key in ["database_path", "export_directory"]: - if isinstance(self._config[key], str) and self._config[key].startswith("~"): - self._config[key] = os.path.expanduser(self._config[key]) - logger.debug(f"Expanded path for {key}: {self._config[key]}") - + # Write default settings to file + self._save_settings() + logger.info(f"Created default settings file at {self._settings_file}") except Exception as e: logger.error(f"Error loading settings: {e}", exc_info=True) - self._config = DEFAULT_SETTINGS.copy() - logger.info("Using default settings due to error") - def save(self) -> None: - """Save settings to config file.""" + def _save_settings(self) -> None: + """Save current settings to file.""" try: - logger.debug(f"Saving settings to {self.config_file}") - with open(self.config_file, "w") as f: - json.dump(self._config, f, indent=2) - logger.debug("Settings saved successfully") + with open(self._settings_file, "w") as f: + json.dump(self._settings, f, indent=2) + logger.debug(f"Settings saved to {self._settings_file}") except Exception as e: logger.error(f"Error saving settings: {e}", exc_info=True) def get(self, key: str, default: Any = None) -> Any: - """Get a setting value.""" - value = self._config.get(key, default) - logger.debug(f"Retrieved setting {key} = {value}") - return value + """ + Get a setting value by key. + + Args: + key: The setting key to retrieve + default: Value to return if key doesn't exist + + Returns: + The setting value or default if not found + """ + return self._settings.get(key, default) def set(self, key: str, value: Any) -> None: - """Set a setting value.""" - logger.info(f"Setting {key} = {value}") - self._config[key] = value - self.save() + """ + Set a setting value and save to file. + + Args: + key: The setting key to set + value: The value to set + """ + self._settings[key] = value + self._save_settings() + + def get_all(self) -> dict[str, Any]: + """ + Get all settings as a dictionary. + + Returns: + A copy of the settings dictionary + """ + return self._settings.copy() def get_database_path(self) -> str: - """Get the database path, ensuring directory exists.""" + """ + Get the database file path from settings. + + Returns: + Path to the database file + """ db_path = self.get("database_path") - db_dir = os.path.dirname(db_path) # Ensure database directory exists + db_dir = os.path.dirname(db_path) os.makedirs(db_dir, exist_ok=True) - logger.debug(f"Ensured database directory exists: {db_dir}") return db_path - - def get_export_directory(self) -> str: - """Get the export directory, ensuring it exists.""" - export_dir = self.get("export_directory") - - # Ensure export directory exists - os.makedirs(export_dir, exist_ok=True) - logger.debug(f"Ensured export directory exists: {export_dir}") - - return export_dir - - def database_exists(self) -> bool: - """Check if the database file exists.""" - exists = os.path.exists(self.get_database_path()) - logger.debug(f"Database exists check: {exists}") - return exists diff --git a/src/tui/__init__.py b/src/gui/__init__.py similarity index 100% rename from src/tui/__init__.py rename to src/gui/__init__.py diff --git a/src/tui/tabs/__init__.py b/src/gui/components/__init__.py similarity index 100% rename from src/tui/tabs/__init__.py rename to src/gui/components/__init__.py diff --git a/src/gui/components/data_table.py b/src/gui/components/data_table.py new file mode 100644 index 0000000..6fcc6dc --- /dev/null +++ b/src/gui/components/data_table.py @@ -0,0 +1,58 @@ +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QAbstractItemView, QHeaderView, QTableWidget + + +class DataTable(QTableWidget): + """Standardized table widget for consistent data display.""" + + row_double_clicked = pyqtSignal(int) # Emits row id when double-clicked + + def __init__(self, rows, columns, parent=None): + """Initialize the table with standard configuration. + + Args: + columns: List of column names + parent: Parent widget + """ + super().__init__(rows, len(columns), parent) + + # Set column headers + self.setHorizontalHeaderLabels(columns) + + # Configure header and selection behavior + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.verticalHeader().setVisible(False) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + + # Style + self.setAlternatingRowColors(True) + self.setStyleSheet(""" + QTableWidget { + border: 1px solid #E5E7EB; + gridline-color: #E5E7EB; + } + QTableWidget::item { + padding: 4px; + } + QHeaderView::section { + background-color: #F3F4F6; + padding: 8px; + border: none; + border-bottom: 1px solid #D1D5DB; + font-weight: bold; + } + QTableWidget::item:selected { + background-color: #EFF6FF; + color: #1F2937; + } + """) + + # Connect double-click signal + self.cellDoubleClicked.connect(self._on_cell_double_clicked) + + def _on_cell_double_clicked(self, row, column): + """Handle cell double click.""" + id_item = self.item(row, 0) + if id_item: + self.row_double_clicked.emit(int(id_item.text())) diff --git a/src/gui/components/dialog_header.py b/src/gui/components/dialog_header.py new file mode 100644 index 0000000..b965291 --- /dev/null +++ b/src/gui/components/dialog_header.py @@ -0,0 +1,40 @@ +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +from src.config import FONT_SIZES, UI_COLORS + + +class DialogHeader(QWidget): + """Standardized header for dialogs.""" + + def __init__(self, title, subtitle=None, parent=None): + super().__init__(parent) + + self.setStyleSheet(f""" + DialogHeader {{ + background-color: {UI_COLORS["card"]}; + border-bottom: 1px solid #E5E7EB; + padding-bottom: 8px; + margin-bottom: 16px; + }} + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + + # Title + self.title_label = QLabel(title) + title_font = QFont() + title_font.setPointSize(FONT_SIZES["2xl"]) + title_font.setBold(True) + self.title_label.setFont(title_font) + layout.addWidget(self.title_label) + + # Subtitle if provided + if subtitle: + self.subtitle_label = QLabel(subtitle) + subtitle_font = QFont() + subtitle_font.setPointSize(FONT_SIZES["md"]) + self.subtitle_label.setFont(subtitle_font) + self.subtitle_label.setStyleSheet(f"color: {UI_COLORS['secondary']};") + layout.addWidget(self.subtitle_label) diff --git a/src/gui/components/status_badge.py b/src/gui/components/status_badge.py new file mode 100644 index 0000000..feb46f2 --- /dev/null +++ b/src/gui/components/status_badge.py @@ -0,0 +1,37 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QLabel + +from src.config import STATUS_COLORS + + +class StatusBadge(QLabel): + """A badge to display application status with consistent styling.""" + + def __init__(self, status, parent=None): + """Initialize status badge. + + Args: + status: Status text to display + parent: Parent widget + """ + super().__init__(status, parent) + self.status = status + self._apply_style() + + def _apply_style(self): + """Apply styling based on status.""" + color = STATUS_COLORS.get(self.status, STATUS_COLORS["SAVED"]) + + self.setStyleSheet(f""" + QLabel {{ + background-color: {color}30; + color: {color}; + border: 1px solid {color}50; + border-radius: 4px; + padding: 4px 8px; + font-weight: bold; + }} + """) + + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setMinimumWidth(100) diff --git a/src/gui/components/styled_button.py b/src/gui/components/styled_button.py new file mode 100644 index 0000000..f235ac2 --- /dev/null +++ b/src/gui/components/styled_button.py @@ -0,0 +1,82 @@ +from PyQt6.QtWidgets import QPushButton + +from src.config import UI_COLORS + + +class StyledButton(QPushButton): + """Custom styled button with consistent appearance.""" + + TYPES = { + "primary": { + "background": UI_COLORS["primary"], + "text": "#FFFFFF", + "border": UI_COLORS["primary"], + "hover_bg": "#1A56DB", + }, + "secondary": { + "background": "#FFFFFF", + "text": UI_COLORS["dark"], + "border": UI_COLORS["secondary"], + "hover_bg": UI_COLORS["light"], + }, + "danger": { + "background": UI_COLORS["danger"], + "text": "#FFFFFF", + "border": UI_COLORS["danger"], + "hover_bg": "#BE123C", + }, + "success": { + "background": UI_COLORS["success"], + "text": "#FFFFFF", + "border": UI_COLORS["success"], + "hover_bg": "#059669", + }, + "text": { + "background": "transparent", + "text": UI_COLORS["primary"], + "border": "transparent", + "hover_bg": UI_COLORS["light"], + }, + } + + def __init__(self, text="", button_type="primary", icon=None, parent=None): + """Initialize styled button. + + Args: + text: Button text + button_type: Type of button (primary, secondary, danger, success, text) + icon: Optional icon to display + parent: Parent widget + """ + super().__init__(text, parent) + self.button_type = button_type + + if icon: + self.setIcon(icon) + + self._apply_style() + + def _apply_style(self): + """Apply the style based on button type.""" + style = self.TYPES.get(self.button_type, self.TYPES["primary"]) + + self.setStyleSheet(f""" + QPushButton {{ + background-color: {style["background"]}; + color: {style["text"]}; + border: 1px solid {style["border"]}; + border-radius: 4px; + padding: 8px 16px; + font-weight: 500; + }} + + QPushButton:hover {{ + background-color: {style["hover_bg"]}; + }} + + QPushButton:disabled {{ + background-color: #E5E7EB; + color: #9CA3AF; + border-color: #E5E7EB; + }} + """) diff --git a/src/tui/tabs/applications/__init__.py b/src/gui/dialogs/__init__.py similarity index 100% rename from src/tui/tabs/applications/__init__.py rename to src/gui/dialogs/__init__.py diff --git a/src/gui/dialogs/application_detail.py b/src/gui/dialogs/application_detail.py new file mode 100644 index 0000000..a6626ad --- /dev/null +++ b/src/gui/dialogs/application_detail.py @@ -0,0 +1,826 @@ +from datetime import datetime + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QDialog, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QTableWidgetItem, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.config import FONT_SIZES, UI_COLORS +from src.gui.components.data_table import DataTable +from src.gui.components.status_badge import StatusBadge +from src.gui.components.styled_button import StyledButton +from src.gui.dialogs.application_form import ApplicationForm +from src.gui.dialogs.contact_selector import ContactSelectorDialog +from src.gui.dialogs.interaction_form import InteractionForm +from src.gui.dialogs.status_transition import StatusTransitionDialog +from src.services.application_service import ApplicationService +from src.services.change_record_service import ChangeRecordService +from src.services.contact_service import ContactService +from src.services.interaction_service import InteractionService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ApplicationDetailDialog(QDialog): + """Dialog for viewing application details.""" + + def __init__(self, parent=None, app_id=None) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.app_id = app_id + self.application_data = None + + self.setWindowTitle("Application Details") + self.resize(800, 600) + + self._init_ui() + + # Load data + if self.app_id: + self.load_application_data() + + def _init_ui(self) -> None: + """Initialize the dialog UI with improved styling.""" + layout = QVBoxLayout(self) + + # Header section with application info and status + header = QWidget() + header.setStyleSheet(f"background-color: {UI_COLORS['card']}; border-radius: 8px;") + header_layout = QHBoxLayout(header) + + # Application identity section + self.identity_layout = QVBoxLayout() + + self.app_job_title = QLabel("") + title_font = QFont() + title_font.setPointSize(FONT_SIZES["2xl"]) + title_font.setBold(True) + self.app_job_title.setFont(title_font) + + self.app_company = QLabel("") + company_font = QFont() + company_font.setPointSize(FONT_SIZES["lg"]) + self.app_company.setFont(company_font) + + self.app_position_location = QLabel("") + + self.identity_layout.addWidget(self.app_job_title) + self.identity_layout.addWidget(self.app_company) + self.identity_layout.addWidget(self.app_position_location) + + # Status section + status_layout = QVBoxLayout() + self.app_status = StatusBadge("") # Using our new component + self.app_applied_date = QLabel("") + self.app_applied_date.setAlignment(Qt.AlignmentFlag.AlignCenter) + + status_layout.addWidget(QLabel("STATUS")) + status_layout.addWidget(self.app_status) + status_layout.addWidget(self.app_applied_date) + status_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight) + + header_layout.addLayout(self.identity_layout, 2) + header_layout.addLayout(status_layout, 1) + + layout.addWidget(header) + + # Quick action buttons - using our new styled buttons + action_layout = QHBoxLayout() + self.edit_button = StyledButton("📝 Edit", "secondary") + self.status_button = StyledButton("📊 Change Status", "primary") + self.add_interaction_button = StyledButton("💬 Add Interaction", "secondary") + self.add_contact_button = StyledButton("👤 Add Contact", "secondary") + + self.edit_button.clicked.connect(self.on_edit_application) + self.status_button.clicked.connect(self.on_change_status) + self.add_interaction_button.clicked.connect(self.on_add_interaction) + self.add_contact_button.clicked.connect(self.on_add_contact) + + action_layout.addWidget(self.edit_button) + action_layout.addWidget(self.status_button) + action_layout.addWidget(self.add_interaction_button) + action_layout.addWidget(self.add_contact_button) + + layout.addLayout(action_layout) + + # Tab widget for different sections + self.tabs = QTabWidget() + + # Tab 1: Overview + overview_tab = QWidget() + overview_scroll = QScrollArea() + overview_scroll.setWidgetResizable(True) + overview_scroll.setWidget(overview_tab) + + overview_layout = QVBoxLayout(overview_tab) + + # Key details grid + overview_form = QFormLayout() + self.applied_date_label = QLabel("") + overview_form.addRow("Applied Date:", self.applied_date_label) + + self.salary_label = QLabel("") + overview_form.addRow("Salary:", self.salary_label) + + self.link_label = QLabel("") + self.link_label.setOpenExternalLinks(True) + overview_form.addRow("Link:", self.link_label) + + overview_layout.addLayout(overview_form) + + # Job description section + overview_layout.addWidget(QLabel("Job Description:")) + self.job_description = QTextEdit() + self.job_description.setReadOnly(True) + overview_layout.addWidget(self.job_description) + + # Status history section + overview_layout.addWidget(QLabel("Status History:")) + self.status_history_table = DataTable(0, ["Date", "Status", "Notes"]) + self.status_history_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.status_history_table.setMinimumHeight(150) + overview_layout.addWidget(self.status_history_table) + + # Notes section + overview_layout.addWidget(QLabel("Notes:")) + self.notes_display = QTextEdit() + self.notes_display.setReadOnly(True) + overview_layout.addWidget(self.notes_display) + + # Tab 2: Timeline + timeline_tab = QScrollArea() + timeline_tab.setWidgetResizable(True) + timeline_content = QWidget() + timeline_layout = QVBoxLayout(timeline_content) + + self.timeline_table = DataTable(0, ["Date", "Event Type", "Details"]) + self.timeline_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + timeline_layout.addWidget(self.timeline_table) + + timeline_tab.setWidget(timeline_content) + + # Tab 3: Contacts + contacts_tab = QWidget() + contacts_layout = QVBoxLayout(contacts_tab) + + contacts_layout.addWidget(QLabel("Associated Contacts")) + + # Contacts table + self.contacts_table = DataTable(0, ["ID", "Name", "Title", "Email", "Phone"]) + self.contacts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.contacts_table.setSelectionBehavior(DataTable.SelectionBehavior.SelectRows) + self.contacts_table.itemDoubleClicked.connect(self.on_contact_double_clicked) + self.contacts_table.itemSelectionChanged.connect(self._on_contact_selected) + contacts_layout.addWidget(self.contacts_table) + + # Contacts actions buttons + contacts_actions = QHBoxLayout() + self.add_contact_button = QPushButton("Add Contact") + self.add_contact_button.clicked.connect(self.on_add_contact) + + self.remove_contact_button = QPushButton("Remove Contact") + self.remove_contact_button.clicked.connect(self.on_remove_contact) + + self.new_interaction_button = QPushButton("Add Interaction") + self.new_interaction_button.clicked.connect(self.on_add_interaction) + + contacts_actions.addWidget(self.add_contact_button) + contacts_actions.addWidget(self.remove_contact_button) + contacts_actions.addWidget(self.new_interaction_button) + contacts_actions.addStretch() + contacts_layout.addLayout(contacts_actions) + + # Tab 4: Interactions + interactions_tab = QWidget() + interactions_layout = QVBoxLayout(interactions_tab) + + interactions_layout.addWidget(QLabel("Interactions")) + + # Interactions table + self.interactions_table = DataTable(0, ["Date", "Type", "Contact", "Subject", "Notes"]) + self.interactions_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.interactions_table.setSelectionBehavior(DataTable.SelectionBehavior.SelectRows) + self.interactions_table.itemSelectionChanged.connect(self._on_interaction_selected) + interactions_layout.addWidget(self.interactions_table) + + # Interactions actions + interactions_actions = QHBoxLayout() + self.edit_interaction_button = QPushButton("Edit") + self.edit_interaction_button.clicked.connect(self.on_edit_interaction) + + self.delete_interaction_button = QPushButton("Delete") + self.delete_interaction_button.clicked.connect(self.on_delete_interaction) + + interactions_actions.addWidget(self.edit_interaction_button) + interactions_actions.addWidget(self.delete_interaction_button) + interactions_actions.addStretch() + interactions_layout.addLayout(interactions_actions) + + # Add tabs to widget + self.tabs.addTab(overview_scroll, "Overview") + self.tabs.addTab(timeline_tab, "Timeline") + self.tabs.addTab(contacts_tab, "Contacts") + self.tabs.addTab(interactions_tab, "Interactions") + + layout.addWidget(self.tabs) + + # Bottom action buttons + btn_layout = QHBoxLayout() + self.back_button = QPushButton("⬅️ Back") + self.back_button.clicked.connect(self.accept) + + self.copy_link_button = QPushButton("📋 Copy Link") + self.copy_link_button.clicked.connect(self.on_copy_link) + + self.delete_button = QPushButton("🗑️ Delete") + self.delete_button.clicked.connect(self.on_delete_application) + + btn_layout.addWidget(self.back_button) + btn_layout.addStretch() + btn_layout.addWidget(self.copy_link_button) + btn_layout.addWidget(self.delete_button) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def load_application_data(self) -> None: + """Load application data and populate the UI.""" + try: + # Load application details + service = ApplicationService() + self.application_data = service.get(self.app_id) + + if not self.application_data: + if self.main_window: + self.main_window.show_status_message(f"Application {self.app_id} not found") + return + + # Update header fields + self.app_job_title.setText(self.application_data["job_title"]) + + # Show position and location together + position = self.application_data.get("position", "") + location = self.application_data.get("location", "") + + position_location = f"{position} | {location}" if location else position + self.app_position_location.setText(position_location) + + # Update company info + company_name = self.application_data.get("company", {}).get("name", "Unknown") + self.app_company.setText(company_name) + + # Status with styling + status = self.application_data["status"] + self.app_status.setText(status) + + # Set status color based on value + if status == "SAVED": + self.app_status.setStyleSheet("color: gray;") + elif status == "APPLIED": + self.app_status.setStyleSheet("color: blue;") + elif status in ["PHONE_SCREEN", "INTERVIEW", "TECHNICAL_INTERVIEW"]: + self.app_status.setStyleSheet("color: orange;") + elif status == "OFFER": + self.app_status.setStyleSheet("color: green;") + elif status == "ACCEPTED": + self.app_status.setStyleSheet("color: green; font-weight: bold;") + elif status in ["REJECTED", "WITHDRAWN"]: + self.app_status.setStyleSheet("color: red;") + + # Format and display applied date + applied_date_str = self.application_data.get("applied_date") + if applied_date_str: + try: + applied_date = datetime.fromisoformat(applied_date_str) + formatted_date = applied_date.strftime("%Y-%m-%d") + self.app_applied_date.setText(f"Applied: {formatted_date}") + self.applied_date_label.setText(formatted_date) + except (ValueError, TypeError): + self.app_applied_date.setText("Invalid Date") + self.applied_date_label.setText("Invalid Date") + else: + self.app_applied_date.setText("No date") + self.applied_date_label.setText("Not specified") + + # Update salary + salary = self.application_data.get("salary", "Not specified") + self.salary_label.setText(salary) + + # Update link with proper formatting + link = self.application_data.get("link", "") + if link: + self.link_label.setText(f"{link}") + else: + self.link_label.setText("No link provided") + + # Update job description + description = self.application_data.get("description", "No description available.") + self.job_description.setPlainText(description) + + # Update notes + notes = self.application_data.get("notes", "No notes available.") + self.notes_display.setPlainText(notes) + + # Load other data + self.load_status_history() + self.load_timeline() + self.load_interactions() + self.load_contacts() + + if self.main_window: + self.main_window.show_status_message( + f"Application details: {self.application_data['job_title']} at {company_name}" + ) + + except Exception as e: + logger.error(f"Error loading application data: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading application details: {str(e)}") + + def load_status_history(self) -> None: + """Load status history from change records.""" + try: + change_service = ChangeRecordService() + changes = change_service.get_change_records(self.app_id) + + # Filter only status changes + status_changes = [change for change in changes if change["change_type"] == "STATUS_CHANGE"] + + # Clear existing table + self.status_history_table.setRowCount(0) + + if not status_changes: + # Show initial status + self.status_history_table.insertRow(0) + + applied_date = self.application_data.get("applied_date", "Unknown") + if isinstance(applied_date, str): + applied_date = applied_date.split("T")[0] + + self.status_history_table.setItem(0, 0, QTableWidgetItem(applied_date)) + self.status_history_table.setItem( + 0, 1, QTableWidgetItem(self.application_data.get("status", "APPLIED")) + ) + self.status_history_table.setItem(0, 2, QTableWidgetItem("Initial application status")) + return + + # Sort by created_at descending + status_changes.sort(key=lambda x: x.get("created_at", ""), reverse=True) + + for i, change in enumerate(status_changes): + self.status_history_table.insertRow(i) + + # Format date + date = change.get("created_at", "").split("T")[0] if change.get("created_at") else "Unknown" + self.status_history_table.setItem(i, 0, QTableWidgetItem(date)) + self.status_history_table.setItem(i, 1, QTableWidgetItem(change.get("new_value", ""))) + self.status_history_table.setItem(i, 2, QTableWidgetItem(change.get("notes", ""))) + + except Exception as e: + logger.error(f"Error loading status history: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading status history: {str(e)}") + + def load_timeline(self) -> None: + """Load and display timeline events.""" + try: + # Get change records + change_service = ChangeRecordService() + changes = change_service.get_change_records(self.app_id) + + # Get interactions for the timeline + interaction_service = InteractionService() + interactions = interaction_service.get_interactions(self.app_id) + + # Combine all events into a timeline + timeline_events = [] + + # Add change records + for change in changes: + event_date = ( + datetime.fromisoformat(change["created_at"]) if change.get("created_at") else datetime.now() + ) + event_type = change["change_type"] + + # Format details + if event_type == "STATUS_CHANGE": + details = f"Status changed from {change['old_value']} to {change['new_value']}" + elif event_type == "APPLICATION_UPDATED": + details = "Application details were updated" + else: + if change.get("old_value") and change.get("new_value"): + details = f"Changed from {change['old_value']} to {change['new_value']}" + elif change.get("new_value"): + details = f"Set to {change['new_value']}" + else: + details = "Change recorded" + + # Add to timeline with icon indicator + timeline_events.append( + { + "date": event_date, + "type": event_type.replace("_", " "), + "details": change.get("notes", "") or details, + "icon": self._get_event_icon(event_type), + } + ) + + # Add interactions + for interaction in interactions: + event_date = datetime.fromisoformat(interaction["date"]) if interaction.get("date") else datetime.now() + details = ( + f"{interaction['interaction_type']}: " + f"{interaction['notes'][:50] + '...' if interaction['notes'] and len(interaction['notes']) > 50 else interaction['notes'] or ''}" + ) + + timeline_events.append({"date": event_date, "type": "INTERACTION", "details": details, "icon": "💬"}) + + # Add application creation as first event + if "created_at" in self.application_data: + creation_date = ( + datetime.fromisoformat(self.application_data["created_at"]) + if self.application_data.get("created_at") + else datetime.now() + ) + timeline_events.append( + { + "date": creation_date, + "type": "APPLICATION CREATED", + "details": f"Application created for {self.application_data['job_title']}", + "icon": "📝", + } + ) + + # Sort by date descending + timeline_events.sort(key=lambda x: x["date"], reverse=True) + + # Update timeline table + self.timeline_table.setRowCount(0) + + for i, event in enumerate(timeline_events): + self.timeline_table.insertRow(i) + + formatted_date = event["date"].strftime("%Y-%m-%d %H:%M") + self.timeline_table.setItem(i, 0, QTableWidgetItem(formatted_date)) + self.timeline_table.setItem(i, 1, QTableWidgetItem(f"{event['icon']} {event['type']}")) + self.timeline_table.setItem(i, 2, QTableWidgetItem(event["details"])) + + except Exception as e: + logger.error(f"Error loading timeline: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading timeline: {str(e)}") + + def _get_event_icon(self, event_type) -> str: + """Get an icon for a timeline event type.""" + icons = { + "STATUS_CHANGE": "🔄", + "INTERACTION_ADDED": "💬", + "CONTACT_ADDED": "👤", + "APPLICATION_UPDATED": "📝", + "NOTE_ADDED": "📝", + "DOCUMENT_ADDED": "📄", + } + return icons.get(event_type, "📌") + + def load_interactions(self) -> None: + """Load interactions associated with this application.""" + try: + self.interactions_table.setRowCount(0) + + if not self.app_id: + return + + # Get interactions for this application + service = InteractionService() + interactions = service.get_interactions(self.app_id) + + if not interactions: + self.interactions_table.insertRow(0) + self.interactions_table.setItem(0, 0, QTableWidgetItem("No interactions found")) + self.interactions_table.setItem(0, 1, QTableWidgetItem("")) + self.interactions_table.setItem(0, 2, QTableWidgetItem("")) + self.interactions_table.setItem(0, 3, QTableWidgetItem("")) + self.interactions_table.setItem(0, 4, QTableWidgetItem("")) + return + + for i, interaction in enumerate(interactions): + self.interactions_table.insertRow(i) + + # Store the interaction ID for later retrieval + id_item = QTableWidgetItem(str(interaction.get("id", ""))) + id_item.setData(Qt.ItemDataRole.UserRole, interaction.get("id")) + self.interactions_table.setItem(i, 0, id_item) + + # Add interaction details + self.interactions_table.setItem(i, 1, QTableWidgetItem(interaction.get("interaction_type", ""))) + + # Get contact info if available + contact_info = "" + if interaction.get("contact"): + contact_info = ( + f"{interaction['contact'].get('name', '')} ({interaction['contact'].get('title', '')})" + ) + self.interactions_table.setItem(i, 2, QTableWidgetItem(contact_info)) + + # Add subject and notes + self.interactions_table.setItem(i, 3, QTableWidgetItem(interaction.get("subject", ""))) + + # Truncate notes if too long + notes = interaction.get("notes", "") + if notes and len(notes) > 100: + notes = notes[:97] + "..." + self.interactions_table.setItem(i, 4, QTableWidgetItem(notes)) + + # Enable/disable interaction buttons based on selection + self.edit_interaction_button.setEnabled(False) + self.delete_interaction_button.setEnabled(False) + + except Exception as e: + logger.error(f"Error loading interactions: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading interactions: {str(e)}") + + def load_contacts(self) -> None: + """Load contacts associated with this application.""" + try: + self.contacts_table.setRowCount(0) + + if not self.app_id: + return + + # Get contacts associated with this application + service = ContactService() + contacts = service.get_contacts_for_application(self.app_id) + + if not contacts: + self.contacts_table.insertRow(0) + self.contacts_table.setItem(0, 0, QTableWidgetItem("No contacts found")) + self.contacts_table.setItem(0, 1, QTableWidgetItem("")) + self.contacts_table.setItem(0, 2, QTableWidgetItem("")) + self.contacts_table.setItem(0, 3, QTableWidgetItem("")) + self.contacts_table.setItem(0, 4, QTableWidgetItem("")) + return + + for i, contact in enumerate(contacts): + self.contacts_table.insertRow(i) + + # Store the contact ID for later retrieval + id_item = QTableWidgetItem(str(contact.get("id", ""))) + id_item.setData(Qt.ItemDataRole.UserRole, contact.get("id")) + self.contacts_table.setItem(i, 0, id_item) + + # Add contact details + self.contacts_table.setItem(i, 1, QTableWidgetItem(contact.get("name", ""))) + self.contacts_table.setItem(i, 2, QTableWidgetItem(contact.get("title", ""))) + self.contacts_table.setItem(i, 3, QTableWidgetItem(contact.get("email", ""))) + self.contacts_table.setItem(i, 4, QTableWidgetItem(contact.get("phone", ""))) + + # Enable/disable remove contact button based on selection + self.remove_contact_button.setEnabled(False) + + except Exception as e: + logger.error(f"Error loading contacts: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading contacts: {str(e)}") + + @pyqtSlot() + def on_edit_application(self) -> None: + """Open dialog to edit the application.""" + dialog = ApplicationForm(self, self.app_id) + if dialog.exec(): + self.load_application_data() + + @pyqtSlot() + def on_change_status(self) -> None: + """Open dialog to change application status.""" + dialog = StatusTransitionDialog(self, self.app_id, self.application_data["status"]) + if dialog.exec(): + self.load_application_data() + + @pyqtSlot() + def on_add_interaction(self) -> None: + """Open dialog to add a new interaction for this application.""" + # Get the selected contact ID if a contact is selected + selected_rows = self.contacts_table.selectedItems() + contact_id = None + + if selected_rows: + contact_id_item = self.contacts_table.item(selected_rows[0].row(), 0) + if contact_id_item and contact_id_item.text() != "No contacts found": + contact_id = contact_id_item.data(Qt.ItemDataRole.UserRole) + + # If no contact is selected, we can still create an interaction with the application + # but we need to select a contact in the form + dialog = InteractionForm(self, contact_id, self.app_id) + if dialog.exec(): + self.load_interactions() + if self.main_window: + self.main_window.show_status_message("Interaction added successfully") + + @pyqtSlot() + def on_add_contact(self) -> None: + """Open dialog to add a contact to this application.""" + dialog = ContactSelectorDialog(self) + if dialog.exec(): + contact_id = dialog.selected_contact_id + + if not contact_id: + if self.main_window: + self.main_window.show_status_message("No contact selected") + return + + # Associate the contact with this application + try: + service = ContactService() + result = service.add_contact_to_application(self.app_id, contact_id) + + if result: + if self.main_window: + self.main_window.show_status_message("Contact added to application") + self.load_contacts() + else: + if self.main_window: + self.main_window.show_status_message("Failed to add contact to application") + except Exception as e: + logger.error(f"Error adding contact to application: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot() + def on_remove_contact(self) -> None: + """Remove the selected contact from this application.""" + selected_rows = self.contacts_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No contact selected") + return + + contact_id = self.contacts_table.item(selected_rows[0].row(), 0).data(Qt.ItemDataRole.UserRole) + if not contact_id: + return + + # Confirm with user + reply = QMessageBox.question( + self, + "Remove Contact", + "Are you sure you want to remove this contact from the application?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = ContactService() + result = service.remove_contact_from_application(self.app_id, contact_id) + + if result: + if self.main_window: + self.main_window.show_status_message("Contact removed from application") + self.load_contacts() + else: + if self.main_window: + self.main_window.show_status_message("Failed to remove contact from application") + except Exception as e: + logger.error(f"Error removing contact from application: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot() + def on_contact_double_clicked(self, index) -> None: + """Open contact details when double clicked.""" + from src.gui.dialogs.contact_detail import ContactDetailDialog + + if self.contacts_table.item(index.row(), 0).text() == "No contacts found": + return + + contact_id = self.contacts_table.item(index.row(), 0).data(Qt.ItemDataRole.UserRole) + dialog = ContactDetailDialog(self, contact_id) + dialog.exec() + + # Refresh contacts in case there were changes + self.load_contacts() + + @pyqtSlot() + def on_edit_interaction(self) -> None: + """Open dialog to edit the selected interaction.""" + selected_rows = self.interactions_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No interaction selected") + return + + interaction_id = self.interactions_table.item(selected_rows[0].row(), 0).data(Qt.ItemDataRole.UserRole) + if not interaction_id: + return + + dialog = InteractionForm(self, None, self.app_id, interaction_id) + if dialog.exec(): + self.load_interactions() + if self.main_window: + self.main_window.show_status_message("Interaction updated successfully") + + @pyqtSlot() + def on_delete_interaction(self) -> None: + """Delete the selected interaction.""" + selected_rows = self.interactions_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No interaction selected") + return + + interaction_id = self.interactions_table.item(selected_rows[0].row(), 0).data(Qt.ItemDataRole.UserRole) + if not interaction_id: + return + + # Confirm deletion + reply = QMessageBox.question( + self, + "Delete Interaction", + "Are you sure you want to delete this interaction?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + service = InteractionService() + success = service.delete_interaction(interaction_id) + + if success: + self.load_interactions() + if self.main_window: + self.main_window.show_status_message("Interaction deleted successfully") + else: + if self.main_window: + self.main_window.show_status_message("Failed to delete interaction") + + @pyqtSlot() + def on_copy_link(self) -> None: + """Copy application link to clipboard.""" + from PyQt6.QtWidgets import QApplication + + link = self.application_data.get("link", "") + if link: + QApplication.clipboard().setText(link) + if self.main_window: + self.main_window.show_status_message("Link copied to clipboard") + else: + if self.main_window: + self.main_window.show_status_message("No link available") + + @pyqtSlot() + def on_delete_application(self) -> None: + """Delete this application.""" + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete this application?\n\n" + f"Title: {self.application_data['job_title']}\n" + f"Company: {self.application_data.get('company', {}).get('name', 'Unknown')}\n\n" + f"This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = ApplicationService() + success = service.delete(self.app_id) + + if success: + if self.main_window: + self.main_window.show_status_message(f"Application {self.app_id} deleted") + self.accept() + else: + if self.main_window: + self.main_window.show_status_message("Failed to delete application") + except Exception as e: + logger.error(f"Error deleting application: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + + def _on_interaction_selected(self) -> None: + """Handle interaction selection.""" + selected_rows = self.interactions_table.selectedItems() + has_selection = bool(selected_rows) + self.edit_interaction_button.setEnabled(has_selection) + self.delete_interaction_button.setEnabled(has_selection) + + def _on_contact_selected(self) -> None: + """Handle contact selection.""" + selected_rows = self.contacts_table.selectedItems() + has_selection = bool(selected_rows) + self.remove_contact_button.setEnabled(has_selection) diff --git a/src/gui/dialogs/application_form.py b/src/gui/dialogs/application_form.py new file mode 100644 index 0000000..a1b145f --- /dev/null +++ b/src/gui/dialogs/application_form.py @@ -0,0 +1,299 @@ +from PyQt6.QtCore import QDate, Qt +from PyQt6.QtWidgets import ( + QComboBox, + QDateEdit, + QDialog, + QFormLayout, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.config import ApplicationStatus +from src.services.application_service import ApplicationService +from src.services.company_service import CompanyService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ApplicationForm(QDialog): + """Dialog for creating or editing a job application.""" + + def __init__(self, parent=None, app_id=None, readonly=False): + super().__init__(parent) + self.app_id = app_id + self.readonly = readonly + self.companies = [] + self.application_data = {} + + title = "View Application" if readonly else "Edit Application" if app_id else "New Application" + self.setWindowTitle(title) + self.resize(700, 600) + + self._init_ui() + + # Load data if editing + if self.app_id: + self.load_application() + + def _init_ui(self): + """Initialize the form UI.""" + layout = QVBoxLayout(self) + + # Create tab widget for form sections + self.tab_widget = QTabWidget() + + # Tab 1: Essential Information + essential_tab = QWidget() + essential_layout = QFormLayout(essential_tab) + + self.job_title_input = QLineEdit() + self.job_title_input.setReadOnly(self.readonly) + essential_layout.addRow("Job Title*:", self.job_title_input) + + # Company selection with new company button + company_layout = QHBoxLayout() + self.company_select = QComboBox() + self.company_select.setEnabled(not self.readonly) + company_layout.addWidget(self.company_select) + + if not self.readonly: + self.new_company_btn = QPushButton("+ New Company") + self.new_company_btn.clicked.connect(self.on_new_company) + company_layout.addWidget(self.new_company_btn) + + essential_layout.addRow("Company*:", company_layout) + + self.position_input = QLineEdit() + self.position_input.setReadOnly(self.readonly) + essential_layout.addRow("Position*:", self.position_input) + + self.status_select = QComboBox() + for status in ApplicationStatus: + self.status_select.addItem(status.value) + self.status_select.setEnabled(not self.readonly) + essential_layout.addRow("Status*:", self.status_select) + + self.applied_date = QDateEdit() + self.applied_date.setCalendarPopup(True) + self.applied_date.setDate(QDate.currentDate()) + self.applied_date.setReadOnly(self.readonly) + essential_layout.addRow("Applied Date*:", self.applied_date) + + # Tab 2: Additional Details + details_tab = QWidget() + details_layout = QFormLayout(details_tab) + + self.location_input = QLineEdit() + self.location_input.setPlaceholderText("City, State or Remote") + self.location_input.setReadOnly(self.readonly) + details_layout.addRow("Location:", self.location_input) + + # Salary range + salary_layout = QHBoxLayout() + self.salary_min_input = QLineEdit() + self.salary_min_input.setPlaceholderText("Min") + self.salary_min_input.setReadOnly(self.readonly) + salary_layout.addWidget(self.salary_min_input) + + self.salary_max_input = QLineEdit() + self.salary_max_input.setPlaceholderText("Max") + self.salary_max_input.setReadOnly(self.readonly) + salary_layout.addWidget(self.salary_max_input) + + details_layout.addRow("Salary Range:", salary_layout) + + self.link_input = QLineEdit() + self.link_input.setPlaceholderText("Job posting URL") + self.link_input.setReadOnly(self.readonly) + details_layout.addRow("Job Link:", self.link_input) + + self.description_input = QTextEdit() + self.description_input.setPlaceholderText("Job description") + self.description_input.setReadOnly(self.readonly) + details_layout.addRow("Description:", self.description_input) + + self.notes_input = QTextEdit() + self.notes_input.setPlaceholderText("Additional notes") + self.notes_input.setReadOnly(self.readonly) + details_layout.addRow("Notes:", self.notes_input) + + # Add tabs to widget + self.tab_widget.addTab(essential_tab, "Essential Info") + self.tab_widget.addTab(details_tab, "Additional Details") + + layout.addWidget(self.tab_widget) + + # Bottom buttons + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + self.save_btn = QPushButton("Save") + self.save_btn.clicked.connect(self.save_application) + btn_layout.addWidget(self.save_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.cancel_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + # Load companies + self.load_companies() + + def load_companies(self): + """Load companies for the dropdown.""" + try: + service = CompanyService() + self.companies = service.get_all() + + self.company_select.clear() + for company in self.companies: + self.company_select.addItem(company["name"], company["id"]) + + except Exception as e: + logger.error(f"Error loading companies: {e}", exc_info=True) + if self.parent(): + self.parent().main_window.show_status_message(f"Error loading companies: {str(e)}") + + def load_application(self): + """Load application data for editing.""" + try: + service = ApplicationService() + app_data = service.get(int(self.app_id)) + + if not app_data: + if self.parent(): + self.parent().main_window.show_status_message(f"Application {self.app_id} not found") + return + + # Store application data + self.application_data = app_data + + # Populate form fields + self.job_title_input.setText(app_data["job_title"]) + self.position_input.setText(app_data["position"]) + + # Set status + index = self.status_select.findText(app_data["status"]) + if index >= 0: + self.status_select.setCurrentIndex(index) + + # Set date + date_str = app_data["applied_date"].split("T")[0] + parts = date_str.split("-") + if len(parts) == 3: + self.applied_date.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2]))) + + # Set company + if app_data.get("company") and "id" in app_data["company"]: + company_id = app_data["company"]["id"] + index = self.company_select.findData(company_id) + if index >= 0: + self.company_select.setCurrentIndex(index) + + # Set optional fields + if app_data.get("location"): + self.location_input.setText(app_data["location"]) + + if app_data.get("salary_min"): + self.salary_min_input.setText(str(app_data["salary_min"])) + if app_data.get("salary_max"): + self.salary_max_input.setText(str(app_data["salary_max"])) + + if app_data.get("link"): + self.link_input.setText(app_data["link"]) + + if app_data.get("description"): + self.description_input.setPlainText(app_data["description"]) + + if app_data.get("notes"): + self.notes_input.setPlainText(app_data["notes"]) + + except Exception as e: + logger.error(f"Error loading application: {e}", exc_info=True) + if self.parent(): + self.parent().main_window.show_status_message(f"Error: {str(e)}") + + def save_application(self): + """Save the application data.""" + try: + # Collect data from form + job_title = self.job_title_input.text().strip() + position = self.position_input.text().strip() + status = self.status_select.currentText() + applied_date = self.applied_date.date().toString(Qt.DateFormat.ISODate) + company_id = self.company_select.currentData() + + # Validate required fields + if not job_title: + QMessageBox.warning(self, "Validation Error", "Job title is required") + self.tab_widget.setCurrentIndex(0) + self.job_title_input.setFocus() + return + + if not position: + QMessageBox.warning(self, "Validation Error", "Position is required") + self.tab_widget.setCurrentIndex(0) + self.position_input.setFocus() + return + + if not company_id: + QMessageBox.warning(self, "Validation Error", "Company is required") + self.tab_widget.setCurrentIndex(0) + self.company_select.setFocus() + return + + # Prepare data + app_data = { + "job_title": job_title, + "position": position, + "status": status, + "applied_date": applied_date, + "company_id": company_id, + "location": self.location_input.text().strip() or None, + "salary_min": int(self.salary_min_input.text()) if self.salary_min_input.text().strip() else None, + "salary_max": int(self.salary_max_input.text()) if self.salary_max_input.text().strip() else None, + "link": self.link_input.text().strip() or None, + "description": self.description_input.toPlainText().strip() or None, + "notes": self.notes_input.toPlainText().strip() or None, + } + + service = ApplicationService() + + if self.app_id: + # Update existing application + service.update(int(self.app_id), app_data) + message = "Application updated successfully" + else: + # Create new application + result = service.create(app_data) + self.app_id = result["id"] + message = "Application created successfully" + + if self.parent(): + self.parent().main_window.show_status_message(message) + + self.accept() + + except ValueError as e: + logger.error(f"Validation error: {e}", exc_info=True) + QMessageBox.warning(self, "Validation Error", "Please enter valid numbers for salary range") + except Exception as e: + logger.error(f"Error saving application: {e}", exc_info=True) + QMessageBox.critical(self, "Error", f"Error saving application: {str(e)}") + + def on_new_company(self): + """Open dialog to create a new company.""" + from src.gui.dialogs.company_form import CompanyForm + + dialog = CompanyForm(self) + if dialog.exec(): + self.load_companies() diff --git a/src/gui/dialogs/application_selector.py b/src/gui/dialogs/application_selector.py new file mode 100644 index 0000000..fbf590c --- /dev/null +++ b/src/gui/dialogs/application_selector.py @@ -0,0 +1,152 @@ +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import ( + QDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +from src.services.application_service import ApplicationService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ApplicationSelectorDialog(QDialog): + """Dialog for selecting an application to associate with a contact or other entity.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.selected_application_id = None + + self.setWindowTitle("Select Application") + self.resize(600, 400) + + self._init_ui() + self.load_applications() + + def _init_ui(self): + """Initialize the dialog UI.""" + layout = QVBoxLayout(self) + + # Search bar + search_layout = QHBoxLayout() + search_layout.addWidget(QLabel("Search:")) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter job title, company name, etc.") + self.search_input.textChanged.connect(self.on_search) + search_layout.addWidget(self.search_input) + layout.addLayout(search_layout) + + # Applications table + layout.addWidget(QLabel("Select an application:")) + self.applications_table = QTableWidget(0, 5) + self.applications_table.setHorizontalHeaderLabels(["ID", "Job Title", "Company", "Status", "Applied Date"]) + self.applications_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.applications_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.applications_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.applications_table.doubleClicked.connect(self.on_table_double_clicked) + layout.addWidget(self.applications_table) + + # Bottom buttons + button_layout = QHBoxLayout() + self.select_button = QPushButton("Select") + self.select_button.clicked.connect(self.on_select) + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(self.select_button) + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + self.setLayout(layout) + + def load_applications(self, search_term=None): + """Load applications with optional search filtering.""" + try: + self.applications_table.setRowCount(0) + + # Get applications from service + service = ApplicationService() + applications = service.search(search_term) if search_term else service.get_all() + + if not applications: + self.applications_table.insertRow(0) + self.applications_table.setItem(0, 0, QTableWidgetItem("No applications found")) + self.applications_table.setItem(0, 1, QTableWidgetItem("")) + self.applications_table.setItem(0, 2, QTableWidgetItem("")) + self.applications_table.setItem(0, 3, QTableWidgetItem("")) + self.applications_table.setItem(0, 4, QTableWidgetItem("")) + return + + # Populate table + for i, app in enumerate(applications): + self.applications_table.insertRow(i) + + # Store the application ID for later retrieval + id_item = QTableWidgetItem(str(app.get("id", ""))) + id_item.setData(Qt.ItemDataRole.UserRole, app.get("id")) + self.applications_table.setItem(i, 0, id_item) + + self.applications_table.setItem(i, 1, QTableWidgetItem(app.get("job_title", ""))) + + # Get company name if available + company_name = "" + if app.get("company"): + company_name = app["company"].get("name", "") + self.applications_table.setItem(i, 2, QTableWidgetItem(company_name)) + + self.applications_table.setItem(i, 3, QTableWidgetItem(app.get("status", ""))) + + # Format date + date_str = "" + if app.get("applied_date"): + date_str = app["applied_date"].split("T")[0] + self.applications_table.setItem(i, 4, QTableWidgetItem(date_str)) + + except Exception as e: + logger.error(f"Error loading applications: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading applications: {str(e)}") + + @pyqtSlot(str) + def on_search(self, text): + """Handle search input changes.""" + if not text: + self.load_applications() + else: + self.load_applications(text) + + @pyqtSlot() + def on_select(self): + """Handle select button click.""" + selected_rows = self.applications_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No application selected") + return + + # Get the application ID from the first column + app_id_item = self.applications_table.item(selected_rows[0].row(), 0) + if not app_id_item or app_id_item.text() == "No applications found": + return + + self.selected_application_id = app_id_item.data(Qt.ItemDataRole.UserRole) + self.accept() + + @pyqtSlot() + def on_table_double_clicked(self, index): + """Handle double-click on table row.""" + if self.applications_table.item(index.row(), 0).text() == "No applications found": + return + + app_id_item = self.applications_table.item(index.row(), 0) + self.selected_application_id = app_id_item.data(Qt.ItemDataRole.UserRole) + self.accept() diff --git a/src/gui/dialogs/base_form.py b/src/gui/dialogs/base_form.py new file mode 100644 index 0000000..1b52ba1 --- /dev/null +++ b/src/gui/dialogs/base_form.py @@ -0,0 +1,67 @@ +from PyQt6.QtWidgets import QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout + +from src.gui.components.styled_button import StyledButton +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class BaseFormDialog(QDialog): + """Base class for all form dialogs to ensure consistency.""" + + def __init__(self, parent=None, entity_id=None, readonly=False): + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.entity_id = entity_id + self.readonly = readonly + self.data = {} + + mode = "View" if readonly else "Edit" if entity_id else "New" + self.entity_type = "Item" # Override in subclass + self.setWindowTitle(f"{mode} {self.entity_type}") + + self._init_ui() + + if self.entity_id: + self.load_data() + + def _init_ui(self): + """Initialize the form UI with consistent layout.""" + self.layout = QVBoxLayout(self) + + # Form layout for fields + self.form_layout = QFormLayout() + self.layout.addLayout(self.form_layout) + + # Required fields note if not readonly + if not self.readonly: + self.layout.addWidget(QLabel("* Required fields")) + + # Consistent button layout + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + if not self.readonly: + self.save_btn = StyledButton("Save", "primary") + self.save_btn.clicked.connect(self.save_data) + btn_layout.addWidget(self.save_btn) + + self.close_btn = StyledButton("Close", "secondary") + self.close_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.close_btn) + + self.layout.addLayout(btn_layout) + + def add_form_field(self, label, widget, required=False): + """Add a field to the form with consistent styling.""" + if required and not self.readonly: + label = f"{label}*" + self.form_layout.addRow(label, widget) + + def load_data(self): + """Load entity data - override in subclass.""" + pass + + def save_data(self): + """Save entity data - override in subclass.""" + pass diff --git a/src/gui/dialogs/company_detail.py b/src/gui/dialogs/company_detail.py new file mode 100644 index 0000000..ee15f7a --- /dev/null +++ b/src/gui/dialogs/company_detail.py @@ -0,0 +1,645 @@ +import json +import os +from datetime import datetime + +import matplotlib.pyplot as plt +import networkx as nx +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QDialog, + QFileDialog, + QFormLayout, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.gui.components.data_table import DataTable +from src.gui.dialogs.application_detail import ApplicationDetailDialog +from src.gui.dialogs.company_relationship_form import CompanyRelationshipForm +from src.services.application_service import ApplicationService +from src.services.company_service import CompanyService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class CompanyDetailDialog(QDialog): + """Dialog for viewing company details.""" + + def __init__(self, parent=None, company_id=None) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.company_id = company_id + self.company_data = None + + self.setWindowTitle("Company Details") + self.resize(700, 500) + + self._init_ui() + + # Load data + if self.company_id: + self.load_company_data() + + def _init_ui(self) -> None: + """Initialize the dialog UI.""" + layout = QVBoxLayout(self) + + # Header section with company info and buttons + header_layout = QHBoxLayout() + + # Company identity section + self.identity_layout = QVBoxLayout() + self.company_name = QLabel("") + name_font = QFont() + name_font.setPointSize(14) + name_font.setBold(True) + self.company_name.setFont(name_font) + + self.company_type = QLabel("") + self.identity_layout.addWidget(self.company_name) + self.identity_layout.addWidget(self.company_type) + + # Header actions + actions_layout = QVBoxLayout() + self.edit_button = QPushButton("Edit Company") + self.edit_button.clicked.connect(self.on_edit_company) + + self.add_relationship_button = QPushButton("Add Relationship") + self.add_relationship_button.clicked.connect(self.on_add_relationship) + + self.export_button = QPushButton("Export Data") + self.export_button.clicked.connect(self.export_company_data) + + actions_layout.addWidget(self.edit_button) + actions_layout.addWidget(self.add_relationship_button) + actions_layout.addWidget(self.export_button) + + header_layout.addLayout(self.identity_layout, 2) + header_layout.addLayout(actions_layout, 1) + layout.addLayout(header_layout) + + # Tab widget for different sections + self.tabs = QTabWidget() + + # Tab 1: Overview + overview_tab = QWidget() + overview_layout = QVBoxLayout(overview_tab) + + # Company details + info_form = QFormLayout() + self.industry_label = QLabel("") + info_form.addRow("Industry:", self.industry_label) + + self.website_label = QLabel("") + info_form.addRow("Website:", self.website_label) + + self.size_label = QLabel("") + info_form.addRow("Size:", self.size_label) + + overview_layout.addLayout(info_form) + + # Notes section + overview_layout.addWidget(QLabel("Notes:")) + self.notes_display = QTextEdit() + self.notes_display.setReadOnly(True) + overview_layout.addWidget(self.notes_display) + + # Tab 2: Relationships + relationships_tab = QWidget() + relationships_layout = QVBoxLayout(relationships_tab) + + relationships_layout.addWidget(QLabel("Company Relationships")) + + # Relationships table + self.relationships_table = DataTable(0, ["Company", "Type", "Relationship", "Direction", "Actions"]) + self.relationships_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.relationships_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + relationships_layout.addWidget(self.relationships_table) + + # Collapsible visualization section + self.visualization_container = QWidget() + visualization_layout = QVBoxLayout(self.visualization_container) + visualization_layout.setContentsMargins(0, 0, 0, 0) + + # Header with toggle button + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + + viz_label = QLabel("Relationship Visualization:") + viz_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + header_layout.addWidget(viz_label) + + self.toggle_viz_button = QPushButton("Show") + self.toggle_viz_button.setMaximumWidth(100) + self.toggle_viz_button.clicked.connect(self.toggle_visualization) + header_layout.addWidget(self.toggle_viz_button) + + visualization_layout.addLayout(header_layout) + + # Separator line + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + visualization_layout.addWidget(line) + + # Visualization content + self.viz_content = QWidget() + self.viz_content.setVisible(False) # Initially hidden + viz_content_layout = QVBoxLayout(self.viz_content) + viz_content_layout.setContentsMargins(0, 10, 0, 0) + + # Create figure and canvas for the network graph + self.figure = plt.figure(figsize=(6, 5)) + self.canvas = FigureCanvas(self.figure) + self.canvas.setMinimumHeight(300) + viz_content_layout.addWidget(self.canvas) + + visualization_layout.addWidget(self.viz_content) + relationships_layout.addWidget(self.visualization_container) + + # Tab 3: Applications + applications_tab = QWidget() + applications_layout = QVBoxLayout(applications_tab) + + applications_layout.addWidget(QLabel("Job Applications with this Company")) + + # Applications table + self.applications_table = DataTable(0, ["ID", "Job Title", "Position", "Status", "Applied Date"]) + self.applications_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.applications_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.applications_table.doubleClicked.connect(self.on_application_double_clicked) + applications_layout.addWidget(self.applications_table) + + # Add tabs to widget + self.tabs.addTab(overview_tab, "Overview") + self.tabs.addTab(relationships_tab, "Relationships") + self.tabs.addTab(applications_tab, "Applications") + + layout.addWidget(self.tabs) + + # Bottom buttons + btn_layout = QHBoxLayout() + self.close_button = QPushButton("Close") + self.close_button.clicked.connect(self.accept) + btn_layout.addStretch() + btn_layout.addWidget(self.close_button) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def toggle_visualization(self): + """Toggle the visibility of the visualization panel.""" + is_visible = self.viz_content.isVisible() + self.viz_content.setVisible(not is_visible) + + # Update button text + if is_visible: + self.toggle_viz_button.setText("Show") + # Restore original size + self.resize(700, 500) + else: + self.toggle_viz_button.setText("Hide") + # Expand dialog size when showing visualization + self.resize(800, 700) + + # Make sure visualization is up to date + if hasattr(self, "last_relationships") and self.last_relationships: + self._generate_network_visualization(self.last_relationships) + + def load_company_data(self) -> None: + """Load all company data and populate the UI.""" + try: + # Load company details + service = CompanyService() + self.company_data = service.get(self.company_id) + + if not self.company_data: + if self.main_window: + self.main_window.show_status_message(f"Company {self.company_id} not found") + return + + # Update header + self.company_name.setText(self.company_data.get("name", "Unknown")) + + # Make sure company type is a string + company_type = self.company_data.get("type", "") + if company_type is None: + company_type = "DIRECT_EMPLOYER" + self.company_type.setText(f"Type: {company_type}") + + # Update overview fields - ensure we always use strings for display + industry = self.company_data.get("industry", "") + if industry is None or industry == "": + industry = "Not specified" + self.industry_label.setText(industry) + + website = self.company_data.get("website", "") + if website is None or website == "": + website = "No website provided" + self.website_label.setText(website) + + size = self.company_data.get("size", "") + if size is None or size == "": + size = "Not specified" + self.size_label.setText(size) + + # Update notes + notes = self.company_data.get("notes", "") + if notes is None or notes == "": + notes = "No notes available." + self.notes_display.setText(notes) + + # Load relationships + self.load_relationships() + + # Load applications + self.load_applications() + + # Load relationships network visualization + self.load_relationships_network() + + if self.main_window: + self.main_window.show_status_message(f"Viewing company: {self.company_data.get('name', 'Unknown')}") + + except Exception as e: + logger.error(f"Error loading company data: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading company data: {str(e)}") + + def load_relationships(self) -> None: + """Load company relationships.""" + try: + service = CompanyService() + relationships = service.get_related_companies(self.company_id) + self.last_relationships = relationships # Store for visualization refresh + + self.relationships_table.setRowCount(0) + + if not relationships: + self.relationships_table.insertRow(0) + self.relationships_table.setItem(0, 0, QTableWidgetItem("No relationships found")) + self.relationships_table.setItem(0, 1, QTableWidgetItem("")) + self.relationships_table.setItem(0, 2, QTableWidgetItem("")) + self.relationships_table.setItem(0, 3, QTableWidgetItem("")) + self.relationships_table.setItem(0, 4, QTableWidgetItem("")) + + # Clear the visualization + self.figure.clear() + self.canvas.draw() + return + + for i, rel in enumerate(relationships): + self.relationships_table.insertRow(i) + + direction = "→" if rel["direction"] == "outgoing" else "←" + + self.relationships_table.setItem(i, 0, QTableWidgetItem(rel["company_name"])) + self.relationships_table.setItem(i, 1, QTableWidgetItem(rel.get("company_type", ""))) + self.relationships_table.setItem(i, 2, QTableWidgetItem(rel["relationship_type"])) + self.relationships_table.setItem(i, 3, QTableWidgetItem(direction)) + + # Create action buttons cell + actions_widget = QWidget() + actions_layout = QHBoxLayout(actions_widget) + actions_layout.setContentsMargins(2, 2, 2, 2) + actions_layout.setSpacing(4) + + # Edit button + edit_btn = QPushButton("Edit") + edit_btn.setMaximumWidth(60) + edit_btn.clicked.connect( + lambda checked=False, row=i, rel_id=rel.get("relationship_id"): self.on_edit_relationship(rel_id) + ) + actions_layout.addWidget(edit_btn) + + # Delete button + delete_btn = QPushButton("Delete") + delete_btn.setMaximumWidth(60) + delete_btn.clicked.connect( + lambda checked=False, row=i, rel_id=rel.get("relationship_id"): self.on_delete_relationship(rel_id) + ) + actions_layout.addWidget(delete_btn) + + actions_layout.addStretch() + actions_widget.setLayout(actions_layout) + + # Set the custom widget in the table cell + self.relationships_table.setCellWidget(i, 4, actions_widget) + + # Generate network visualization if panel is visible + if self.viz_content.isVisible(): + self._generate_network_visualization(relationships) + + except Exception as e: + logger.error(f"Error loading relationships: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading relationships: {str(e)}") + + def _generate_network_visualization(self, relationships): + """Generate a network graph visualization of company relationships.""" + try: + # Clear previous figure + self.figure.clear() + + # Create directed graph + G = nx.DiGraph() + + # Add focal company node + company_name = self.company_data.get("name", f"Company {self.company_id}") + G.add_node(company_name) + + # Add edges for relationships + # edges = [] + edge_labels = {} + + for rel in relationships: + other_company = rel["company_name"] + rel_type = rel["relationship_type"] + direction = rel["direction"] + + # Add the other company node + G.add_node(other_company) + + # Add directed edge based on relationship direction + if direction == "outgoing": + G.add_edge(company_name, other_company) + edge_labels[(company_name, other_company)] = rel_type + else: + G.add_edge(other_company, company_name) + edge_labels[(other_company, company_name)] = rel_type + + # Create the plot + ax = self.figure.add_subplot(111) + + # Generate positions for the nodes + pos = nx.spring_layout(G) + + # Draw the graph + nx.draw_networkx_nodes(G, pos, node_size=700, node_color="skyblue", ax=ax) + nx.draw_networkx_edges( + G, pos, width=2, edge_color="gray", ax=ax, arrowsize=20, connectionstyle="arc3,rad=0.1" + ) + nx.draw_networkx_labels(G, pos, font_size=10, font_weight="bold", ax=ax) + nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=8, ax=ax) + + # Remove axis + ax.axis("off") + + # Update the canvas + self.canvas.draw() + + except Exception as e: + logger.error(f"Error generating network visualization: {e}", exc_info=True) + # Clear the figure in case of error + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.text(0.5, 0.5, "Visualization error", ha="center", va="center") + ax.axis("off") + self.canvas.draw() + + def load_relationships_network(self): + """Load and visualize company relationships as a network graph.""" + try: + if not self.company_id: + return + + # Clear the previous graph + if hasattr(self, "relationship_canvas"): + self.relationship_figure.clear() + self.relationship_canvas.draw() + + # Get company relationships + service = CompanyService() + companies, relationships = service.get_company_network(self.company_id) + + if not companies or not relationships: + return + + # Create a directed graph + G = nx.DiGraph() + + # Add nodes for all companies + for company in companies: + G.add_node(company["id"], name=company["name"]) + + # Add edges for relationships + for rel in relationships: + source = rel["company_id"] + target = rel["related_company_id"] + rel_type = rel["relationship_type"] + G.add_edge(source, target, rel_type=rel_type) + + # Create the plot + self.relationship_figure = plt.figure(figsize=(6, 4)) + ax = self.relationship_figure.add_subplot(111) + + # Position nodes using force-directed layout + pos = nx.spring_layout(G) + + # Draw network + nx.draw_networkx_nodes( + G, pos, node_size=500, node_color=["red" if n == self.company_id else "skyblue" for n in G.nodes()] + ) + nx.draw_networkx_edges(G, pos, arrowsize=15, edge_color="gray") + + # Draw node labels (company names) + labels = {node: G.nodes[node]["name"] for node in G.nodes()} + nx.draw_networkx_labels(G, pos, labels=labels, font_size=8) + + # Draw edge labels (relationship types) + edge_labels = {(u, v): G.edges[u, v]["rel_type"] for u, v in G.edges()} + nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=7) + + # Set axis properties + ax.set_title(f"Company Relationships Network for {self.company_data['name']}") + ax.set_axis_off() + + # Update canvas with the new figure + if not hasattr(self, "relationship_canvas"): + # Create canvas for the first time + self.relationship_canvas = FigureCanvas(self.relationship_figure) + self.relationship_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.network_layout.addWidget(self.relationship_canvas) + else: + # Update existing canvas + self.relationship_canvas.figure = self.relationship_figure + self.relationship_canvas.draw() + + except Exception as e: + logger.error(f"Error visualizing relationships: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error visualizing relationships: {str(e)}") + + def load_applications(self) -> None: + """Load job applications for this company.""" + try: + app_service = ApplicationService() + applications = app_service.get_applications_by_company(self.company_id) + + self.applications_table.setRowCount(0) + + if not applications: + self.applications_table.insertRow(0) + self.applications_table.setItem(0, 0, QTableWidgetItem("No applications found")) + self.applications_table.setItem(0, 1, QTableWidgetItem("")) + self.applications_table.setItem(0, 2, QTableWidgetItem("")) + self.applications_table.setItem(0, 3, QTableWidgetItem("")) + self.applications_table.setItem(0, 4, QTableWidgetItem("")) + return + + for i, app in enumerate(applications): + self.applications_table.insertRow(i) + + self.applications_table.setItem(i, 0, QTableWidgetItem(str(app["id"]))) + self.applications_table.setItem(i, 1, QTableWidgetItem(app["job_title"])) + self.applications_table.setItem(i, 2, QTableWidgetItem(app["position"])) + self.applications_table.setItem(i, 3, QTableWidgetItem(app["status"])) + + # Format date + date_str = app["applied_date"].split("T")[0] + self.applications_table.setItem(i, 4, QTableWidgetItem(date_str)) + + except Exception as e: + logger.error(f"Error loading applications: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading applications: {str(e)}") + + @pyqtSlot() + def on_edit_company(self) -> None: + """Open dialog to edit the company.""" + from src.gui.dialogs.company_form import CompanyForm + + dialog = CompanyForm(self, self.company_id) + if dialog.exec(): + self.load_company_data() + + @pyqtSlot() + def on_add_relationship(self) -> None: + """Open dialog to add a company relationship.""" + dialog = CompanyRelationshipForm(self, self.company_id) + if dialog.exec(): + self.load_relationships() + + @pyqtSlot() + def on_application_double_clicked(self, index) -> None: + """Open application details when double clicked.""" + if self.applications_table.item(index.row(), 0).text() == "No applications found": + return + + app_id = int(self.applications_table.item(index.row(), 0).text()) + dialog = ApplicationDetailDialog(self, app_id) + dialog.exec() + # Refresh applications in case there were changes + self.load_applications() + + @pyqtSlot(int) + def on_edit_relationship(self, relationship_id): + """Open dialog to edit a company relationship.""" + if not relationship_id: + if self.main_window: + self.main_window.show_status_message("Cannot edit relationship: Invalid ID") + return + + dialog = CompanyRelationshipForm(self, self.company_id, relationship_id) + if dialog.exec(): + self.load_relationships() + if self.main_window: + self.main_window.show_status_message("Relationship updated successfully") + + @pyqtSlot(int) + def on_delete_relationship(self, relationship_id): + """Delete a company relationship.""" + if not relationship_id: + if self.main_window: + self.main_window.show_status_message("Cannot delete relationship: Invalid ID") + return + + reply = QMessageBox.question( + self, + "Delete Relationship", + "Are you sure you want to delete this relationship?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = CompanyService() + success = service.delete_relationship(relationship_id) + + if success: + if self.main_window: + self.main_window.show_status_message("Relationship deleted successfully") + self.load_relationships() + else: + if self.main_window: + self.main_window.show_status_message("Failed to delete relationship") + except Exception as e: + logger.error(f"Error deleting relationship: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error deleting relationship: {str(e)}") + + @pyqtSlot(QTableWidgetItem) + def on_relationship_item_clicked(self, item): + """Handle clicks on relationship table items.""" + row = item.row() + col = item.column() + + # Handle action cell clicks (if we're using text actions instead of button widgets) + if col == 4 and self.relationships_table.item(row, 0).text() != "No relationships found": + text = item.text() + relationship_id = self.relationships_table.item(row, 0).data(Qt.ItemDataRole.UserRole) + + if "Edit" in text: + self.on_edit_relationship(relationship_id) + elif "Delete" in text: + self.on_delete_relationship(relationship_id) + + def export_company_data(self): + """Export company data to a file.""" + if not self.company_data: + if self.main_window: + self.main_window.show_status_message("No company data to export") + return + + try: + # Get export location + file_path, _ = QFileDialog.getSaveFileName( + self, "Export Company Data", os.path.expanduser("~/Desktop"), "JSON Files (*.json)" + ) + + if not file_path: + return # User canceled + + # Prepare data for export + export_data = { + "company": self.company_data, + "relationships": self.last_relationships if hasattr(self, "last_relationships") else [], + "exported_at": str(datetime.now().isoformat()), + } + + # Write to file + with open(file_path, "w") as f: + json.dump(export_data, f, indent=2) + + if self.main_window: + self.main_window.show_status_message(f"Company data exported to {file_path}") + + except Exception as e: + logger.error(f"Error exporting company data: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error exporting company data: {str(e)}") diff --git a/src/gui/dialogs/company_form.py b/src/gui/dialogs/company_form.py new file mode 100644 index 0000000..bb6e610 --- /dev/null +++ b/src/gui/dialogs/company_form.py @@ -0,0 +1,183 @@ +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from src.config import CompanyType +from src.services.company_service import CompanyService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class CompanyForm(QDialog): + """Dialog for creating or editing a company.""" + + def __init__(self, parent=None, company_id=None, readonly=False) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.company_id = company_id + self.readonly = readonly + + title = "View Company" if readonly else "Edit Company" if company_id else "New Company" + self.setWindowTitle(title) + self.resize(500, 400) + + self._init_ui() + + # Load data if editing + if self.company_id: + self.load_company() + + def _init_ui(self) -> None: + """Initialize the form UI.""" + layout = QVBoxLayout(self) + + # Form layout for company fields + form_layout = QFormLayout() + + # Company name + self.name_input = QLineEdit() + self.name_input.setReadOnly(self.readonly) + form_layout.addRow("Company Name*:", self.name_input) + + # Website + self.website_input = QLineEdit() + self.website_input.setReadOnly(self.readonly) + form_layout.addRow("Website:", self.website_input) + + # Company type + self.type_select = QComboBox() + for company_type in CompanyType: + self.type_select.addItem(company_type.value) + self.type_select.setEnabled(not self.readonly) + form_layout.addRow("Company Type:", self.type_select) + + # Industry + self.industry_input = QLineEdit() + self.industry_input.setReadOnly(self.readonly) + form_layout.addRow("Industry:", self.industry_input) + + # Size + self.size_input = QLineEdit() + self.size_input.setPlaceholderText("e.g. 1-50, 51-200, 201-500, etc.") + self.size_input.setReadOnly(self.readonly) + form_layout.addRow("Size:", self.size_input) + + # Notes + self.notes_input = QTextEdit() + self.notes_input.setReadOnly(self.readonly) + form_layout.addRow("Notes:", self.notes_input) + + layout.addLayout(form_layout) + + # Required fields note + layout.addWidget(QLabel("* Required fields")) + + # Button row + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + if not self.readonly: + self.save_btn = QPushButton("Save") + self.save_btn.clicked.connect(self.save_company) + btn_layout.addWidget(self.save_btn) + + self.close_btn = QPushButton("Close") + self.close_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.close_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def load_company(self) -> None: + """Load company data for editing.""" + try: + service = CompanyService() + company_data = service.get(int(self.company_id)) + + if not company_data: + if self.main_window: + self.main_window.show_status_message(f"Company {self.company_id} not found") + return + + # Populate form fields + self.name_input.setText(company_data["name"]) + + if company_data.get("website"): + self.website_input.setText(company_data["website"]) + + if company_data.get("industry"): + self.industry_input.setText(company_data["industry"]) + + if company_data.get("size"): + self.size_input.setText(company_data["size"]) + + if company_data.get("type"): + index = self.type_select.findText(company_data["type"]) + if index >= 0: + self.type_select.setCurrentIndex(index) + + if company_data.get("notes"): + self.notes_input.setPlainText(company_data["notes"]) + + except Exception as e: + logger.error(f"Error loading company: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + + def save_company(self) -> None: + """Save the company data.""" + try: + # Collect data from form + name = self.name_input.text().strip() + website = self.website_input.text().strip() + company_type = self.type_select.currentText() + industry = self.industry_input.text().strip() + size = self.size_input.text().strip() + notes = self.notes_input.toPlainText().strip() + + # Validate required fields + if not name: + QMessageBox.warning(self, "Validation Error", "Company name is required") + self.name_input.setFocus() + return + + # Prepare data + company_data = { + "name": name, + "website": website or None, + "industry": industry or None, + "size": size or None, + "type": company_type, + "notes": notes or None, + } + + service = CompanyService() + + if self.company_id: + # Update existing company + service.update(int(self.company_id), company_data) + message = "Company updated successfully" + else: + # Create new company + result = service.create(company_data) + self.company_id = result["id"] + message = "Company created successfully" + + if self.main_window: + self.main_window.show_status_message(message) + + self.accept() + + except Exception as e: + logger.error(f"Error saving company: {e}", exc_info=True) + QMessageBox.critical(self, "Error", f"Error saving company: {str(e)}") diff --git a/src/gui/dialogs/company_relationship_form.py b/src/gui/dialogs/company_relationship_form.py new file mode 100644 index 0000000..fd82b92 --- /dev/null +++ b/src/gui/dialogs/company_relationship_form.py @@ -0,0 +1,191 @@ +from typing import Any + +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from src.config import COMPANY_RELATIONSHIP_TYPES +from src.services.company_service import CompanyService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class CompanyRelationshipForm(QDialog): + """Dialog for creating or editing company relationships.""" + + def __init__(self, parent=None, source_company_id=None, relationship_id=None) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.source_company_id = source_company_id + self.relationship_id = relationship_id + self.companies: list[dict[str, Any]] = [] + + title = "Edit Relationship" if relationship_id else "Add Company Relationship" + self.setWindowTitle(title) + self.resize(500, 400) + + self._init_ui() + + # Load relationship data if editing + if self.relationship_id: + self.load_relationship() + + def _init_ui(self) -> None: + """Initialize the form UI.""" + layout = QVBoxLayout(self) + + # Form layout for relationship fields + form_layout = QFormLayout() + + # Source company (read-only) + self.source_company_input = QLineEdit() + self.source_company_input.setReadOnly(True) + form_layout.addRow("From Company:", self.source_company_input) + + # Relationship type + self.relationship_type = QComboBox() + for rel_type in COMPANY_RELATIONSHIP_TYPES: + self.relationship_type.addItem(rel_type) + form_layout.addRow("Relationship Type:", self.relationship_type) + + # Target company + self.target_company = QComboBox() + form_layout.addRow("To Company:", self.target_company) + + # Notes + self.notes_input = QTextEdit() + form_layout.addRow("Notes:", self.notes_input) + + layout.addLayout(form_layout) + + # Button row + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + self.save_btn = QPushButton("Save") + self.save_btn.clicked.connect(self.save_relationship) + btn_layout.addWidget(self.save_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.cancel_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + # Load companies + self.load_companies() + + def load_companies(self) -> None: + """Load companies for dropdown.""" + try: + service = CompanyService() + + # Get current company details for display + source_company = service.get(self.source_company_id) + if source_company: + self.source_company_input.setText(source_company["name"]) + + # Get all companies + self.companies = service.get_all() + + # Remove the source company from options + target_companies = [c for c in self.companies if c["id"] != self.source_company_id] + + # Update target company dropdown + self.target_company.clear() + for company in target_companies: + self.target_company.addItem(company["name"], company["id"]) + + except Exception as e: + logger.error(f"Error loading companies: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading companies: {str(e)}") + + def load_relationship(self) -> None: + """Load relationship data if editing.""" + try: + service = CompanyService() + relationship = service.get_relationship(self.relationship_id) + + if not relationship: + if self.main_window: + self.main_window.show_status_message(f"Relationship {self.relationship_id} not found") + return + + # Set relationship type + relationship_type = relationship.get("relationship_type") + if relationship_type: + index = self.relationship_type.findText(relationship_type) + if index >= 0: + self.relationship_type.setCurrentIndex(index) + + # Set target company + target_id = relationship.get("target_id") + if target_id: + index = self.target_company.findData(target_id) + if index >= 0: + self.target_company.setCurrentIndex(index) + + # Set notes + notes = relationship.get("notes") + if notes: + self.notes_input.setPlainText(notes) + + if self.main_window: + self.main_window.show_status_message("Loaded relationship data for editing") + + except Exception as e: + logger.error(f"Error loading relationship: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading relationship: {str(e)}") + + def save_relationship(self) -> None: + """Save the relationship.""" + try: + service = CompanyService() + + relationship_type = self.relationship_type.currentText() + related_company_id = self.target_company.currentData() + notes = self.notes_input.toPlainText().strip() + + # Validate inputs + if not relationship_type: + QMessageBox.warning(self, "Validation Error", "Please select a relationship type") + self.relationship_type.setFocus() + return + + if not related_company_id: + QMessageBox.warning(self, "Validation Error", "Please select a target company") + self.target_company.setFocus() + return + + # Create or update the relationship + if self.relationship_id: + # Update logic would go here when implemented + if self.main_window: + self.main_window.show_status_message("Relationship update not yet implemented") + else: + service.create_relationship( + source_id=self.source_company_id, + target_id=related_company_id, + relationship_type=relationship_type, + notes=notes or None, + ) + if self.main_window: + self.main_window.show_status_message("Relationship created successfully") + + self.accept() + + except Exception as e: + logger.error(f"Error saving relationship: {e}", exc_info=True) + QMessageBox.critical(self, "Error", f"Error saving relationship: {str(e)}") diff --git a/src/gui/dialogs/contact_detail.py b/src/gui/dialogs/contact_detail.py new file mode 100644 index 0000000..69215a9 --- /dev/null +++ b/src/gui/dialogs/contact_detail.py @@ -0,0 +1,442 @@ +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QDialog, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QTableWidgetItem, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from src.gui.components.data_table import DataTable +from src.gui.dialogs.contact_form import ContactForm +from src.gui.dialogs.interaction_form import InteractionForm +from src.services.contact_service import ContactService +from src.services.interaction_service import InteractionService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ContactDetailDialog(QDialog): + """Dialog for viewing contact details.""" + + def __init__(self, parent=None, contact_id=None) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.contact_id = contact_id + self.contact_data = None + + self.setWindowTitle("Contact Details") + self.resize(700, 500) + + self._init_ui() + + # Load data + if self.contact_id: + self.load_contact_data() + + def _init_ui(self) -> None: + """Initialize the dialog UI.""" + layout = QVBoxLayout(self) + + # Header with contact info + header_layout = QHBoxLayout() + + # Contact identity + self.identity_layout = QVBoxLayout() + self.contact_name = QLabel("") + name_font = QFont() + name_font.setPointSize(14) + name_font.setBold(True) + self.contact_name.setFont(name_font) + + self.contact_title = QLabel("") + title_font = QFont() + title_font.setPointSize(12) + self.contact_title.setFont(title_font) + + self.contact_company = QLabel("") + + self.identity_layout.addWidget(self.contact_name) + self.identity_layout.addWidget(self.contact_title) + self.identity_layout.addWidget(self.contact_company) + + # Action buttons + action_layout = QVBoxLayout() + self.edit_button = QPushButton("Edit Contact") + self.edit_button.clicked.connect(self.on_edit_contact) + + self.add_interaction_button = QPushButton("Add Interaction") + self.add_interaction_button.clicked.connect(self.on_add_interaction) + + action_layout.addWidget(self.edit_button) + action_layout.addWidget(self.add_interaction_button) + + header_layout.addLayout(self.identity_layout, 2) + header_layout.addLayout(action_layout, 1) + + layout.addLayout(header_layout) + + # Contact details + details_layout = QFormLayout() + + self.email_label = QLabel("") + self.email_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + details_layout.addRow("Email:", self.email_label) + + self.phone_label = QLabel("") + self.phone_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + details_layout.addRow("Phone:", self.phone_label) + + self.linkedin_label = QLabel("") + self.linkedin_label.setOpenExternalLinks(True) + details_layout.addRow("LinkedIn:", self.linkedin_label) + + layout.addLayout(details_layout) + + # Tab widget + self.tabs = QTabWidget() + + # Tab 1: Notes + notes_tab = QWidget() + notes_layout = QVBoxLayout(notes_tab) + + notes_layout.addWidget(QLabel("Notes:")) + self.notes_display = QTextEdit() + self.notes_display.setReadOnly(True) + notes_layout.addWidget(self.notes_display) + + # Tab 2: Interactions + interactions_tab = QWidget() + interactions_layout = QVBoxLayout(interactions_tab) + + interactions_layout.addWidget(QLabel("Interactions")) + + # Interactions table + self.interactions_table = DataTable(0, ["Date", "Type", "Subject", "Notes"]) + self.interactions_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.interactions_table.setSelectionBehavior(DataTable.SelectionBehavior.SelectRows) + self.interactions_table.doubleClicked.connect(self.on_interaction_double_clicked) + interactions_layout.addWidget(self.interactions_table) + + # Interactions action buttons + interaction_actions = QHBoxLayout() + + self.edit_interaction_button = QPushButton("Edit Interaction") + self.edit_interaction_button.clicked.connect(self.on_edit_interaction) + + self.delete_interaction_button = QPushButton("Delete Interaction") + self.delete_interaction_button.clicked.connect(self.on_delete_interaction) + + interaction_actions.addWidget(self.edit_interaction_button) + interaction_actions.addWidget(self.delete_interaction_button) + interaction_actions.addStretch() + + interactions_layout.addLayout(interaction_actions) + + # Tab 3: Applications + applications_tab = QWidget() + applications_layout = QVBoxLayout(applications_tab) + + applications_layout.addWidget(QLabel("Associated Applications")) + + # Applications table + self.applications_table = DataTable(0, ["Job Title", "Company", "Status", "Applied Date"]) + self.applications_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.applications_table.setSelectionBehavior(DataTable.SelectionBehavior.SelectRows) + self.applications_table.doubleClicked.connect(self.on_application_double_clicked) + applications_layout.addWidget(self.applications_table) + + # Add tabs to widget + self.tabs.addTab(notes_tab, "Notes") + self.tabs.addTab(interactions_tab, "Interactions") + self.tabs.addTab(applications_tab, "Applications") + + layout.addWidget(self.tabs) + + # Bottom buttons + btn_layout = QHBoxLayout() + self.close_button = QPushButton("Close") + self.close_button.clicked.connect(self.accept) + + self.delete_button = QPushButton("Delete Contact") + self.delete_button.clicked.connect(self.on_delete_contact) + + btn_layout.addWidget(self.delete_button) + btn_layout.addStretch() + btn_layout.addWidget(self.close_button) + + layout.addLayout(btn_layout) + + def load_contact_data(self) -> None: + """Load all contact data and populate the UI.""" + try: + # Load contact details + service = ContactService() + self.contact_data = service.get(self.contact_id) + + if not self.contact_data: + if self.main_window: + self.main_window.show_status_message(f"Contact {self.contact_id} not found") + return + + # Update header + self.contact_name.setText(self.contact_data.get("name", "Unknown")) + self.contact_title.setText(self.contact_data.get("title", "")) + + # Get company name if available + company_name = "" + if self.contact_data.get("company"): + company_name = self.contact_data["company"].get("name", "") + self.contact_company.setText(company_name) + + # Update details + self.email_label.setText(self.contact_data.get("email", "")) + self.phone_label.setText(self.contact_data.get("phone", "")) + + # Update LinkedIn with clickable link + linkedin = self.contact_data.get("linkedin", "") + if linkedin: + self.linkedin_label.setText(f"{linkedin}") + else: + self.linkedin_label.setText("No LinkedIn profile provided") + + # Update notes + self.notes_display.setText(self.contact_data.get("notes", "")) + + # Load interactions and applications + self.load_interactions() + self.load_applications() + + if self.main_window: + self.main_window.show_status_message(f"Viewing contact: {self.contact_data.get('name', 'Unknown')}") + + except Exception as e: + logger.error(f"Error loading contact data: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading contact data: {str(e)}") + + def load_interactions(self) -> None: + """Load interactions for this contact.""" + try: + self.interactions_table.setRowCount(0) + + if not self.contact_id: + return + + # Get interactions for this contact + service = InteractionService() + interactions = service.get_interactions_by_contact(self.contact_id) + + if not interactions: + self.interactions_table.insertRow(0) + self.interactions_table.setItem(0, 0, QTableWidgetItem("No interactions found")) + self.interactions_table.setItem(0, 1, QTableWidgetItem("")) + self.interactions_table.setItem(0, 2, QTableWidgetItem("")) + self.interactions_table.setItem(0, 3, QTableWidgetItem("")) + return + + for i, interaction in enumerate(interactions): + self.interactions_table.insertRow(i) + + # Store the interaction ID for later retrieval + date_item = QTableWidgetItem(interaction.get("date", "").split("T")[0]) + date_item.setData(Qt.ItemDataRole.UserRole, interaction.get("id")) + self.interactions_table.setItem(i, 0, date_item) + + self.interactions_table.setItem(i, 1, QTableWidgetItem(interaction.get("interaction_type", ""))) + self.interactions_table.setItem(i, 2, QTableWidgetItem(interaction.get("subject", ""))) + + # Truncate notes if too long + notes = interaction.get("notes", "") + if len(notes) > 50: + notes = notes[:50] + "..." + self.interactions_table.setItem(i, 3, QTableWidgetItem(notes)) + + except Exception as e: + logger.error(f"Error loading interactions: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading interactions: {str(e)}") + + def load_applications(self) -> None: + """Load applications associated with this contact.""" + try: + self.applications_table.setRowCount(0) + + if not self.contact_id: + return + + # Get applications associated with this contact + service = ContactService() + applications = service.get_associated_applications(self.contact_id) + + if not applications: + self.applications_table.insertRow(0) + self.applications_table.setItem(0, 0, QTableWidgetItem("No applications found")) + self.applications_table.setItem(0, 1, QTableWidgetItem("")) + self.applications_table.setItem(0, 2, QTableWidgetItem("")) + self.applications_table.setItem(0, 3, QTableWidgetItem("")) + return + + for i, app in enumerate(applications): + self.applications_table.insertRow(i) + + # Store the application ID for later retrieval + job_title_item = QTableWidgetItem(app.get("job_title", "")) + job_title_item.setData(Qt.ItemDataRole.UserRole, app.get("id")) + self.applications_table.setItem(i, 0, job_title_item) + + # Company name + company_name = "" + if app.get("company"): + company_name = app["company"].get("name", "") + self.applications_table.setItem(i, 1, QTableWidgetItem(company_name)) + + self.applications_table.setItem(i, 2, QTableWidgetItem(app.get("status", ""))) + + # Format date + applied_date = app.get("applied_date", "") + if applied_date: + applied_date = applied_date.split("T")[0] + self.applications_table.setItem(i, 3, QTableWidgetItem(applied_date)) + + except Exception as e: + logger.error(f"Error loading applications: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading applications: {str(e)}") + + @pyqtSlot() + def on_edit_contact(self) -> None: + """Open dialog to edit the contact.""" + dialog = ContactForm(self, self.contact_id) + if dialog.exec(): + self.load_contact_data() + + @pyqtSlot() + def on_add_interaction(self) -> None: + """Open dialog to add a new interaction for this contact.""" + dialog = InteractionForm(self, self.contact_id) + if dialog.exec(): + self.load_interactions() + if self.main_window: + self.main_window.show_status_message("Interaction added successfully") + + @pyqtSlot() + def on_edit_interaction(self) -> None: + """Open dialog to edit the selected interaction.""" + selected_rows = self.interactions_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No interaction selected") + return + + interaction_id = self.interactions_table.item(selected_rows[0].row(), 0).data(Qt.ItemDataRole.UserRole) + if not interaction_id: + return + + dialog = InteractionForm(self, self.contact_id, None, interaction_id) + if dialog.exec(): + self.load_interactions() + if self.main_window: + self.main_window.show_status_message("Interaction updated successfully") + + @pyqtSlot() + def on_interaction_double_clicked(self, index) -> None: + """Handle double-click on an interaction to edit it.""" + if self.interactions_table.item(index.row(), 0).text() == "No interactions found": + return + + interaction_id = self.interactions_table.item(index.row(), 0).data(Qt.ItemDataRole.UserRole) + dialog = InteractionForm(self, self.contact_id, None, interaction_id) + if dialog.exec(): + self.load_interactions() + + @pyqtSlot() + def on_delete_interaction(self) -> None: + """Delete the selected interaction.""" + selected_rows = self.interactions_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No interaction selected") + return + + interaction_id = self.interactions_table.item(selected_rows[0].row(), 0).data(Qt.ItemDataRole.UserRole) + if not interaction_id: + return + + reply = QMessageBox.question( + self, + "Delete Interaction", + "Are you sure you want to delete this interaction?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = InteractionService() + result = service.delete_interaction(interaction_id) + + if result: + if self.main_window: + self.main_window.show_status_message("Interaction deleted successfully") + self.load_interactions() + else: + if self.main_window: + self.main_window.show_status_message("Failed to delete interaction") + except Exception as e: + logger.error(f"Error deleting interaction: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot() + def on_application_double_clicked(self, index) -> None: + """Open application details when double clicked.""" + from src.gui.dialogs.application_detail import ApplicationDetailDialog + + if self.applications_table.item(index.row(), 0).text() == "No applications found": + return + + application_id = self.applications_table.item(index.row(), 0).data(Qt.ItemDataRole.UserRole) + dialog = ApplicationDetailDialog(self, application_id) + dialog.exec() + + # Refresh applications in case there were changes + self.load_applications() + + @pyqtSlot() + def on_delete_contact(self) -> None: + """Delete this contact.""" + reply = QMessageBox.question( + self, + "Delete Contact", + f"Are you sure you want to delete {self.contact_data.get('name', 'this contact')}?\n\n" + f"This will also remove all associated interactions.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = ContactService() + result = service.delete(self.contact_id) + + if result: + if self.main_window: + self.main_window.show_status_message("Contact deleted successfully") + self.accept() + else: + if self.main_window: + self.main_window.show_status_message("Failed to delete contact") + except Exception as e: + logger.error(f"Error deleting contact: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") diff --git a/src/gui/dialogs/contact_form.py b/src/gui/dialogs/contact_form.py new file mode 100644 index 0000000..38d12e4 --- /dev/null +++ b/src/gui/dialogs/contact_form.py @@ -0,0 +1,221 @@ +from typing import Any + +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from src.services.company_service import CompanyService +from src.services.contact_service import ContactService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ContactForm(QDialog): + """Dialog for creating or editing a contact.""" + + def __init__(self, parent=None, contact_id=None, readonly=False) -> None: + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.contact_id = contact_id + self.readonly = readonly + self.companies: list[dict[str, Any]] = [] + + title = "View Contact" if readonly else "Edit Contact" if contact_id else "New Contact" + self.setWindowTitle(title) + self.resize(500, 400) + + self._init_ui() + + # Load data if editing + if self.contact_id: + self.load_contact() + + def _init_ui(self) -> None: + """Initialize the form UI.""" + layout = QVBoxLayout(self) + + # Form layout for contact fields + form_layout = QFormLayout() + + # Contact name + self.name_input = QLineEdit() + self.name_input.setReadOnly(self.readonly) + form_layout.addRow("Name*:", self.name_input) + + # Title + self.title_input = QLineEdit() + self.title_input.setReadOnly(self.readonly) + form_layout.addRow("Title:", self.title_input) + + # Company selection with new company button + company_layout = QHBoxLayout() + self.company_select = QComboBox() + self.company_select.setEnabled(not self.readonly) + company_layout.addWidget(self.company_select) + + if not self.readonly: + self.new_company_btn = QPushButton("+ New") + self.new_company_btn.clicked.connect(self.on_new_company) + company_layout.addWidget(self.new_company_btn) + + form_layout.addRow("Company:", company_layout) + + # Contact information + self.email_input = QLineEdit() + self.email_input.setPlaceholderText("email@example.com") + self.email_input.setReadOnly(self.readonly) + form_layout.addRow("Email:", self.email_input) + + self.phone_input = QLineEdit() + self.phone_input.setPlaceholderText("(123) 456-7890") + self.phone_input.setReadOnly(self.readonly) + form_layout.addRow("Phone:", self.phone_input) + + # Notes + self.notes_input = QTextEdit() + self.notes_input.setReadOnly(self.readonly) + form_layout.addRow("Notes:", self.notes_input) + + layout.addLayout(form_layout) + + # Required fields note + layout.addWidget(QLabel("* Required fields")) + + # Button row + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + if not self.readonly: + self.save_btn = QPushButton("Save") + self.save_btn.clicked.connect(self.save_contact) + btn_layout.addWidget(self.save_btn) + + self.close_btn = QPushButton("Close") + self.close_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.close_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + # Load companies for dropdown + self.load_companies() + + def load_companies(self) -> None: + """Load companies for the dropdown.""" + try: + service = CompanyService() + self.companies = service.get_all() + + self.company_select.clear() + self.company_select.addItem("No Company", "") + for company in self.companies: + self.company_select.addItem(company["name"], company["id"]) + + except Exception as e: + logger.error(f"Error loading companies: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading companies: {str(e)}") + + def load_contact(self) -> None: + """Load contact data for editing.""" + try: + service = ContactService() + contact_data = service.get(int(self.contact_id)) + + if not contact_data: + if self.main_window: + self.main_window.show_status_message(f"Contact {self.contact_id} not found") + return + + # Populate form fields + self.name_input.setText(contact_data["name"]) + + if contact_data.get("title"): + self.title_input.setText(contact_data["title"]) + + if contact_data.get("email"): + self.email_input.setText(contact_data["email"]) + + if contact_data.get("phone"): + self.phone_input.setText(contact_data["phone"]) + + if contact_data.get("notes"): + self.notes_input.setPlainText(contact_data["notes"]) + + # Set company if available + if contact_data.get("company") and contact_data["company"].get("id"): + company_id = contact_data["company"]["id"] + index = self.company_select.findData(company_id) + if index >= 0: + self.company_select.setCurrentIndex(index) + + except Exception as e: + logger.error(f"Error loading contact: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading contact: {str(e)}") + + def save_contact(self) -> None: + """Save the contact data.""" + try: + # Collect data from form + name = self.name_input.text().strip() + title = self.title_input.text().strip() + company_id = self.company_select.currentData() + email = self.email_input.text().strip() + phone = self.phone_input.text().strip() + notes = self.notes_input.toPlainText().strip() + + # Validate required fields + if not name: + QMessageBox.warning(self, "Validation Error", "Contact name is required") + self.name_input.setFocus() + return + + # Prepare data + contact_data = { + "name": name, + "title": title or None, + "company_id": int(company_id) if company_id else None, + "email": email or None, + "phone": phone or None, + "notes": notes or None, + } + + service = ContactService() + + if self.contact_id: + # Update existing contact + service.update(int(self.contact_id), contact_data) + message = "Contact updated successfully" + else: + # Create new contact + result = service.create(contact_data) + self.contact_id = result["id"] + message = "Contact created successfully" + + if self.main_window: + self.main_window.show_status_message(message) + + self.accept() + + except Exception as e: + logger.error(f"Error saving contact: {e}", exc_info=True) + QMessageBox.critical(self, "Error", f"Error saving contact: {str(e)}") + + def on_new_company(self) -> None: + """Open dialog to create a new company.""" + from src.gui.dialogs.company_form import CompanyForm + + dialog = CompanyForm(self) + if dialog.exec(): + self.load_companies() diff --git a/src/gui/dialogs/contact_selector.py b/src/gui/dialogs/contact_selector.py new file mode 100644 index 0000000..ff89e6f --- /dev/null +++ b/src/gui/dialogs/contact_selector.py @@ -0,0 +1,170 @@ +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import ( + QDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +from src.services.contact_service import ContactService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ContactSelectorDialog(QDialog): + """Dialog for selecting a contact to associate with an application or other entity.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.selected_contact_id = None + + self.setWindowTitle("Select Contact") + self.resize(600, 400) + + self._init_ui() + self.load_contacts() + + def _init_ui(self): + """Initialize the dialog UI.""" + layout = QVBoxLayout(self) + + # Search bar + search_layout = QHBoxLayout() + search_layout.addWidget(QLabel("Search:")) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter name, email, title, etc.") + self.search_input.textChanged.connect(self.on_search) + search_layout.addWidget(self.search_input) + layout.addLayout(search_layout) + + # Contacts table + layout.addWidget(QLabel("Select a contact:")) + self.contacts_table = QTableWidget(0, 5) + self.contacts_table.setHorizontalHeaderLabels(["ID", "Name", "Title", "Email", "Company"]) + self.contacts_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.contacts_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.contacts_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.contacts_table.itemDoubleClicked.connect(self.on_table_double_clicked) + layout.addWidget(self.contacts_table) + + # Bottom buttons with "Add New Contact" option + button_layout = QHBoxLayout() + + self.new_contact_button = QPushButton("Add New Contact") + self.new_contact_button.clicked.connect(self.on_add_new_contact) + + self.select_button = QPushButton("Select") + self.select_button.clicked.connect(self.on_select) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(self.new_contact_button) + button_layout.addStretch() + button_layout.addWidget(self.select_button) + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + self.setLayout(layout) + + def load_contacts(self, search_term=None): + """Load contacts with optional search filtering.""" + try: + self.contacts_table.setRowCount(0) + + # Get contacts from service + service = ContactService() + contacts = service.search_contacts(search_term) if search_term else service.get_contacts() + + if not contacts: + self.contacts_table.insertRow(0) + self.contacts_table.setItem(0, 0, QTableWidgetItem("No contacts found")) + self.contacts_table.setItem(0, 1, QTableWidgetItem("")) + self.contacts_table.setItem(0, 2, QTableWidgetItem("")) + self.contacts_table.setItem(0, 3, QTableWidgetItem("")) + self.contacts_table.setItem(0, 4, QTableWidgetItem("")) + return + + # Populate table + for i, contact in enumerate(contacts): + self.contacts_table.insertRow(i) + + # Store the contact ID for later retrieval + id_item = QTableWidgetItem(str(contact.get("id", ""))) + id_item.setData(Qt.ItemDataRole.UserRole, contact.get("id")) + self.contacts_table.setItem(i, 0, id_item) + + self.contacts_table.setItem(i, 1, QTableWidgetItem(contact.get("name", ""))) + self.contacts_table.setItem(i, 2, QTableWidgetItem(contact.get("title", ""))) + self.contacts_table.setItem(i, 3, QTableWidgetItem(contact.get("email", ""))) + + # Get company name if available + company_name = "" + if contact.get("company"): + company_name = contact["company"].get("name", "") + self.contacts_table.setItem(i, 4, QTableWidgetItem(company_name)) + + except Exception as e: + logger.error(f"Error loading contacts: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading contacts: {str(e)}") + + @pyqtSlot(str) + def on_search(self, text): + """Handle search input changes.""" + if not text: + self.load_contacts() + else: + self.load_contacts(text) + + @pyqtSlot() + def on_select(self): + """Handle select button click.""" + selected_rows = self.contacts_table.selectedItems() + if not selected_rows: + if self.main_window: + self.main_window.show_status_message("No contact selected") + return + + # Get the contact ID from the first column + contact_id_item = self.contacts_table.item(selected_rows[0].row(), 0) + if not contact_id_item or contact_id_item.text() == "No contacts found": + return + + self.selected_contact_id = contact_id_item.data(Qt.ItemDataRole.UserRole) + self.accept() + + @pyqtSlot(QTableWidgetItem) + def on_table_double_clicked(self, item: QTableWidgetItem) -> None: + """Handle double click on a contact in the table.""" + if not item: + return + + row = item.row() + contact_id = self.contacts_table.item(row, 0).data(Qt.ItemDataRole.UserRole) + + if contact_id: + self.selected_contact_id = contact_id + self.accept() + + @pyqtSlot() + def on_add_new_contact(self): + """Open dialog to add a new contact.""" + from src.gui.dialogs.contact_form import ContactForm + + dialog = ContactForm(self) + if dialog.exec(): + # Reload contacts with the newly added one + self.load_contacts() + + # If we can get the ID of the newly created contact, select it + if dialog.contact_id: + self.selected_contact_id = dialog.contact_id + self.accept() diff --git a/src/gui/dialogs/interaction_form.py b/src/gui/dialogs/interaction_form.py new file mode 100644 index 0000000..dd54f4b --- /dev/null +++ b/src/gui/dialogs/interaction_form.py @@ -0,0 +1,303 @@ +from datetime import datetime + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import ( + QComboBox, + QDateTimeEdit, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from src.gui.dialogs.application_selector import ApplicationSelectorDialog +from src.gui.dialogs.contact_selector import ContactSelectorDialog +from src.services.application_service import ApplicationService +from src.services.contact_service import ContactService +from src.services.interaction_service import InteractionService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class InteractionForm(QDialog): + """Form for adding and editing interactions with contacts.""" + + INTERACTION_TYPES = [ + "PHONE_CALL", + "EMAIL", + "MEETING", + "INTERVIEW", + "LINKEDIN_MESSAGE", + "COFFEE_CHAT", + "NETWORKING_EVENT", + "OTHER", + ] + + def __init__(self, parent=None, contact_id=None, application_id=None, interaction_id=None): + """ + Initialize the interaction form. + + Args: + parent: Parent widget + contact_id: ID of the contact associated with this interaction (optional) + application_id: ID of the application associated with this interaction (optional) + interaction_id: ID of the interaction to edit (if editing an existing interaction) + """ + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.contact_id = contact_id + self.application_id = application_id + self.interaction_id = interaction_id + self.interaction_data = None + + self.setWindowTitle("Interaction Form") + self.resize(500, 400) + + self._init_ui() + + # Load data if editing + if self.interaction_id: + self.load_interaction_data() + else: + # Set defaults for new interaction + self.datetime_input.setDateTime(datetime.now()) + + # Pre-select contact and application if provided + self._update_contact_display() + self._update_application_display() + + def _init_ui(self): + """Initialize the form UI.""" + layout = QVBoxLayout(self) + + form_layout = QFormLayout() + + # Interaction type dropdown + self.type_combo = QComboBox() + self.type_combo.addItems(self.INTERACTION_TYPES) + form_layout.addRow("Type:", self.type_combo) + + # Date and time picker + self.datetime_input = QDateTimeEdit() + self.datetime_input.setCalendarPopup(True) + self.datetime_input.setDisplayFormat("yyyy-MM-dd HH:mm") + form_layout.addRow("Date & Time:", self.datetime_input) + + # Subject field + self.subject_input = QLineEdit() + form_layout.addRow("Subject:", self.subject_input) + + # Contact selection + contact_layout = QHBoxLayout() + self.contact_label = QLabel("No contact selected") + contact_layout.addWidget(self.contact_label) + + self.select_contact_button = QPushButton("Select Contact") + self.select_contact_button.clicked.connect(self.on_select_contact) + contact_layout.addWidget(self.select_contact_button) + + self.clear_contact_button = QPushButton("Clear") + self.clear_contact_button.clicked.connect(self.on_clear_contact) + contact_layout.addWidget(self.clear_contact_button) + + form_layout.addRow("Contact:", contact_layout) + + # Application selection + app_layout = QHBoxLayout() + self.application_label = QLabel("No application selected") + app_layout.addWidget(self.application_label) + + self.select_app_button = QPushButton("Select Application") + self.select_app_button.clicked.connect(self.on_select_application) + app_layout.addWidget(self.select_app_button) + + self.clear_app_button = QPushButton("Clear") + self.clear_app_button.clicked.connect(self.on_clear_application) + app_layout.addWidget(self.clear_app_button) + + form_layout.addRow("Application:", app_layout) + + # Notes field + self.notes_input = QTextEdit() + form_layout.addRow("Notes:", self.notes_input) + + layout.addLayout(form_layout) + + # Bottom buttons + button_layout = QHBoxLayout() + + self.save_button = QPushButton("Save") + self.save_button.clicked.connect(self.on_save) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(self.save_button) + button_layout.addWidget(self.cancel_button) + + layout.addLayout(button_layout) + + def load_interaction_data(self): + """Load interaction data for editing.""" + try: + service = InteractionService() + self.interaction_data = service.get(self.interaction_id) + + if not self.interaction_data: + QMessageBox.warning(self, "Error", f"Interaction {self.interaction_id} not found") + return + + # Set values + interaction_type = self.interaction_data.get("interaction_type", "") + index = self.type_combo.findText(interaction_type) + if index >= 0: + self.type_combo.setCurrentIndex(index) + + # Set date and time + if self.interaction_data.get("date"): + try: + date_obj = datetime.fromisoformat(self.interaction_data["date"].replace("Z", "+00:00")) + self.datetime_input.setDateTime(date_obj) + except (ValueError, TypeError): + self.datetime_input.setDateTime(datetime.now()) + + # Set subject + self.subject_input.setText(self.interaction_data.get("subject", "")) + + # Set notes + self.notes_input.setText(self.interaction_data.get("notes", "")) + + # Set contact ID + self.contact_id = self.interaction_data.get("contact_id") + self._update_contact_display() + + # Set application ID + self.application_id = self.interaction_data.get("application_id") + self._update_application_display() + + except Exception as e: + logger.error(f"Error loading interaction data: {e}", exc_info=True) + QMessageBox.warning(self, "Error", f"Failed to load interaction data: {str(e)}") + + def _update_contact_display(self): + """Update the contact label with the current contact info.""" + if not self.contact_id: + self.contact_label.setText("No contact selected") + return + + try: + service = ContactService() + contact = service.get(self.contact_id) + + if contact: + self.contact_label.setText(f"{contact.get('name', '')} (ID: {self.contact_id})") + else: + self.contact_label.setText(f"Unknown Contact (ID: {self.contact_id})") + + except Exception as e: + logger.error(f"Error getting contact info: {e}", exc_info=True) + self.contact_label.setText(f"Error getting contact (ID: {self.contact_id})") + + def _update_application_display(self): + """Update the application label with the current application info.""" + if not self.application_id: + self.application_label.setText("No application selected") + return + + try: + service = ApplicationService() + application = service.get(self.application_id) + + if application: + job_title = application.get("job_title", "") + company_name = application.get("company", {}).get("name", "") + self.application_label.setText(f"{job_title} at {company_name} (ID: {self.application_id})") + else: + self.application_label.setText(f"Unknown Application (ID: {self.application_id})") + + except Exception as e: + logger.error(f"Error getting application info: {e}", exc_info=True) + self.application_label.setText(f"Error getting application (ID: {self.application_id})") + + @pyqtSlot() + def on_select_contact(self): + """Open dialog to select a contact.""" + dialog = ContactSelectorDialog(self) + if dialog.exec(): + self.contact_id = dialog.selected_contact_id + self._update_contact_display() + + @pyqtSlot() + def on_clear_contact(self): + """Clear the selected contact.""" + self.contact_id = None + self.contact_label.setText("No contact selected") + + @pyqtSlot() + def on_select_application(self): + """Open dialog to select an application.""" + dialog = ApplicationSelectorDialog(self) + if dialog.exec(): + self.application_id = dialog.selected_application_id + self._update_application_display() + + @pyqtSlot() + def on_clear_application(self): + """Clear the selected application.""" + self.application_id = None + self.application_label.setText("No application selected") + + @pyqtSlot() + def on_save(self): + """Save the interaction data.""" + # Check for required fields + if not self.contact_id: + QMessageBox.warning(self, "Missing Data", "Please select a contact for this interaction") + return + + # Gather data + interaction_data = { + "interaction_type": self.type_combo.currentText(), + "date": self.datetime_input.dateTime().toString(Qt.DateFormat.ISODate), + "subject": self.subject_input.text(), + "notes": self.notes_input.toPlainText(), + "contact_id": self.contact_id, + } + + # Add application ID if available + if self.application_id: + interaction_data["application_id"] = self.application_id + + try: + service = InteractionService() + + if self.interaction_id: + # Update existing interaction + result = service.update(self.interaction_id, interaction_data) + if result: + if self.main_window: + self.main_window.show_status_message("Interaction updated successfully") + self.accept() + else: + QMessageBox.warning(self, "Error", "Failed to update interaction") + else: + # Create new interaction + new_id = service.create(interaction_data) + if new_id: + if self.main_window: + self.main_window.show_status_message("Interaction created successfully") + self.interaction_id = new_id + self.accept() + else: + QMessageBox.warning(self, "Error", "Failed to create interaction") + + except Exception as e: + logger.error(f"Error saving interaction: {e}", exc_info=True) + QMessageBox.warning(self, "Error", f"Error saving interaction: {str(e)}") diff --git a/src/gui/dialogs/settings.py b/src/gui/dialogs/settings.py new file mode 100644 index 0000000..605e988 --- /dev/null +++ b/src/gui/dialogs/settings.py @@ -0,0 +1,221 @@ +import os + +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from src.db.database import change_database +from src.db.settings import Settings +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class SettingsDialog(QDialog): + """Dialog for application settings.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.main_window = parent + self.settings = Settings() + + self.setWindowTitle("Settings") + self.resize(500, 400) + + self._init_ui() + self._load_settings() + + def _init_ui(self) -> None: + """Initialize the settings dialog UI.""" + layout = QVBoxLayout(self) + + # Database settings + db_group = QGroupBox("Database Settings") + db_layout = QFormLayout() + + self.db_path_input = QLineEdit() + db_path_layout = QHBoxLayout() + db_path_layout.addWidget(self.db_path_input) + self.browse_button = QPushButton("Browse...") + self.browse_button.clicked.connect(self.on_browse_db) + db_path_layout.addWidget(self.browse_button) + db_layout.addRow("Database Path:", db_path_layout) + + self.db_status = QLabel() + db_layout.addRow("Current Status:", self.db_status) + + self.apply_db_button = QPushButton("Apply Database Changes") + self.apply_db_button.clicked.connect(self.on_apply_db_changes) + db_layout.addRow("", self.apply_db_button) + + db_group.setLayout(db_layout) + layout.addWidget(db_group) + + # General settings + general_group = QGroupBox("General Settings") + general_layout = QFormLayout() + + self.updates_checkbox = QCheckBox() + general_layout.addRow("Check for updates:", self.updates_checkbox) + + general_group.setLayout(general_layout) + layout.addWidget(general_group) + + # Logging settings + log_group = QGroupBox("Logging Settings") + log_layout = QFormLayout() + + self.log_level = QComboBox() + self.log_level.addItems(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + log_layout.addRow("Log Level:", self.log_level) + + self.log_dir = QLabel() + log_layout.addRow("Log Directory:", self.log_dir) + + self.view_logs_button = QPushButton("View Logs") + self.view_logs_button.clicked.connect(self.on_view_logs) + log_layout.addRow("", self.view_logs_button) + + log_group.setLayout(log_layout) + layout.addWidget(log_group) + + # Button row + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + self.save_button = QPushButton("Save Settings") + self.save_button.clicked.connect(self.on_save_settings) + btn_layout.addWidget(self.save_button) + + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + btn_layout.addWidget(self.cancel_button) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def _load_settings(self) -> None: + """Load current settings values.""" + # Database path + db_path = self.settings.get("database_path") + self.db_path_input.setText(db_path) + + # Database status + if self.settings.database_exists(): + self.db_status.setText("Database file exists") + self.db_status.setStyleSheet("color: green") + else: + self.db_status.setText("Database file does not exist") + self.db_status.setStyleSheet("color: red") + + # General settings + self.updates_checkbox.setChecked(self.settings.get("check_updates", True)) + + # Log settings + from src.config import LOG_DIR, LOG_LEVEL + + index = self.log_level.findText(LOG_LEVEL) + if index >= 0: + self.log_level.setCurrentIndex(index) + + self.log_dir.setText(str(LOG_DIR)) + + def on_browse_db(self) -> None: + """Browse for database file.""" + current_path = self.db_path_input.text() + initial_dir = os.path.dirname(os.path.expanduser(current_path)) + + file_path, _ = QFileDialog.getSaveFileName( + self, "Select Database Location", initial_dir, "Database Files (*.db);;All Files (*)" + ) + + if file_path: + self.db_path_input.setText(file_path) + + def on_apply_db_changes(self) -> None: + """Apply database path changes.""" + new_db_path = self.db_path_input.text() + + # Expand user path if needed + if new_db_path.startswith("~"): + new_db_path = os.path.expanduser(new_db_path) + + try: + # Check if directory exists or can be created + db_dir = os.path.dirname(new_db_path) + os.makedirs(db_dir, exist_ok=True) + + # Change database path + if change_database(new_db_path): + if self.main_window: + self.main_window.show_status_message(f"Database changed to {new_db_path}") + + # Update status + if os.path.exists(new_db_path): + self.db_status.setText("Database file exists") + self.db_status.setStyleSheet("color: green") + else: + self.db_status.setText("New database created") + self.db_status.setStyleSheet("color: green") + else: + if self.main_window: + self.main_window.show_status_message("Failed to change database") + self.db_status.setText("Error changing database") + self.db_status.setStyleSheet("color: red") + + except Exception as e: + logger.error(f"Error changing database: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + self.db_status.setText(f"Error: {str(e)}") + self.db_status.setStyleSheet("color: red") + + def on_view_logs(self) -> None: + """Open log directory.""" + import webbrowser + + from src.config import LOG_DIR + + log_dir = str(LOG_DIR) + if os.path.exists(log_dir): + webbrowser.open(f"file://{log_dir}") + if self.main_window: + self.main_window.show_status_message(f"Opening log directory: {log_dir}") + else: + if self.main_window: + self.main_window.show_status_message("Log directory does not exist yet") + QMessageBox.information(self, "Logs", "Log directory does not exist yet.") + + def on_save_settings(self) -> None: + """Save all settings.""" + try: + # Get values + db_path = self.db_path_input.text() + check_updates = self.updates_checkbox.isChecked() + + # Save settings + self.settings.set("database_path", db_path) + self.settings.set("check_updates", check_updates) + + if self.main_window: + self.main_window.show_status_message("Settings saved") + + self.accept() + + except Exception as e: + logger.error(f"Error saving settings: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error: {str(e)}") + QMessageBox.critical(self, "Error", f"Error saving settings: {str(e)}") diff --git a/src/gui/dialogs/status_transition.py b/src/gui/dialogs/status_transition.py new file mode 100644 index 0000000..38653e5 --- /dev/null +++ b/src/gui/dialogs/status_transition.py @@ -0,0 +1,137 @@ +from datetime import datetime + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from src.config import ApplicationStatus +from src.services.application_service import ApplicationService +from src.services.change_record_service import ChangeRecordService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class StatusTransitionDialog(QDialog): + """Dialog for changing application status.""" + + def __init__(self, parent=None, application_id=None, current_status=None): + super().__init__(parent) + self.main_window = parent.main_window if parent else None + self.application_id = application_id + self.current_status = current_status + + self.setWindowTitle("Change Application Status") + self.resize(400, 300) + + self._init_ui() + + def _init_ui(self): + """Initialize the dialog UI.""" + layout = QVBoxLayout(self) + + # Dialog title + title_label = QLabel(f"Change Status for Application #{self.application_id}") + title_font = QLabel().font() + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title_label) + + # Form layout for status selection + form_layout = QFormLayout() + + # Current status (read-only) + current_status_label = QLabel(self.current_status) + form_layout.addRow("Current Status:", current_status_label) + + # New status dropdown + self.new_status_select = QComboBox() + for status in ApplicationStatus: + self.new_status_select.addItem(status.value) + if status.value == self.current_status: + self.new_status_select.setCurrentText(status.value) + + form_layout.addRow("New Status:", self.new_status_select) + + # Notes field + form_layout.addRow("Add Note:", QLabel()) + self.notes_input = QTextEdit() + self.notes_input.setPlaceholderText("Optional note about this status change") + form_layout.addWidget(self.notes_input) + + layout.addLayout(form_layout) + + # Button row + btn_layout = QHBoxLayout() + btn_layout.addStretch() + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.cancel_btn) + + self.save_btn = QPushButton("Save") + self.save_btn.setDefault(True) + self.save_btn.clicked.connect(self.save_status_change) + btn_layout.addWidget(self.save_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + @pyqtSlot() + def save_status_change(self): + """Save the status change.""" + try: + new_status = self.new_status_select.currentText() + note = self.notes_input.toPlainText().strip() + + if new_status == self.current_status: + if self.main_window: + self.main_window.show_status_message("Status unchanged") + self.accept() + return + + # Update the status + service = ApplicationService() + service.update_status(self.application_id, new_status) + + # Create change record for the status change + change_service = ChangeRecordService() + change_service.create( + { + "application_id": self.application_id, + "change_type": "STATUS_CHANGE", + "old_value": self.current_status, + "new_value": new_status, + "notes": note if note else f"Status changed from {self.current_status} to {new_status}", + } + ) + + # Create interaction record for the status change + if note: + service.add_interaction( + { + "application_id": self.application_id, + "type": "NOTE", + "notes": f"Status changed from {self.current_status} to {new_status}. Note: {note}", + "date": datetime.now().isoformat(), + } + ) + + if self.main_window: + self.main_window.show_status_message(f"Status updated to {new_status}") + + self.accept() + + except Exception as e: + logger.error(f"Error updating status: {e}", exc_info=True) + QMessageBox.critical(self, "Error", f"Error updating status: {str(e)}") diff --git a/src/gui/main_window.py b/src/gui/main_window.py new file mode 100644 index 0000000..b5ad7fe --- /dev/null +++ b/src/gui/main_window.py @@ -0,0 +1,118 @@ +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QMainWindow, QMenuBar, QMessageBox, QStatusBar, QTabWidget + +from src import __version__ +from src.db.settings import Settings +from src.gui.dialogs.settings import SettingsDialog # Fixed import path +from src.gui.search import SearchDialog +from src.gui.tabs.applications import ApplicationsTab +from src.gui.tabs.companies import CompaniesTab +from src.gui.tabs.contacts import ContactsTab +from src.gui.tabs.dashboard import DashboardTab +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class MainWindow(QMainWindow): + """Main application window with tabs for different sections.""" + + def __init__(self) -> None: + super().__init__() + + # Configuration + self.settings = Settings() + self.setWindowTitle("JobTrackr") + self.resize(1024, 768) + + # Set up status bar + self.setStatusBar(QStatusBar()) # Create status bar using built-in method + self.statusBar().showMessage("Ready") # Use statusBar() method to access it + + # Initialize UI components + self._init_ui() + + logger.info("Main window initialized") + + def _init_ui(self) -> None: + """Initialize the user interface components.""" + # Create menu bar + self.menu_bar = QMenuBar(self) + self.setMenuBar(self.menu_bar) + + # File menu + file_menu = self.menu_bar.addMenu("File") + settings_action = QAction("Settings", self) + settings_action.triggered.connect(self.show_settings) + file_menu.addAction(settings_action) + + # Help menu + help_menu = self.menu_bar.addMenu("Help") + about_action = QAction("About", self) + about_action.triggered.connect(self.show_about) + help_menu.addAction(about_action) + + # Create tab widget + self.tabs = QTabWidget() + + # Create each tab + self.dashboard_tab = DashboardTab(self) + self.applications_tab = ApplicationsTab(self) + self.companies_tab = CompaniesTab(self) + self.contacts_tab = ContactsTab(self) + + # Add tabs to widget + self.tabs.addTab(self.dashboard_tab, "Dashboard") + self.tabs.addTab(self.applications_tab, "Applications") + self.tabs.addTab(self.companies_tab, "Companies") + self.tabs.addTab(self.contacts_tab, "Contacts") + + # Connect tab changed signal + self.tabs.currentChanged.connect(self.on_tab_changed) + + # Set central widget + self.setCentralWidget(self.tabs) + + # Set up keyboard shortcuts + self._setup_shortcuts() + + def _setup_shortcuts(self) -> None: + """Set up keyboard shortcuts for common actions.""" + # These would typically be implemented using QAction and QShortcut + pass + + def on_tab_changed(self, index) -> None: + """Handle tab changes - refresh data in the selected tab.""" + current_tab = self.tabs.widget(index) + if hasattr(current_tab, "refresh_data"): + current_tab.refresh_data() + + def show_settings(self) -> None: + """Show the settings dialog.""" + dialog = SettingsDialog(self) + dialog.exec() + + def show_search(self) -> None: + """Show the search dialog.""" + dialog = SearchDialog(self) + dialog.exec() + + def show_about(self) -> None: + """Show the about dialog.""" + from PyQt6.QtWidgets import QMessageBox + + QMessageBox.about(self, "About JobTrackr", f"JobTrackr v{__version__}\n\nA job application tracking tool.") + + def show_status_message(self, message, timeout=5000) -> None: + """Display a message in the status bar.""" + self.statusBar().showMessage(message, timeout) # Use statusBar() method instead of status_bar attribute + + def show_error_message(self, title, message) -> None: + """Display an error message dialog.""" + QMessageBox.critical(self, title, message) + + def closeEvent(self, event) -> None: + """Handle window close event.""" + logger.info("Application shutting down") + # Any cleanup needed before closing + event.accept() diff --git a/src/gui/search.py b/src/gui/search.py new file mode 100644 index 0000000..f3317ad --- /dev/null +++ b/src/gui/search.py @@ -0,0 +1,123 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +from src.gui.components.data_table import DataTable +from src.gui.dialogs.application_detail import ApplicationDetailDialog +from src.services.application_service import ApplicationService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class SearchDialog(QDialog): + """Dialog for searching applications.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.main_window = parent + + self.setWindowTitle("Search Applications") + self.resize(600, 400) + + self._init_ui() + + def _init_ui(self) -> None: + """Initialize the search dialog UI.""" + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Search Applications") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title_label) + + # Search form + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter search term...") + self.search_input.returnPressed.connect(self.perform_search) + self.search_button = QPushButton("Search") + self.search_button.clicked.connect(self.perform_search) + + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.search_button) + layout.addLayout(search_layout) + + # Results table + self.results_table = DataTable(0, ["ID", "Job Title", "Company", "Position", "Status"]) + self.results_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.results_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.results_table.doubleClicked.connect(self.on_row_double_clicked) + layout.addWidget(self.results_table) + + # Close button + self.close_button = QPushButton("Close") + self.close_button.clicked.connect(self.reject) + + btn_layout = QHBoxLayout() + btn_layout.addStretch() + btn_layout.addWidget(self.close_button) + layout.addLayout(btn_layout) + + self.setLayout(layout) + + # Focus the search input + self.search_input.setFocus() + + def perform_search(self) -> None: + """Search for applications matching the search term.""" + search_term = self.search_input.text().strip() + + if not search_term: + if self.main_window: + self.main_window.show_status_message("Please enter a search term") + return + + try: + if self.main_window: + self.main_window.show_status_message(f"Searching for '{search_term}'...") + + service = ApplicationService() + results = service.search_applications(search_term) + + self.results_table.setRowCount(0) + + for i, app in enumerate(results): + self.results_table.insertRow(i) + self.results_table.setItem(i, 0, QTableWidgetItem(str(app["id"]))) + self.results_table.setItem(i, 1, QTableWidgetItem(app["job_title"])) + + company_name = app.get("company", {}).get("name", "") + self.results_table.setItem(i, 2, QTableWidgetItem(company_name)) + + self.results_table.setItem(i, 3, QTableWidgetItem(app["position"])) + self.results_table.setItem(i, 4, QTableWidgetItem(app["status"])) + + count = len(results) + if self.main_window: + self.main_window.show_status_message(f"Found {count} result{'s' if count != 1 else ''}") + + except Exception as e: + logger.error(f"Search error: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Search error: {str(e)}") + + def on_row_double_clicked(self, index) -> None: + """Open the selected application.""" + app_id = int(self.results_table.item(index.row(), 0).text()) + + # Close search dialog + self.accept() + + # Open application detail + dialog = ApplicationDetailDialog(self.main_window, app_id) + dialog.exec() diff --git a/src/tui/tabs/companies/__init__.py b/src/gui/tabs/__init__.py similarity index 100% rename from src/tui/tabs/companies/__init__.py rename to src/gui/tabs/__init__.py diff --git a/src/gui/tabs/applications.py b/src/gui/tabs/applications.py new file mode 100644 index 0000000..31bff28 --- /dev/null +++ b/src/gui/tabs/applications.py @@ -0,0 +1,306 @@ +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from src.config import ApplicationStatus +from src.gui.components.data_table import DataTable +from src.gui.dialogs.application_detail import ApplicationDetailDialog +from src.gui.dialogs.application_form import ApplicationForm +from src.services.application_service import ApplicationService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ApplicationsTab(QWidget): + """Tab for managing job applications.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.main_window = parent + self.current_status = None + self._init_ui() + self.load_applications() + + def _init_ui(self) -> None: + """Initialize the applications tab UI.""" + layout = QVBoxLayout(self) + + # Header section with filters + header_layout = QHBoxLayout() + + # Status filter + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("Status:")) + self.status_filter = QComboBox() + self.status_filter.addItem("All") + for status in ApplicationStatus: + self.status_filter.addItem(status.value) + self.status_filter.currentTextChanged.connect(self.on_status_filter_changed) + filter_layout.addWidget(self.status_filter) + + # Search box + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search applications...") + self.search_input.returnPressed.connect(self.on_search) + self.search_button = QPushButton("🔍") + self.search_button.clicked.connect(self.on_search) + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.search_button) + + # New application button + self.new_app_button = QPushButton("New Application") + self.new_app_button.clicked.connect(self.on_new_application) + + # Add all components to header + header_layout.addLayout(filter_layout) + header_layout.addLayout(search_layout) + header_layout.addStretch() + header_layout.addWidget(self.new_app_button) + + layout.addLayout(header_layout) + + # Table for applications + self.table = DataTable( + 0, ["ID", "Job Title", "Company", "Position", "Status", "Applied Date"] + ) # 0 rows, 6 columns + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.itemDoubleClicked.connect(self.on_row_double_clicked) + layout.addWidget(self.table) + + # Action buttons + actions_layout = QHBoxLayout() + self.view_button = QPushButton("View") + self.edit_button = QPushButton("Edit") + self.delete_button = QPushButton("Delete") + self.view_button.clicked.connect(self.on_view_application) + self.edit_button.clicked.connect(self.on_edit_application) + self.delete_button.clicked.connect(self.on_delete_application) + + # Initially disable buttons until selection + self.view_button.setEnabled(False) + self.edit_button.setEnabled(False) + self.delete_button.setEnabled(False) + + actions_layout.addStretch() + actions_layout.addWidget(self.view_button) + actions_layout.addWidget(self.edit_button) + actions_layout.addWidget(self.delete_button) + layout.addLayout(actions_layout) + + self.setLayout(layout) + + # Connect selection signal + self.table.itemSelectionChanged.connect(self.on_selection_changed) + + def load_applications(self, status=None) -> None: + """Load applications with optional status filter.""" + try: + self.main_window.show_status_message("Loading applications...") + self.current_status = status + + service = ApplicationService() + + if status and status != "All": + applications = service.get_applications(status=status) + else: + applications = service.get_applications() + + # Clear and update table + self.table.setRowCount(0) + + if not applications: + self.main_window.show_status_message("No applications found") + return + + for i, app in enumerate(applications): + self.table.insertRow(i) + + # Add items to the row + self.table.setItem(i, 0, QTableWidgetItem(str(app["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(app["job_title"])) + + company_name = app.get("company", {}).get("name", "") + self.table.setItem(i, 2, QTableWidgetItem(company_name)) + + self.table.setItem(i, 3, QTableWidgetItem(app["position"])) + + status_item = QTableWidgetItem(app["status"]) + self.table.setItem(i, 4, status_item) + + # Format date + date_str = app["applied_date"].split("T")[0] + self.table.setItem(i, 5, QTableWidgetItem(date_str)) + + count = len(applications) + self.main_window.show_status_message(f"Loaded {count} application{'s' if count != 1 else ''}") + + except Exception as e: + logger.error(f"Error loading applications: {e}", exc_info=True) + self.main_window.show_status_message(f"Error: {str(e)}") + + def search_applications(self, search_term) -> None: + """Search applications by keyword.""" + try: + if not search_term: + self.load_applications(self.current_status) + return + + self.main_window.show_status_message(f"Searching for '{search_term}'...") + + service = ApplicationService() + applications = service.search_applications(search_term) + + # Apply status filter if active + if self.current_status and self.current_status != "All": + applications = [app for app in applications if app["status"] == self.current_status] + + # Update table + self.table.setRowCount(0) + + if not applications: + self.main_window.show_status_message(f"No results found for '{search_term}'") + return + + for i, app in enumerate(applications): + self.table.insertRow(i) + self.table.setItem(i, 0, QTableWidgetItem(str(app["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(app["job_title"])) + + company_name = app.get("company", {}).get("name", "") + self.table.setItem(i, 2, QTableWidgetItem(company_name)) + + self.table.setItem(i, 3, QTableWidgetItem(app["position"])) + self.table.setItem(i, 4, QTableWidgetItem(app["status"])) + + date_str = app["applied_date"].split("T")[0] + self.table.setItem(i, 5, QTableWidgetItem(date_str)) + + count = len(applications) + self.main_window.show_status_message( + f"Found {count} application{'s' if count != 1 else ''} matching '{search_term}'" + ) + + except Exception as e: + logger.error(f"Error searching applications: {e}", exc_info=True) + self.main_window.show_status_message(f"Search error: {str(e)}") + + def get_selected_application_id(self) -> int | None: + """Get the ID of the selected application.""" + selected_items = self.table.selectedItems() + if not selected_items: + return None + + row = selected_items[0].row() + id_item = self.table.item(row, 0) + if id_item: + return int(id_item.text()) + return None + + def refresh_data(self) -> None: + """Refresh the applications data.""" + self.load_applications(self.current_status) + + @pyqtSlot() + def on_selection_changed(self) -> None: + """Enable or disable buttons based on selection.""" + has_selection = bool(self.table.selectedItems()) + self.view_button.setEnabled(has_selection) + self.edit_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + + @pyqtSlot(str) + def on_status_filter_changed(self, status) -> None: + """Handle status filter changes.""" + if status == "All": + self.load_applications(None) + else: + self.load_applications(status) + + @pyqtSlot() + def on_search(self) -> None: + """Handle search button click.""" + search_term = self.search_input.text().strip() + self.search_applications(search_term) + + @pyqtSlot() + def on_new_application(self) -> None: + """Open dialog to create a new application.""" + dialog = ApplicationForm(self) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_view_application(self) -> None: + """Open the application detail view.""" + app_id = self.get_selected_application_id() + if app_id: + dialog = ApplicationDetailDialog(self, app_id) + dialog.exec() + self.refresh_data() + + @pyqtSlot() + def on_edit_application(self) -> None: + """Open dialog to edit the selected application.""" + app_id = self.get_selected_application_id() + if app_id: + dialog = ApplicationForm(self, app_id) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_delete_application(self) -> None: + """Delete the selected application.""" + app_id = self.get_selected_application_id() + if not app_id: + return + + # Get application details for confirmation message + row = self.table.selectedItems()[0].row() + job_title = self.table.item(row, 1).text() + company = self.table.item(row, 2).text() + + # Confirm deletion + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete this application?\n\nTitle: {job_title}\nCompany: {company}\n\nThis action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = ApplicationService() + success = service.delete(app_id) + + if success: + self.main_window.show_status_message(f"Application {app_id} deleted successfully") + self.refresh_data() + else: + self.main_window.show_status_message(f"Failed to delete application {app_id}") + except Exception as e: + logger.error(f"Error deleting application: {e}", exc_info=True) + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot(QTableWidgetItem) + def on_row_double_clicked(self, item) -> None: + """Handle row double click to open application details.""" + row = item.row() + app_id = int(self.table.item(row, 0).text()) + dialog = ApplicationDetailDialog(self, app_id) + dialog.exec() + self.refresh_data() diff --git a/src/gui/tabs/companies.py b/src/gui/tabs/companies.py new file mode 100644 index 0000000..f9a1ebb --- /dev/null +++ b/src/gui/tabs/companies.py @@ -0,0 +1,308 @@ +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from src.config import CompanyType +from src.gui.components.data_table import DataTable +from src.gui.dialogs.company_detail import CompanyDetailDialog +from src.gui.dialogs.company_form import CompanyForm +from src.services.company_service import CompanyService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class CompaniesTab(QWidget): + """Tab for managing companies.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.main_window = parent + self.company_type_filter = None + self._init_ui() + self.load_companies() + + def _init_ui(self) -> None: + """Initialize the companies tab UI.""" + layout = QVBoxLayout(self) + + # Header section with filters + header_layout = QHBoxLayout() + + # Type filter + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("Type:")) + self.type_filter = QComboBox() + self.type_filter.addItem("All") + for company_type in CompanyType: + self.type_filter.addItem(company_type.value) + self.type_filter.currentTextChanged.connect(self.on_type_filter_changed) + filter_layout.addWidget(self.type_filter) + + # Search box + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search companies...") + self.search_input.returnPressed.connect(self.on_search) + self.search_button = QPushButton("🔍") + self.search_button.clicked.connect(self.on_search) + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.search_button) + + # New company button + self.new_company_button = QPushButton("New Company") + self.new_company_button.clicked.connect(self.on_new_company) + + # Add all components to header + header_layout.addLayout(filter_layout) + header_layout.addLayout(search_layout) + header_layout.addStretch() + header_layout.addWidget(self.new_company_button) + + layout.addLayout(header_layout) + + # Table for companies + self.table = DataTable(0, ["ID", "Name", "Industry", "Type", "Website", "Size"]) # 0 rows, 6 columns + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.itemDoubleClicked.connect(self.on_row_double_clicked) + layout.addWidget(self.table) + + # Action buttons + actions_layout = QHBoxLayout() + self.view_button = QPushButton("View") + self.edit_button = QPushButton("Edit") + self.delete_button = QPushButton("Delete") + self.view_button.clicked.connect(self.on_view_company) + self.edit_button.clicked.connect(self.on_edit_company) + self.delete_button.clicked.connect(self.on_delete_company) + + # Initially disable buttons until selection + self.view_button.setEnabled(False) + self.edit_button.setEnabled(False) + self.delete_button.setEnabled(False) + + actions_layout.addStretch() + actions_layout.addWidget(self.view_button) + actions_layout.addWidget(self.edit_button) + actions_layout.addWidget(self.delete_button) + layout.addLayout(actions_layout) + + self.setLayout(layout) + + # Connect selection signal + self.table.itemSelectionChanged.connect(self.on_selection_changed) + + def load_companies(self, company_type=None) -> None: + """Load companies with optional type filter.""" + try: + self.main_window.show_status_message("Loading companies...") + self.company_type_filter = company_type + + service = CompanyService() + companies = service.get_all() + + # Apply type filter if specified + if company_type and company_type != "All": + companies = [c for c in companies if c.get("type") == company_type] + + # Clear and update table + self.table.setRowCount(0) + + if not companies: + self.main_window.show_status_message("No companies found") + return + + for i, company in enumerate(companies): + self.table.insertRow(i) + + # Add items to the row + self.table.setItem(i, 0, QTableWidgetItem(str(company["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(company["name"])) + + industry = company.get("industry", "") + self.table.setItem(i, 2, QTableWidgetItem(industry or "")) + + company_type = company.get("type", "") + self.table.setItem(i, 3, QTableWidgetItem(company_type or "")) + + website = company.get("website", "") + self.table.setItem(i, 4, QTableWidgetItem(website or "")) + + size = company.get("size", "") + self.table.setItem(i, 5, QTableWidgetItem(size or "")) + + count = len(companies) + self.main_window.show_status_message(f"Loaded {count} compan{'ies' if count != 1 else 'y'}") + + except Exception as e: + logger.error(f"Error loading companies: {e}", exc_info=True) + self.main_window.show_status_message(f"Error loading companies: {str(e)}") + + def search_companies(self, search_term) -> None: + """Search companies by name or industry.""" + try: + if not search_term: + self.load_companies(self.company_type_filter) + return + + self.main_window.show_status_message(f"Searching for '{search_term}'...") + + service = CompanyService() + all_companies = service.get_all() + + # Simple case-insensitive search + search_term = search_term.lower() + filtered_companies = [ + c + for c in all_companies + if search_term in c["name"].lower() + or (c.get("industry") and search_term in c.get("industry", "").lower()) + or (c.get("notes") and search_term in c.get("notes", "").lower()) + ] + + # Apply type filter if active + if self.company_type_filter and self.company_type_filter != "All": + filtered_companies = [c for c in filtered_companies if c.get("type") == self.company_type_filter] + + # Update table + self.table.setRowCount(0) + + if not filtered_companies: + self.main_window.show_status_message(f"No companies found matching '{search_term}'") + return + + for i, company in enumerate(filtered_companies): + self.table.insertRow(i) + self.table.setItem(i, 0, QTableWidgetItem(str(company["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(company["name"])) + self.table.setItem(i, 2, QTableWidgetItem(company.get("industry") or "")) + self.table.setItem(i, 3, QTableWidgetItem(company.get("type") or "")) + self.table.setItem(i, 4, QTableWidgetItem(company.get("website") or "")) + self.table.setItem(i, 5, QTableWidgetItem(company.get("size") or "")) + + count = len(filtered_companies) + self.main_window.show_status_message( + f"Found {count} compan{'ies' if count != 1 else 'y'} matching '{search_term}'" + ) + + except Exception as e: + logger.error(f"Error searching companies: {e}", exc_info=True) + self.main_window.show_status_message(f"Search error: {str(e)}") + + def get_selected_company_id(self) -> int | None: + """Get the ID of the selected company.""" + selected_items = self.table.selectedItems() + if not selected_items: + return None + + row = selected_items[0].row() + id_item = self.table.item(row, 0) + if id_item: + return int(id_item.text()) + return None + + def refresh_data(self) -> None: + """Refresh the companies data.""" + self.load_companies(self.company_type_filter) + + @pyqtSlot() + def on_selection_changed(self) -> None: + """Enable or disable buttons based on selection.""" + has_selection = bool(self.table.selectedItems()) + self.view_button.setEnabled(has_selection) + self.edit_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + + @pyqtSlot(str) + def on_type_filter_changed(self, company_type) -> None: + """Handle type filter changes.""" + if company_type == "All": + self.load_companies(None) + else: + self.load_companies(company_type) + + @pyqtSlot() + def on_search(self) -> None: + """Handle search button click.""" + search_term = self.search_input.text().strip() + self.search_companies(search_term) + + @pyqtSlot() + def on_new_company(self) -> None: + """Open dialog to create a new company.""" + dialog = CompanyForm(self) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_view_company(self) -> None: + """Open the company detail view.""" + company_id = self.get_selected_company_id() + if company_id: + dialog = CompanyDetailDialog(self, company_id) + dialog.exec() + self.refresh_data() + + @pyqtSlot() + def on_edit_company(self) -> None: + """Open dialog to edit the selected company.""" + company_id = self.get_selected_company_id() + if company_id: + dialog = CompanyForm(self, company_id) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_delete_company(self) -> None: + """Delete the selected company.""" + company_id = self.get_selected_company_id() + if not company_id: + return + + # Get company name for confirmation message + row = self.table.selectedItems()[0].row() + company_name = self.table.item(row, 1).text() + + # Confirm deletion + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete company '{company_name}'?\n\nThis will remove all relationships with this company. This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = CompanyService() + success = service.delete(company_id) + + if success: + self.main_window.show_status_message(f"Company '{company_name}' deleted successfully") + self.refresh_data() + else: + self.main_window.show_status_message(f"Failed to delete company {company_id}") + except Exception as e: + logger.error(f"Error deleting company: {e}", exc_info=True) + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot(QTableWidgetItem) + def on_row_double_clicked(self, item) -> None: + """Handle row double click to open application details.""" + row = item.row() + company_id = int(self.table.item(row, 0).text()) + dialog = CompanyDetailDialog(self, company_id) + dialog.exec() + self.refresh_data() diff --git a/src/gui/tabs/contacts.py b/src/gui/tabs/contacts.py new file mode 100644 index 0000000..d1ca85c --- /dev/null +++ b/src/gui/tabs/contacts.py @@ -0,0 +1,326 @@ +from typing import Any + +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from src.gui.components.data_table import DataTable +from src.gui.dialogs.contact_detail import ContactDetailDialog +from src.gui.dialogs.contact_form import ContactForm +from src.services.company_service import CompanyService +from src.services.contact_service import ContactService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class ContactsTab(QWidget): + """Tab for managing contacts.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.main_window = parent + self.companies: list[dict[str, Any]] = [] + self.company_filter = None + self._init_ui() + self.load_companies() # Load companies first before contacts + + def _init_ui(self) -> None: + """Initialize the contacts tab UI.""" + layout = QVBoxLayout(self) + + # Header section with filters + header_layout = QHBoxLayout() + + # Company filter + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("Company:")) + self.company_filter_combo = QComboBox() + self.company_filter_combo.currentTextChanged.connect(self.on_company_filter_changed) + filter_layout.addWidget(self.company_filter_combo) + + # Search box + search_layout = QHBoxLayout() + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search contacts...") + self.search_input.returnPressed.connect(self.on_search) + self.search_button = QPushButton("🔍") + self.search_button.clicked.connect(self.on_search) + search_layout.addWidget(self.search_input) + search_layout.addWidget(self.search_button) + + # New contact button + self.new_contact_button = QPushButton("New Contact") + self.new_contact_button.clicked.connect(self.on_new_contact) + + # Add all components to header + header_layout.addLayout(filter_layout) + header_layout.addLayout(search_layout) + header_layout.addStretch() + header_layout.addWidget(self.new_contact_button) + + layout.addLayout(header_layout) + + # Table for contacts + self.table = DataTable(0, ["ID", "Name", "Title", "Company", "Email", "Phone"]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.itemDoubleClicked.connect(self.on_row_double_clicked) + layout.addWidget(self.table) + + # Action buttons + actions_layout = QHBoxLayout() + self.view_button = QPushButton("View") + self.edit_button = QPushButton("Edit") + self.delete_button = QPushButton("Delete") + self.view_button.clicked.connect(self.on_view_contact) + self.edit_button.clicked.connect(self.on_edit_contact) + self.delete_button.clicked.connect(self.on_delete_contact) + + # Initially disable buttons until selection + self.view_button.setEnabled(False) + self.edit_button.setEnabled(False) + self.delete_button.setEnabled(False) + + actions_layout.addStretch() + actions_layout.addWidget(self.view_button) + actions_layout.addWidget(self.edit_button) + actions_layout.addWidget(self.delete_button) + layout.addLayout(actions_layout) + + self.setLayout(layout) + + # Connect selection signal + self.table.itemSelectionChanged.connect(self.on_selection_changed) + + def load_companies(self) -> None: + """Load companies for filtering.""" + try: + service = CompanyService() + self.companies = service.get_all() + + # Create options list + self.company_filter_combo.clear() + self.company_filter_combo.addItem("All Companies") + + for company in self.companies: + self.company_filter_combo.addItem(company["name"], company["id"]) + + self.company_filter_combo.addItem("No Company", "None") + + # Now load contacts after company filter is set up + self.load_contacts() + + except Exception as e: + logger.error(f"Error loading companies: {e}", exc_info=True) + if self.main_window: + self.main_window.show_status_message(f"Error loading companies: {str(e)}") + + def load_contacts(self, company_id=None) -> None: + """Load contacts with optional company filter.""" + try: + self.main_window.show_status_message("Loading contacts...") + self.company_filter = company_id + + service = ContactService() + + filter_company_id = ( + None if not company_id or company_id == "All" else int(company_id) if company_id != "None" else None + ) + contacts = service.get_contacts(company_id=filter_company_id) + + # Clear and update table + self.table.setRowCount(0) + + if not contacts: + self.main_window.show_status_message("No contacts found") + return + + for i, contact in enumerate(contacts): + self.table.insertRow(i) + + # Add items to the row + self.table.setItem(i, 0, QTableWidgetItem(str(contact["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(contact["name"])) + self.table.setItem(i, 2, QTableWidgetItem(contact.get("title", ""))) + + company_name = contact.get("company", {}).get("name", "") + self.table.setItem(i, 3, QTableWidgetItem(company_name)) + + self.table.setItem(i, 4, QTableWidgetItem(contact.get("email", ""))) + self.table.setItem(i, 5, QTableWidgetItem(contact.get("phone", ""))) + + count = len(contacts) + self.main_window.show_status_message(f"Loaded {count} contact{'s' if count != 1 else ''}") + + except Exception as e: + logger.error(f"Error loading contacts: {e}", exc_info=True) + self.main_window.show_status_message(f"Error loading contacts: {str(e)}") + + def search_contacts(self, search_term) -> None: + """Search contacts by name, email, or title.""" + try: + if not search_term: + self.load_contacts(self.company_filter) + return + + self.main_window.show_status_message(f"Searching for '{search_term}'...") + + service = ContactService() + results = service.search_contacts(search_term) + + # Apply company filter if active + if self.company_filter and self.company_filter != "All": + if self.company_filter == "None": + # Filter for contacts without company + results = [c for c in results if not c.get("company")] + else: + filter_id = int(self.company_filter) + results = [c for c in results if c.get("company", {}).get("id") == filter_id] + + # Update table + self.table.setRowCount(0) + + if not results: + self.main_window.show_status_message(f"No contacts found matching '{search_term}'") + return + + for i, contact in enumerate(results): + self.table.insertRow(i) + self.table.setItem(i, 0, QTableWidgetItem(str(contact["id"]))) + self.table.setItem(i, 1, QTableWidgetItem(contact["name"])) + self.table.setItem(i, 2, QTableWidgetItem(contact.get("title", ""))) + + company_name = contact.get("company", {}).get("name", "") + self.table.setItem(i, 3, QTableWidgetItem(company_name)) + + self.table.setItem(i, 4, QTableWidgetItem(contact.get("email", ""))) + self.table.setItem(i, 5, QTableWidgetItem(contact.get("phone", ""))) + + count = len(results) + self.main_window.show_status_message( + f"Found {count} contact{'s' if count != 1 else ''} matching '{search_term}'" + ) + + except Exception as e: + logger.error(f"Error searching contacts: {e}", exc_info=True) + self.main_window.show_status_message(f"Search error: {str(e)}") + + def get_selected_contact_id(self) -> int | None: + """Get the ID of the selected contact.""" + selected_items = self.table.selectedItems() + if not selected_items: + return None + + row = selected_items[0].row() + id_item = self.table.item(row, 0) + if id_item: + return int(id_item.text()) + return None + + def refresh_data(self) -> None: + """Refresh the contacts data.""" + self.load_contacts(self.company_filter) + + @pyqtSlot() + def on_selection_changed(self) -> None: + """Enable or disable buttons based on selection.""" + has_selection = bool(self.table.selectedItems()) + self.view_button.setEnabled(has_selection) + self.edit_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + + @pyqtSlot(str) + def on_company_filter_changed(self, company_name) -> None: + """Handle company filter changes.""" + index = self.company_filter_combo.currentIndex() + company_id = self.company_filter_combo.itemData(index) + + if company_name == "All Companies": + self.load_contacts(None) + else: + self.load_contacts(company_id) + + @pyqtSlot() + def on_search(self) -> None: + """Handle search button click.""" + search_term = self.search_input.text().strip() + self.search_contacts(search_term) + + @pyqtSlot() + def on_new_contact(self) -> None: + """Open dialog to create a new contact.""" + dialog = ContactForm(self) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_view_contact(self) -> None: + """Open the contact detail view.""" + contact_id = self.get_selected_contact_id() + if contact_id: + dialog = ContactDetailDialog(self, contact_id) + dialog.exec() + + @pyqtSlot() + def on_edit_contact(self) -> None: + """Open dialog to edit the selected contact.""" + contact_id = self.get_selected_contact_id() + if contact_id: + dialog = ContactForm(self, contact_id) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_delete_contact(self) -> None: + """Delete the selected contact.""" + contact_id = self.get_selected_contact_id() + if not contact_id: + return + + # Get contact name for confirmation message + row = self.table.selectedItems()[0].row() + contact_name = self.table.item(row, 1).text() + + # Confirm deletion + reply = QMessageBox.question( + self, + "Confirm Deletion", + f"Are you sure you want to delete contact '{contact_name}'?\n\nThis action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + service = ContactService() + success = service.delete(contact_id) + + if success: + self.main_window.show_status_message(f"Contact '{contact_name}' deleted successfully") + self.refresh_data() + else: + self.main_window.show_status_message(f"Failed to delete contact {contact_id}") + except Exception as e: + logger.error(f"Error deleting contact: {e}", exc_info=True) + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot(QTableWidgetItem) + def on_row_double_clicked(self, item) -> None: + """Handle row double click to open application details.""" + row = item.row() + contact_id = int(self.table.item(row, 0).text()) + dialog = ContactDetailDialog(self, contact_id) + dialog.exec() + self.refresh_data() diff --git a/src/gui/tabs/dashboard.py b/src/gui/tabs/dashboard.py new file mode 100644 index 0000000..042e3d4 --- /dev/null +++ b/src/gui/tabs/dashboard.py @@ -0,0 +1,247 @@ +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from src.config import FONT_SIZES, UI_COLORS +from src.gui.components.data_table import DataTable +from src.gui.dialogs.application_form import ApplicationForm +from src.services.application_service import ApplicationService +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +class StatsCard(QWidget): + """Widget displaying a statistic with title and value.""" + + def __init__(self, title, value="0", icon=None, color=UI_COLORS["primary"], parent=None): + super().__init__(parent) + + # Set up styling + self.setStyleSheet(f""" + QWidget {{ + background-color: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 8px; + }} + QLabel[class="title"] {{ + color: #6B7280; + font-size: {FONT_SIZES["md"]}px; + }} + QLabel[class="value"] {{ + color: {color}; + font-size: {FONT_SIZES["3xl"]}px; + font-weight: bold; + }} + """) + + # Create layout + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(16, 16, 16, 16) + + # Header with icon and title + header_layout = QHBoxLayout() + + # Title label + self.title_label = QLabel(title) + self.title_label.setProperty("class", "title") + header_layout.addWidget(self.title_label) + header_layout.addStretch() + + # Add icon if provided + if icon: + icon_label = QLabel() + icon_label.setPixmap(icon.pixmap(24, 24)) + header_layout.addWidget(icon_label) + + # Value label with larger font + self.value_label = QLabel(value) + self.value_label.setProperty("class", "value") + self.value_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + + # Add widgets to layout + self.layout.addLayout(header_layout) + self.layout.addWidget(self.value_label) + self.layout.addStretch() + + # Set fixed size + self.setMinimumHeight(120) + self.setMaximumHeight(120) + + def update_value(self, value): + """Update the displayed value.""" + self.value_label.setText(str(value)) + + +class ApplicationList(DataTable): + """Table widget showing a list of applications.""" + + def __init__(self, parent=None): + super().__init__(0, ["Job Title", "Company", "Status", "Applied Date"], parent) # 0 rows, 4 columns + + # Set up the table + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + + def update_applications(self, applications): + """Update the displayed applications.""" + self.setRowCount(0) # Clear current rows + + if not applications: + self.insertRow(0) + self.setItem(0, 0, QTableWidgetItem("No applications found")) + return + + for i, app in enumerate(applications): + self.insertRow(i) + + # Add items to the row + self.setItem(i, 0, QTableWidgetItem(app["job_title"])) + + company_name = app.get("company", {}).get("name", "") + self.setItem(i, 1, QTableWidgetItem(company_name)) + + status_item = QTableWidgetItem(app["status"]) + self.setItem(i, 2, status_item) + + self.setItem(i, 3, QTableWidgetItem(app["applied_date"].split("T")[0])) + + +class DashboardTab(QWidget): + """Dashboard tab showing overview and recent activities.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.main_window = parent + self._init_ui() + self.refresh_data() + + def _init_ui(self) -> None: + """Initialize the dashboard UI.""" + main_layout = QVBoxLayout(self) + + # Header section + header_layout = QVBoxLayout() + title_label = QLabel("Your Job Search Dashboard") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Quick actions + actions_layout = QHBoxLayout() + self.new_app_btn = QPushButton("➕ New Application") + self.new_app_btn.clicked.connect(self.on_new_application) + actions_layout.addStretch() + actions_layout.addWidget(self.new_app_btn) + actions_layout.addStretch() + + header_layout.addWidget(title_label) + header_layout.addLayout(actions_layout) + main_layout.addLayout(header_layout) + + # Stats section + stats_layout = QVBoxLayout() + stats_label = QLabel("Overview") + stats_label.setFont(QFont("", 12, QFont.Weight.Bold)) + stats_layout.addWidget(stats_label) + + # Stats cards in a grid + stats_grid = QGridLayout() + self.total_apps_card = StatsCard("Total Applications") + self.applied_apps_card = StatsCard("Applied") + self.interview_apps_card = StatsCard("Interviews") + self.offer_apps_card = StatsCard("Offers") + + stats_grid.addWidget(self.total_apps_card, 0, 0) + stats_grid.addWidget(self.applied_apps_card, 0, 1) + stats_grid.addWidget(self.interview_apps_card, 0, 2) + stats_grid.addWidget(self.offer_apps_card, 0, 3) + + stats_layout.addLayout(stats_grid) + main_layout.addLayout(stats_layout) + + # Content section - two columns + content_layout = QHBoxLayout() + + # Left column - Recent applications + left_column = QVBoxLayout() + recent_apps_label = QLabel("Recent Applications") + recent_apps_label.setFont(QFont("", 12, QFont.Weight.Bold)) + left_column.addWidget(recent_apps_label) + + self.recent_apps_list = ApplicationList() + left_column.addWidget(self.recent_apps_list) + + self.view_all_apps_btn = QPushButton("View All Applications") + self.view_all_apps_btn.clicked.connect(self.on_view_all_applications) + left_column.addWidget(self.view_all_apps_btn) + + # Right column - Activity feed + right_column = QVBoxLayout() + activity_label = QLabel("Recent Activity") + activity_label.setFont(QFont("", 12, QFont.Weight.Bold)) + right_column.addWidget(activity_label) + + self.activity_feed = QLabel("No recent activity") + right_column.addWidget(self.activity_feed) + right_column.addStretch() + + # Add columns to content layout + content_layout.addLayout(left_column) + content_layout.addLayout(right_column) + main_layout.addLayout(content_layout) + + self.setLayout(main_layout) + + def refresh_data(self) -> None: + """Load and display dashboard data.""" + try: + service = ApplicationService() + stats = service.get_dashboard_stats() + + # Update stats cards + self.total_apps_card.update_value(str(stats["total_applications"])) + + # Find status counts + interview_count = 0 + for status_count in stats["applications_by_status"]: + if status_count["status"] == "APPLIED": + self.applied_apps_card.update_value(str(status_count["count"])) + elif status_count["status"] in ["INTERVIEW", "PHONE_SCREEN", "TECHNICAL_INTERVIEW"]: + interview_count += status_count["count"] + elif status_count["status"] == "OFFER": + self.offer_apps_card.update_value(str(status_count["count"])) + + self.interview_apps_card.update_value(str(interview_count)) + + # Update recent applications list + self.recent_apps_list.update_applications(stats["recent_applications"]) + + self.main_window.show_status_message("Dashboard updated") + except Exception as e: + logger.error(f"Error updating dashboard: {e}", exc_info=True) + self.main_window.show_status_message(f"Error: {str(e)}") + + @pyqtSlot() + def on_new_application(self) -> None: + """Open the new application form.""" + dialog = ApplicationForm(self) + if dialog.exec(): + self.refresh_data() + + @pyqtSlot() + def on_view_all_applications(self) -> None: + """Switch to applications tab.""" + self.main_window.tabs.setCurrentIndex(1) # Switch to Applications tab diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a127a16 --- /dev/null +++ b/src/main.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +"""Main entry point for the JobTrackr application.""" + +import sys + +from PyQt6.QtWidgets import QApplication + +from src.db.manager import check_and_run_migrations +from src.gui.main_window import MainWindow +from src.utils.logging import get_logger + +logger = get_logger(__name__) + + +def main() -> None: + """Run the GUI application.""" + try: + # Check and run migrations if needed + if not check_and_run_migrations(): + logger.error("Database migration failed or was rejected. Exiting...") + sys.exit(1) + + # Create Qt application + app = QApplication(sys.argv) + app.setApplicationName("JobTrackr") + app.setStyle("Fusion") # Consistent look across platforms + + # Create and show the main window + window = MainWindow() + window.show() + + # Run the application + sys.exit(app.exec()) + + except Exception as e: + logger.critical(f"Application crashed: {e}", exc_info=True) + raise + + +if __name__ == "__main__": + main() diff --git a/src/services/application_service.py b/src/services/application_service.py index 5ab33b7..c3f5a48 100644 --- a/src/services/application_service.py +++ b/src/services/application_service.py @@ -2,14 +2,14 @@ from datetime import datetime from typing import Any -from sqlalchemy import desc, func, or_ +from sqlalchemy import desc, func, or_, update from sqlalchemy.orm import Session, joinedload from src.config import ApplicationStatus, ChangeType -from src.db.database import get_session from src.db.models import Application, Company from src.services.base_service import BaseService from src.services.change_record_service import ChangeRecordService +from src.utils.decorators import db_operation logger = logging.getLogger(__name__) @@ -31,7 +31,8 @@ def _create_entity_from_dict(self, data: dict[str, Any], session: Session) -> Ap job_title=data["job_title"], position=data["position"], location=data.get("location"), - salary=data.get("salary"), + salary_min=data.get("salary_min"), + salary_max=data.get("salary_max"), status=data["status"], applied_date=applied_date, link=data.get("link"), @@ -95,7 +96,8 @@ def _entity_to_dict(self, app: Application, include_details: bool = True) -> dic result.update( { "location": app.location, - "salary": app.salary, + "salary_min": app.salary_min, + "salary_max": app.salary_max, "link": app.link, "description": app.description, "notes": app.notes, @@ -105,9 +107,9 @@ def _entity_to_dict(self, app: Application, include_details: bool = True) -> dic return result - def get_applications(self, status: str | None = None, **kwargs) -> list[dict[str, Any]]: + @db_operation + def get_applications(self, session: Session, status: str | None = None, **kwargs) -> list[dict[str, Any]]: """Get applications with optional filtering and sorting.""" - session = get_session() try: query = session.query(Application).options(joinedload(Application.company)) @@ -131,8 +133,11 @@ def get_applications(self, status: str | None = None, **kwargs) -> list[dict[str # Apply pagination offset = kwargs.get("offset", 0) - limit = kwargs.get("limit", 10) - applications = query.offset(offset).limit(limit).all() + limit = kwargs.get("limit", None) + if limit is not None: + applications = query.offset(offset).limit(limit).all() + else: + applications = query.offset(offset).all() # Convert to dictionaries return [self._entity_to_dict(app, include_details=False) for app in applications] @@ -140,39 +145,34 @@ def get_applications(self, status: str | None = None, **kwargs) -> list[dict[str except Exception as e: logger.error(f"Error fetching applications: {e}") raise - finally: - session.close() - def search_applications(self, search_term: str) -> list[dict[str, Any]]: + @db_operation + def search_applications(self, search_term: str, session: Session) -> list[dict[str, Any]]: """Search for applications by keyword.""" - session = get_session() - try: - search_pattern = f"%{search_term}%" + search_pattern = f"%{search_term}%" - # Create base query - query = session.query(Application).join(Company, isouter=True) + # Create base query + query = session.query(Application).join(Company, isouter=True) - # Add search conditions - search_fields = [ - Application.job_title, - Application.position, - Application.description, - Application.notes, - Application.location, - Company.name, - ] + # Add search conditions + search_fields = [ + Application.job_title, + Application.position, + Application.description, + Application.notes, + Application.location, + Company.name, + ] - conditions = [field.ilike(search_pattern) for field in search_fields] - query = query.filter(or_(*conditions)).order_by(Application.applied_date.desc()) + conditions = [field.ilike(search_pattern) for field in search_fields] + query = query.filter(or_(*conditions)).order_by(Application.applied_date.desc()) - applications = query.all() - return [self._entity_to_dict(app, include_details=False) for app in applications] - finally: - session.close() + applications = query.all() + return [self._entity_to_dict(app, include_details=False) for app in applications] - def get_applications_by_company(self, company_id: int) -> list[dict[str, Any]]: + @db_operation + def get_applications_by_company(self, company_id: int, session: Session) -> list[dict[str, Any]]: """Get applications for a specific company.""" - session = get_session() try: query = session.query(Application).filter(Application.company_id == company_id) applications = query.order_by(Application.applied_date.desc()).all() @@ -180,14 +180,12 @@ def get_applications_by_company(self, company_id: int) -> list[dict[str, Any]]: except Exception as e: logger.error(f"Error fetching applications for company {company_id}: {e}") raise - finally: - session.close() + @db_operation def get_applications_for_export( - self, include_notes=True, include_interactions=True, include_reminders=True + self, session: Session, include_notes=True, include_interactions=True ) -> list[dict[str, Any]]: """Get all applications with optional details for export.""" - session = get_session() try: # Base query for applications with company info query = ( @@ -208,7 +206,7 @@ def get_applications_for_export( "company_website": app.company.website if app.company else "", "position": app.position, "location": app.location or "", - "salary": app.salary or "", + "salary": app.salary_min or "" if app.salary_min else app.salary_max or "", "status": app.status, "applied_date": app.applied_date.isoformat(), "link": app.link or "", @@ -239,12 +237,10 @@ def get_applications_for_export( except Exception as e: logger.error(f"Error getting applications for export: {e}") raise - finally: - session.close() - def get_dashboard_stats(self) -> dict[str, Any]: + @db_operation + def get_dashboard_stats(self, session: Session) -> dict[str, Any]: """Get dashboard statistics.""" - session = get_session() try: # Get total applications count total_count = session.query(func.count(Application.id)).scalar() or 0 @@ -269,8 +265,6 @@ def get_dashboard_stats(self) -> dict[str, Any]: except Exception as e: logger.error(f"Error fetching dashboard stats: {e}") raise - finally: - session.close() def add_interaction(self, data: dict[str, Any]) -> dict[str, Any]: """Add an interaction to an application.""" @@ -278,3 +272,26 @@ def add_interaction(self, data: dict[str, Any]) -> dict[str, Any]: interaction_service = InteractionService() return interaction_service.create(data) + + @db_operation + def update_status(self, application_id, new_status, session): + """ + Update the status of an application directly using its ID. + This avoids the entity refresh issue in the base update method. + + Args: + application_id: The ID of the application to update + new_status: The new status to set + """ + try: + # Używamy bezpośredniego zapytania UPDATE, które nie wymaga odświeżania encji + update_stmt = update(Application).where(Application.id == application_id).values(status=new_status) + session.execute(update_stmt) + session.commit() + logger.info(f"Updated application status for ID {application_id} to {new_status}") + except Exception as e: + session.rollback() + logger.error(f"Error updating application status for ID {application_id}: {e}", exc_info=True) + raise + finally: + session.close() diff --git a/src/services/base_service.py b/src/services/base_service.py index e8f8d84..0d3fc20 100644 --- a/src/services/base_service.py +++ b/src/services/base_service.py @@ -1,35 +1,38 @@ -from typing import Any +from typing import Any, Generic, TypeVar from sqlalchemy import desc from sqlalchemy.orm import Session -from src.db.database import Base as ModelBase -from src.db.database import get_session +from src.db.models import Base +from src.utils.decorators import db_operation from src.utils.logging import get_logger +# Define type for models +ModelType = TypeVar("ModelType", bound=Base) + # Get module logger logger = get_logger(__name__) -class BaseService: +class BaseService(Generic[ModelType]): """Base service class for common database operations.""" # The model this service manages (overridden in subclasses) - model_class: type[ModelBase] = None + model_class: type[ModelType] | None = None # Name of this entity type (for error messages) entity_name: str = "record" - def __init__(self): + def __init__(self) -> None: """Initialize the service with a logger specific to the subclass.""" self.logger = get_logger(self.__class__.__name__) - def get(self, _id: int) -> dict[str, Any] | None: + @db_operation + def get(self, _id: int, session: Session) -> dict[str, Any] | None: """Get a specific entity by ID.""" if not self.model_class: raise NotImplementedError("model_class must be defined in the subclass") - session = get_session() try: self.logger.debug(f"Fetching {self.entity_name} with ID {_id}") entity = session.query(self.model_class).filter(self.model_class.id == _id).first() @@ -42,21 +45,19 @@ def get(self, _id: int) -> dict[str, Any] | None: except Exception as e: self.logger.error(f"Error fetching {self.entity_name} {_id}: {e}", exc_info=True) raise - finally: - session.close() - def get_all(self, **kwargs) -> list[dict[str, Any]]: + @db_operation + def get_all(self, session: Session, **kwargs: Any) -> list[dict[str, Any]]: """Get all entities with optional filtering.""" if not self.model_class: raise NotImplementedError("model_class must be defined in the subclass") - session = get_session() try: # Build query with filtering sort_by = kwargs.get("sort_by") sort_desc = kwargs.get("sort_desc", False) offset = kwargs.get("offset", 0) - limit = kwargs.get("limit") + limit = kwargs.get("limit", None) self.logger.debug( f"Getting {self.entity_name}s with sort_by={sort_by}, " @@ -75,26 +76,24 @@ def get_all(self, **kwargs) -> list[dict[str, Any]]: # Apply offset/limit if provided if offset: query = query.offset(offset) - if limit: + if limit is not None: query = query.limit(limit) entities = query.all() self.logger.debug(f"Found {len(entities)} {self.entity_name}(s)") - return [self._entity_to_dict(entity, include_details=False) for entity in entities] + return [self._entity_to_dict(entity, include_details=True) for entity in entities] except Exception as e: self.logger.error(f"Error fetching {self.entity_name}s: {e}", exc_info=True) raise - finally: - session.close() - def create(self, data: dict[str, Any]) -> dict[str, Any]: + @db_operation + def create(self, data: dict[str, Any], session: Session) -> dict[str, Any]: """Create a new entity.""" if not self.model_class: raise NotImplementedError("model_class must be defined in the subclass") - session = get_session() try: self.logger.info(f"Creating new {self.entity_name}") self.logger.debug(f"Creation data: {data}") @@ -111,18 +110,15 @@ def create(self, data: dict[str, Any]) -> dict[str, Any]: return self._entity_to_dict(entity) except Exception as e: - session.rollback() self.logger.error(f"Error creating {self.entity_name}: {e}", exc_info=True) raise - finally: - session.close() - def update(self, _id: int, data: dict[str, Any]) -> dict[str, Any]: + @db_operation + def update(self, _id: int, data: dict[str, Any], session: Session) -> dict[str, Any]: """Update an existing entity.""" if not self.model_class: raise NotImplementedError("model_class must be defined in the subclass") - session = get_session() try: # Get entity self.logger.info(f"Updating {self.entity_name} with ID {_id}") @@ -144,18 +140,15 @@ def update(self, _id: int, data: dict[str, Any]) -> dict[str, Any]: self.logger.info(f"{self.entity_name.capitalize()} {_id} updated successfully") return self._entity_to_dict(entity) except Exception as e: - session.rollback() self.logger.error(f"Error updating {self.entity_name} {_id}: {e}", exc_info=True) raise - finally: - session.close() - def delete(self, _id: int) -> bool: + @db_operation + def delete(self, _id: int, session: Session) -> bool: """Delete an entity.""" if not self.model_class: raise NotImplementedError("model_class must be defined in the subclass") - session = get_session() try: self.logger.info(f"Deleting {self.entity_name} with ID {_id}") @@ -171,20 +164,17 @@ def delete(self, _id: int) -> bool: self.logger.info(f"{self.entity_name.capitalize()} {_id} deleted successfully") return True except Exception as e: - session.rollback() self.logger.error(f"Error deleting {self.entity_name} {_id}: {e}", exc_info=True) raise - finally: - session.close() - def _create_entity_from_dict(self, data: dict[str, Any], session: Session) -> ModelBase: + def _create_entity_from_dict(self, data: dict[str, Any], session: Session) -> ModelType: """Create an entity from a dictionary of attributes.""" raise NotImplementedError("Subclasses must implement _create_entity_from_dict") - def _update_entity_from_dict(self, entity: ModelBase, data: dict[str, Any], session: Session) -> None: + def _update_entity_from_dict(self, entity: ModelType, data: dict[str, Any], session: Session) -> None: """Update an entity from a dictionary of attributes.""" raise NotImplementedError("Subclasses must implement _update_entity_from_dict") - def _entity_to_dict(self, entity: ModelBase, include_details: bool = True) -> dict[str, Any]: + def _entity_to_dict(self, entity: ModelType, include_details: bool = True) -> dict[str, Any]: """Convert an entity object to a dictionary.""" raise NotImplementedError("Subclasses must implement _entity_to_dict") diff --git a/src/services/change_record_service.py b/src/services/change_record_service.py index 8ebed76..5a12cf4 100644 --- a/src/services/change_record_service.py +++ b/src/services/change_record_service.py @@ -4,9 +4,9 @@ from sqlalchemy import desc from sqlalchemy.orm import Session -from src.db.database import get_session from src.db.models import ChangeRecord from src.services.base_service import BaseService +from src.utils.decorators import db_operation logger = logging.getLogger(__name__) @@ -43,27 +43,24 @@ def _entity_to_dict(self, record: ChangeRecord, include_details: bool = True) -> return { "id": record.id, "application_id": record.application_id, - "timestamp": record.timestamp.isoformat(), + "created_at": record.created_at.isoformat(), "change_type": record.change_type, "old_value": record.old_value, "new_value": record.new_value, "notes": record.notes, } - def get_change_records(self, application_id: int) -> list[dict[str, Any]]: + @db_operation + def get_change_records(self, application_id: int, session: Session) -> list[dict[str, Any]]: """Get change records for an application.""" - session = get_session() try: records = ( session.query(ChangeRecord) .filter(ChangeRecord.application_id == application_id) - .order_by(desc(ChangeRecord.timestamp)) + .order_by(desc(ChangeRecord.created_at)) .all() ) - - return [self._entity_to_dict(record) for record in records] + return [record.to_dict() for record in records] except Exception as e: - logger.error(f"Error fetching change records: {e}") + logger.error(f"Error fetching change records: {e}", exc_info=True) raise - finally: - session.close() diff --git a/src/services/company_service.py b/src/services/company_service.py index 939b063..135c21a 100644 --- a/src/services/company_service.py +++ b/src/services/company_service.py @@ -4,9 +4,9 @@ from sqlalchemy.orm import Session from src.config import CompanyType -from src.db.database import get_session from src.db.models import Company, CompanyRelationship from src.services.base_service import BaseService +from src.utils.decorators import db_operation logger = logging.getLogger(__name__) @@ -67,11 +67,11 @@ def get_company_types(self) -> list[str]: """Get all available company types.""" return [ct.value for ct in CompanyType] + @db_operation def create_relationship( - self, source_id: int, target_id: int, relationship_type: str, notes: str = None + self, source_id: int, target_id: int, relationship_type: str, session: Session, notes: str | None = None ) -> dict[str, Any]: """Create a new relationship between companies.""" - session = get_session() try: # Verify both companies exist source = session.query(Company).filter(Company.id == source_id).first() @@ -82,7 +82,7 @@ def create_relationship( relationship = CompanyRelationship( source_company_id=source_id, - target_company_id=target_id, + related_company_id=target_id, relationship_type=relationship_type, notes=notes, ) @@ -94,7 +94,7 @@ def create_relationship( return { "id": relationship.id, "source_company_id": relationship.source_company_id, - "target_company_id": relationship.target_company_id, + "related_company_id": relationship.related_company_id, "relationship_type": relationship.relationship_type, "notes": relationship.notes, } @@ -102,12 +102,56 @@ def create_relationship( session.rollback() logger.error(f"Error creating company relationship: {e}") raise - finally: - session.close() - def get_related_companies(self, company_id: int) -> list[dict[str, Any]]: + @db_operation + def update_relationship(self, relationship_id: int, data: dict[str, Any], session: Session) -> dict[str, Any]: + """Update an existing company relationship.""" + try: + relationship = session.query(CompanyRelationship).filter(CompanyRelationship.id == relationship_id).first() + + if not relationship: + raise ValueError(f"Relationship {relationship_id} not found") + + # Update fields + if "relationship_type" in data: + relationship.relationship_type = data["relationship_type"] + if "notes" in data: + relationship.notes = data["notes"] + + session.commit() + session.refresh(relationship) + + return { + "id": relationship.id, + "source_company_id": relationship.source_company_id, + "target_company_id": relationship.target_company_id, + "relationship_type": relationship.relationship_type, + "notes": relationship.notes, + } + except Exception as e: + session.rollback() + logger.error(f"Error updating relationship: {e}") + raise + + @db_operation + def delete_relationship(self, relationship_id: int, session: Session) -> None: + """Delete a company relationship.""" + try: + relationship = session.query(CompanyRelationship).filter(CompanyRelationship.id == relationship_id).first() + + if not relationship: + raise ValueError(f"Relationship {relationship_id} not found") + + session.delete(relationship) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Error deleting relationship {relationship_id}: {e}") + raise + + @db_operation + def get_related_companies(self, company_id: int, session: Session) -> list[dict[str, Any]]: """Get companies related to the given company.""" - session = get_session() try: # Get outgoing relationships outgoing = ( @@ -116,14 +160,14 @@ def get_related_companies(self, company_id: int) -> list[dict[str, Any]]: # Get incoming relationships incoming = ( - session.query(CompanyRelationship).filter(CompanyRelationship.target_company_id == company_id).all() + session.query(CompanyRelationship).filter(CompanyRelationship.related_company_id == company_id).all() ) results = [] # Process outgoing relationships for rel in outgoing: - target = session.query(Company).filter(Company.id == rel.target_company_id).first() + target = session.query(Company).filter(Company.id == rel.related_company_id).first() if target: results.append( { @@ -157,12 +201,10 @@ def get_related_companies(self, company_id: int) -> list[dict[str, Any]]: except Exception as e: logger.error(f"Error getting related companies: {e}") raise - finally: - session.close() - def get_relationship(self, relationship_id: int) -> dict[str, Any] | None: + @db_operation + def get_relationship(self, relationship_id: int, session: Session) -> dict[str, Any] | None: """Get a specific relationship by ID.""" - session = get_session() try: relationship = session.query(CompanyRelationship).filter(CompanyRelationship.id == relationship_id).first() @@ -172,12 +214,10 @@ def get_relationship(self, relationship_id: int) -> dict[str, Any] | None: return { "id": relationship.id, "source_id": relationship.source_company_id, - "target_id": relationship.target_company_id, + "target_id": relationship.related_company_id, "relationship_type": relationship.relationship_type, "notes": relationship.notes, } except Exception as e: logger.error(f"Error getting relationship {relationship_id}: {e}") raise - finally: - session.close() diff --git a/src/services/contact_service.py b/src/services/contact_service.py index 673ad35..1797032 100644 --- a/src/services/contact_service.py +++ b/src/services/contact_service.py @@ -2,13 +2,13 @@ from typing import Any from sqlalchemy import or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from src.config import ChangeType -from src.db.database import get_session from src.db.models import Application, Company, Contact from src.services.base_service import BaseService from src.services.change_record_service import ChangeRecordService +from src.utils.decorators import db_operation logger = logging.getLogger(__name__) @@ -77,9 +77,9 @@ def _entity_to_dict(self, contact: Contact, include_details: bool = True) -> dic return result - def get_contacts(self, company_id: int | None = None, **kwargs) -> list[dict[str, Any]]: + @db_operation + def get_contacts(self, session: Session, company_id: int | None = None, **kwargs: Any) -> list[dict[str, Any]]: """Get contacts with optional filtering by company.""" - session = get_session() try: query = session.query(Contact) @@ -96,12 +96,10 @@ def get_contacts(self, company_id: int | None = None, **kwargs) -> list[dict[str except Exception as e: logger.error(f"Error fetching contacts: {e}") raise - finally: - session.close() - def search_contacts(self, search_term: str) -> list[dict[str, Any]]: + @db_operation + def search_contacts(self, search_term: str, session: Session) -> list[dict[str, Any]]: """Search for contacts by name, email, or title.""" - session = get_session() try: search_pattern = f"%{search_term}%" query = session.query(Contact).join(Company, isouter=True) @@ -120,12 +118,10 @@ def search_contacts(self, search_term: str) -> list[dict[str, Any]]: except Exception as e: logger.error(f"Error searching contacts: {e}") raise - finally: - session.close() - def add_contact_to_application(self, application_id: int, contact_id: int) -> bool: + @db_operation + def add_contact_to_application(self, application_id: int, contact_id: int, session: Session) -> bool: """Associate a contact with an application.""" - session = get_session() try: application = session.query(Application).filter(Application.id == application_id).first() contact = session.query(Contact).filter(Contact.id == contact_id).first() @@ -133,25 +129,175 @@ def add_contact_to_application(self, application_id: int, contact_id: int) -> bo if not application or not contact: return False - if contact not in application.contacts: - application.contacts.append(contact) - session.commit() - - # Record the change - change_record_service = ChangeRecordService() - change_record_service.create( - { - "application_id": application_id, - "change_type": ChangeType.CONTACT_ADDED.value, - "new_value": contact.name, - "notes": f"Added contact: {contact.name}", - } + # Check if the association already exists + if contact in application.contacts: + logger.debug( + f"Association already exists between contact {contact_id} and application {application_id}" ) + return True + + # Create the association + application.contacts.append(contact) + session.commit() + + # Record the change + change_record_service = ChangeRecordService() + change_record_service.create( + { + "application_id": application_id, + "change_type": ChangeType.CONTACT_ADDED.value, + "new_value": contact.name, + "notes": f"Added contact: {contact.name}", + } + ) return True except Exception as e: session.rollback() logger.error(f"Error adding contact to application: {e}") raise - finally: - session.close() + + @db_operation + def get_contacts_for_application(self, application_id: int, session: Session) -> list[dict[str, Any]]: + """Get contacts associated with an application.""" + try: + application = session.query(Application).filter(Application.id == application_id).first() + + if not application: + return [] + + return [self._entity_to_dict(contact) for contact in application.contacts] + except Exception as e: + logger.error(f"Error getting contacts for application {application_id}: {e}") + raise + + @db_operation + def remove_contact_from_application(self, application_id: int, contact_id: int, session: Session) -> bool: + """Remove a contact from an application.""" + try: + application = session.query(Application).filter(Application.id == application_id).first() + contact = session.query(Contact).filter(Contact.id == contact_id).first() + + if not application or not contact or contact not in application.contacts: + logger.debug(f"No association found between contact {contact_id} and application {application_id}") + return False + + # Remove the association + application.contacts.remove(contact) + session.commit() + + # Record the change + change_record_service = ChangeRecordService() + change_record_service.create( + { + "application_id": application_id, + "change_type": ChangeType.CONTACT_REMOVED.value, + "old_value": contact.name, + "notes": f"Removed contact: {contact.name}", + } + ) + + return True + except Exception as e: + session.rollback() + logger.error(f"Error removing contact from application: {e}") + raise + + @db_operation + def get_associated_applications(self, contact_id: int, session: Session) -> list[dict[str, Any]]: + """Get all applications associated with a contact.""" + try: + logger.debug(f"Getting applications for contact {contact_id}") + + # Get contact with applications + contact = ( + session.query(Contact) + .options(joinedload(Contact.applications).joinedload(Application.company)) + .filter(Contact.id == contact_id) + .first() + ) + + if not contact or not contact.applications: + logger.debug(f"No applications found for contact {contact_id}") + return [] + + # Convert to dictionaries + result = [] + for app in contact.applications: + app_dict = app.to_dict() + + # Add company info if available + if app.company: + app_dict["company"] = app.company.to_dict() + + result.append(app_dict) + + logger.debug(f"Found {len(result)} applications for contact {contact_id}") + return result + + except Exception as e: + logger.error(f"Error getting associated applications for contact {contact_id}: {e}", exc_info=True) + raise + + @db_operation + def associate_with_application(self, contact_id: int, application_id: int, session: Session) -> bool: + """Associate a contact with an application.""" + try: + logger.debug(f"Associating contact {contact_id} with application {application_id}") + + # Check if the contact exists + contact = session.query(Contact).filter(Contact.id == contact_id).first() + if not contact: + logger.error(f"Contact {contact_id} not found") + return False + + # Check if the application exists + application = session.query(Application).filter(Application.id == application_id).first() + if not application: + logger.error(f"Application {application_id} not found") + return False + + # Check if the association already exists + if contact in application.contacts: + logger.debug( + f"Association already exists between contact {contact_id} and application {application_id}" + ) + return True + + # Create the association + application.contacts.append(contact) + session.commit() + + logger.debug(f"Created association between contact {contact_id} and application {application_id}") + return True + + except Exception as e: + session.rollback() + logger.error(f"Error associating contact with application: {e}", exc_info=True) + raise + + @db_operation + def disassociate_from_application(self, contact_id: int, application_id: int, session: Session) -> bool: + """Remove association between a contact and an application.""" + try: + logger.debug(f"Removing association between contact {contact_id} and application {application_id}") + + # Get the application and contact + application = session.query(Application).filter(Application.id == application_id).first() + contact = session.query(Contact).filter(Contact.id == contact_id).first() + + if not application or not contact or contact not in application.contacts: + logger.debug(f"No association found between contact {contact_id} and application {application_id}") + return False + + # Delete the association + application.contacts.remove(contact) + session.commit() + + logger.debug(f"Removed association between contact {contact_id} and application {application_id}") + return True + + except Exception as e: + session.rollback() + logger.error(f"Error removing association between contact and application: {e}", exc_info=True) + raise diff --git a/src/services/interaction_service.py b/src/services/interaction_service.py index e41ba58..299535b 100644 --- a/src/services/interaction_service.py +++ b/src/services/interaction_service.py @@ -1,17 +1,15 @@ -import logging from datetime import datetime from typing import Any from sqlalchemy import desc -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload -from src.config import ChangeType -from src.db.database import get_session -from src.db.models import Application, Interaction +from src.db.models import Interaction from src.services.base_service import BaseService -from src.services.change_record_service import ChangeRecordService +from src.utils.decorators import db_operation +from src.utils.logging import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class InteractionService(BaseService): @@ -22,133 +20,180 @@ class InteractionService(BaseService): def _create_entity_from_dict(self, data: dict[str, Any], session: Session) -> Interaction: """Create an Interaction object from a dictionary.""" - # Ensure application exists - application = session.query(Application).filter(Application.id == data["application_id"]).first() - if not application: - raise ValueError(f"Application with ID {data['application_id']} not found") - - # Handle date format - date = data["date"] + # Parse the date string to a datetime object + date = data.get("date") if isinstance(date, str): - date = datetime.fromisoformat(date) + try: + date = datetime.fromisoformat(date.replace("Z", "+00:00")) + except ValueError: + # Try another format if ISO format fails + date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S") return Interaction( - application_id=data["application_id"], - type=data["type"], - date=date, - notes=data.get("notes"), + contact_id=data["contact_id"], + application_id=data.get("application_id"), + interaction_type=data["interaction_type"], + date=date or datetime.utcnow(), + subject=data.get("subject", ""), + notes=data.get("notes", ""), ) def _update_entity_from_dict(self, entity: Interaction, data: dict[str, Any], session: Session) -> None: """Update an Interaction object from a dictionary.""" - if "type" in data: - entity.type = data["type"] + if "interaction_type" in data: + entity.interaction_type = data["interaction_type"] + if "date" in data: - if isinstance(data["date"], str): - entity.date = datetime.fromisoformat(data["date"]) - else: - entity.date = data["date"] + date = data["date"] + if isinstance(date, str): + try: + date = datetime.fromisoformat(date.replace("Z", "+00:00")) + except ValueError: + # Try another format if ISO format fails + date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S") + entity.date = date + + if "subject" in data: + entity.subject = data["subject"] + if "notes" in data: entity.notes = data["notes"] - def create(self, data: dict[str, Any]) -> dict[str, Any]: - """Create a new interaction and record the change.""" - session = get_session() - try: - # Create using the parent method's implementation - interaction = self._create_entity_from_dict(data, session) + if "application_id" in data: + entity.application_id = data["application_id"] - # Add to session and commit - session.add(interaction) - session.commit() - session.refresh(interaction) + def _entity_to_dict(self, interaction: Interaction, include_details: bool = True) -> dict[str, Any]: + """Convert an Interaction object to a dictionary.""" + result = { + "id": interaction.id, + "contact_id": interaction.contact_id, + "interaction_type": interaction.interaction_type, + "date": interaction.date.isoformat() if interaction.date else None, + } - # Record the change - change_record_service = ChangeRecordService() - change_record_service.create( + if include_details: + result.update( { - "application_id": data["application_id"], - "change_type": ChangeType.INTERACTION_ADDED.value, - "new_value": data["type"], - "notes": f"Added {data['type']} interaction on {interaction.date.isoformat()}", + "application_id": interaction.application_id, + "subject": interaction.subject, + "notes": interaction.notes, + "created_at": interaction.created_at.isoformat() if interaction.created_at else None, + "updated_at": interaction.updated_at.isoformat() if interaction.updated_at else None, } ) - return self._entity_to_dict(interaction) - except Exception as e: - session.rollback() - logger.error(f"Error creating interaction: {e}") - raise - finally: - session.close() - - def _entity_to_dict(self, interaction: Interaction, include_details: bool = True) -> dict[str, Any]: - """Convert an Interaction to a dictionary.""" - result = { - "id": interaction.id, - "application_id": interaction.application_id, - "type": interaction.type, - "date": interaction.date.isoformat(), - "notes": interaction.notes, - } + # Include contact information + if interaction.contact: + result["contact"] = { + "id": interaction.contact.id, + "name": interaction.contact.name, + } - # Add contact information if available - if include_details and interaction.contacts: - result["contacts"] = [ - {"id": contact.id, "name": contact.name, "title": contact.title} for contact in interaction.contacts - ] + # Include application information if available + if interaction.application: + result["application"] = { + "id": interaction.application.id, + "job_title": interaction.application.job_title, + } return result - def get_interactions(self, application_id: int) -> list[dict[str, Any]]: - """Get all interactions for an application.""" - session = get_session() + @db_operation + def get_interactions_by_contact( + self, contact_id: int, session: Session, limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + """Get interactions for a specific contact.""" try: - interactions = ( + logger.debug(f"Getting interactions for contact {contact_id}") + + # Query interactions for the contact + query = ( session.query(Interaction) + .options(joinedload(Interaction.contact)) + .options(joinedload(Interaction.application)) + .filter(Interaction.contact_id == contact_id) + .order_by(desc(Interaction.date)) + ) + + # Apply pagination + interactions = query.offset(offset).limit(limit).all() + + # Convert to dictionaries + result = [self._entity_to_dict(interaction) for interaction in interactions] + + logger.debug(f"Found {len(result)} interactions for contact {contact_id}") + return result + + except Exception as e: + logger.error(f"Error getting interactions for contact {contact_id}: {e}", exc_info=True) + raise + + @db_operation + def get_interactions_by_application( + self, application_id: int, session: Session, limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + """Get interactions for a specific application.""" + try: + logger.debug(f"Getting interactions for application {application_id}") + + # Query interactions for the application + query = ( + session.query(Interaction) + .options(joinedload(Interaction.contact)) .filter(Interaction.application_id == application_id) .order_by(desc(Interaction.date)) - .all() ) - return [self._entity_to_dict(interaction) for interaction in interactions] + # Apply pagination + interactions = query.offset(offset).limit(limit).all() + + # Convert to dictionaries + result = [self._entity_to_dict(interaction) for interaction in interactions] + + logger.debug(f"Found {len(result)} interactions for application {application_id}") + return result + except Exception as e: - logger.error(f"Error fetching interactions: {e}") + logger.error(f"Error getting interactions for application {application_id}: {e}", exc_info=True) raise - finally: - session.close() - def delete(self, _id: int) -> bool: - """Delete an interaction and record the change.""" - session = get_session() + @db_operation + def delete_interaction(self, interaction_id: int, session: Session) -> bool: + """Delete an interaction.""" try: - interaction = session.query(Interaction).filter(Interaction.id == _id).first() + logger.debug(f"Deleting interaction {interaction_id}") + + # Find the interaction + interaction = session.query(Interaction).filter(Interaction.id == interaction_id).first() + if not interaction: + logger.debug(f"Interaction {interaction_id} not found") return False - application_id = interaction.application_id - interaction_type = interaction.type - interaction_date = interaction.date - # Delete the interaction session.delete(interaction) session.commit() - # Record the change - change_record_service = ChangeRecordService() - change_record_service.create( - { - "application_id": application_id, - "change_type": ChangeType.INTERACTION_ADDED.value, - "old_value": interaction_type, - "notes": f"Deleted {interaction_type} interaction from {interaction_date.isoformat()}", - } - ) - + logger.debug(f"Deleted interaction {interaction_id}") return True + except Exception as e: + logger.error(f"Error deleting interaction {interaction_id}: {e}", exc_info=True) session.rollback() - logger.error(f"Error deleting interaction {_id}: {e}") raise - finally: - session.close() + + @db_operation + def get_interactions(self, application_id: int, session: Session) -> list[dict]: + """Get all interactions for an application.""" + try: + interactions = ( + session.query(Interaction) + .options(joinedload(Interaction.contact)) + .filter(Interaction.application_id == application_id) + .order_by(Interaction.date.desc()) + .all() + ) + return [self._entity_to_dict(interaction) for interaction in interactions] + except Exception as e: + logger.error(f"Error getting interactions: {e}", exc_info=True) + return [] diff --git a/src/tui/app.py b/src/tui/app.py deleted file mode 100644 index f9a7e12..0000000 --- a/src/tui/app.py +++ /dev/null @@ -1,104 +0,0 @@ -import os - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.widgets import Footer, Header, TabbedContent, TabPane - -from src.db.database import init_db -from src.db.settings import Settings -from src.tui.tabs.applications.applications import ApplicationsList -from src.tui.tabs.companies.companies import CompaniesList -from src.tui.tabs.contacts.contacts import ContactsList -from src.tui.tabs.dashboard.dashboard import Dashboard -from src.utils.logging import get_logger - -# Set up module-level logger -logger = get_logger(__name__) - - -class JobTrackr(App): - """Main Job Tracker application.""" - - CSS_PATH = "app.tcss" - BINDINGS = [ - # Clear and simple keyboard shortcuts - Binding("d", "switch_tab('dashboard')", "Dashboard"), - Binding("a", "switch_tab('applications')", "Applications"), - Binding("c", "switch_tab('companies')", "Companies"), - Binding("t", "switch_tab('contacts')", "Contacts"), - # Action shortcuts - Binding("n", "new_application", "New"), - Binding("f", "search", "Find"), - # System shortcuts - Binding("ctrl+s", "show_settings", "Settings"), - Binding("ctrl+q", "quit", "Quit"), - ] - - def compose(self) -> ComposeResult: - """Compose the app layout.""" - yield Header(show_clock=True) - - with TabbedContent(initial="dashboard"): - with TabPane("Dashboard", id="dashboard"): - yield Dashboard() - with TabPane("Applications", id="applications"): - yield ApplicationsList() - with TabPane("Companies", id="companies"): - yield CompaniesList() - with TabPane("Contacts", id="contacts"): - yield ContactsList() - - yield Footer() - - def on_mount(self) -> None: - """Initialize the database when app starts.""" - settings = Settings() - logger.info("Application starting") - - # Check if this is first run or if database exists - if not settings.database_exists(): - # Show first run screen - logger.info("First run detected - showing setup screen") - from src.tui.tabs.settings.first_run import FirstRunScreen - - self.push_screen(FirstRunScreen()) - else: - # Initialize with existing database - db_path = settings.get_database_path() - logger.info(f"Initializing database at {db_path}") - self.sub_title = "Initializing database..." - - try: - init_db() - self.sub_title = "Ready" - logger.info("Database initialization successful") - except Exception as e: - error_msg = f"Database initialization failed: {str(e)}" - logger.error(error_msg, exc_info=True) - self.sub_title = error_msg - - # ... rest of the class unchanged ... - - def on_exception(self, exception: Exception) -> None: - """Handle uncaught exceptions in the application.""" - logger.exception(f"Uncaught exception: {exception}") - self.sub_title = f"ERROR: {str(exception)}" - - -def app() -> None: - """Run the application.""" - try: - # Ensure data directory exists - os.makedirs("data", exist_ok=True) - - # Log application start - logger.info("Starting JobTrackr application") - - # Run the app - app = JobTrackr() - app.run() - - except Exception as e: - logger.critical(f"Application crashed: {e}", exc_info=True) - # Re-raise to show the error to the user - raise diff --git a/src/tui/app.tcss b/src/tui/app.tcss deleted file mode 100644 index 0aec59a..0000000 --- a/src/tui/app.tcss +++ /dev/null @@ -1,503 +0,0 @@ -/* Main app styles */ -Screen { - background: $surface; - color: $text; -} - -/* Common container styles */ -.content-box { - height: auto; - margin: 1 1; - padding: 1 2; - background: $boost; - border: tall $primary; - border-title-color: $primary; -} - -.content-box-full { - height: 1fr; - margin: 1 1; - padding: 1 2; - background: $boost; - border: tall $primary; - border-title-color: $primary; -} - -.list-title { - text-align: center; - background: $primary; - color: $text; - text-style: bold; - padding: 1; - margin-bottom: 1; -} - -/* List view improvements */ -.list-header { - width: 100%; - height: auto; - padding: 1 1; -} - -.table-container { - width: 100%; - height: 1fr; - min-height: 20; - overflow: auto; -} - -.list-footer { - width: 100%; - height: auto; - padding: 1 1; - align: right middle; -} - -/* Filter bar styles */ -.filter-bar { - height: auto; - margin: 0 0 1 0; - align: left middle; -} - -.filter-section { - width: 30%; - height: auto; -} - -.search-section { - width: 40%; - height: auto; -} - -.action-section { - width: 30%; - height: auto; - align: right middle; -} - -/* Form styles */ -#app-form { - padding: 2 4; - height: 100%; - background: $boost; - border: tall $primary; -} - -#company-form { - padding: 2 4; - width: 60; - background: $boost; - border: tall $primary; -} - -.form-page { - margin: 1 0; - height: 1fr; -} - -.hidden { - display: none; -} - -.field-label { - padding-top: 1; - color: $text-muted; -} - -#form-title { - text-style: bold; - content-align: center middle; - width: 100%; - padding: 1; - border-bottom: solid $primary; - margin-bottom: 1; -} - -#form-actions { - margin-top: 2; - align: right middle; - height: auto; -} - -#page-indicator { - margin-bottom: 1; - width: 100%; - content-align: center middle; - color: $text-muted; -} - -#page-navigation { - margin-top: 1; - width: 100%; - align-horizontal: center; - height: auto; -} - -#required-fields-note { - margin-top: 1; - color: $warning; -} - -#company-field { - height: 3; -} - -/* File dialog styles */ -#file-dialog { - width: 70%; - height: 80%; - padding: 1; - background: $surface; - border: wide $primary; -} - -#dialog-title { - text-style: bold; - content-align: center middle; - width: 100%; - padding-bottom: 1; - border-bottom: solid $primary; - margin-bottom: 1; -} - -#file-path { - margin-bottom: 1; -} - -#file-name { - margin-bottom: 1; -} - -#dialog-buttons { - margin-top: 1; - align: right middle; -} - -/* Company relationship form */ -#relationship-form { - width: 60; - padding: 1 2; - background: $boost; - border: tall $primary; -} - -/* Modal styles */ -.modal-container { - align: center middle; - background: rgba(0, 0, 0, 0.7); -} - -.modal-content { - padding: 2 4; - background: $boost; - border: tall $primary; - min-width: 40; -} - -.modal-title { - text-style: bold; - content-align: center middle; - width: 100%; - padding-bottom: 1; - border-bottom: solid $primary; - margin-bottom: 1; -} - -.modal-actions { - margin-top: 2; - align: right middle; -} - -/* Confirmation dialog */ -#dialog { - padding: 1 2; - width: 60; - height: auto; - background: $boost; - border: tall $error; -} - -#buttons { - margin-top: 1; - align: right middle; - height: auto; -} - -.warning-text { - color: $warning; - margin-top: 1; -} - -/* Dashboard styles */ -#dashboard-container { - padding: 1; -} - -#dashboard-header { - background: $panel; - height: auto; - padding-bottom: 1; -} - -#dashboard-title { - text-style: bold; - content-align: center middle; - padding: 1; - color: $text; - text-style: bold; -} - -#quick-actions { - align: center middle; - height: auto; -} - -#stats-section { - margin-top: 1; -} - -.section-heading { - text-style: bold; - color: $text; - margin-bottom: 1; - border-bottom: solid $primary; -} - -#stats-grid { - grid-size: 4 1; - grid-gutter: 1 0; - grid-columns: 1fr 1fr 1fr 1fr; - height: auto; - margin: 0 0 1 0; -} - -.stats-card { - height: 100%; - width: 100%; - content-align: center middle; - padding: 1; - background: $boost; - border: solid $primary; -} - -.stats-title { - color: $text-muted; -} - -.stats-value { - text-style: bold; - color: $text; - padding-top: 1; -} - -#dashboard-content { - margin-top: 1; -} - -#left-column, #right-column { - width: 1fr; -} - -#activity-feed { - height: auto; -} - -#recent-apps-list { - height: 1fr; - margin-bottom: 1; -} - -/* Action buttons layout */ -.action-buttons { - height: auto; -} - -/* Table styles */ -DataTable { - height: 100%; - border: tall $primary; -} - -/* Status colors */ -.status-SAVED { - color: $text-muted; -} - -.status-APPLIED { - color: $primary; -} - -.status-PHONE_SCREEN { - color: $secondary; -} - -.status-INTERVIEW, .status-TECHNICAL_INTERVIEW { - color: $warning; -} - -.status-OFFER { - color: $success; -} - -.status-ACCEPTED { - color: $success; - text-style: bold; -} - -.status-REJECTED, .status-WITHDRAWN { - color: $error; -} - -/* Common detail view styling */ -.detail-view { - padding: 1; - height: 100%; - overflow: auto; -} - -.detail-header { - height: auto; - margin-bottom: 1; - background: $boost; - border-bottom: solid $primary; - padding-bottom: 1; -} - -.detail-title { - text-style: bold; - color: $text; -} - -.detail-subtitle { - color: $text-muted; -} - -.detail-tabs { - height: 1fr; - min-height: 20; -} - -.detail-section { - padding: 1; - margin: 1 0; - border: tall $primary; - border-title-color: $primary; -} - -.detail-table { - border: tall $primary; - height: 1fr; -} - -.info-grid { - grid-size: 2; - grid-columns: auto 1fr; - grid-gutter: 1 1; - margin: 1 0; -} - -.notes-box { - background: $panel; - border: panel $primary; - padding: 1; - min-height: 5; -} - -.quick-actions { - height: auto; - margin-top: 1; - margin-bottom: 1; -} - -.action-bar { - height: auto; - margin-top: 1; - align: right middle; -} - -/* Status indicator */ -.status-container { - background: $boost; - min-width: 15; - max-width: 20; - width: auto; - height: auto; - border: tall $primary; - content-align: center middle; -} - -.status-label { - color: $text-muted; -} - -.status-value { - text-style: bold; - padding: 1; -} - -.date-value { - color: $text-muted; - padding-bottom: 1; -} - -/* Tab content styling */ -.tab-content { - padding: 1; - height: 1fr; - overflow: auto; -} - -.tab-subtitle { - text-style: bold; - color: $text; - margin-bottom: 1; -} - -.section-label { - text-style: bold; - margin-top: 1; -} - -/* Timeline and activity styling */ -.timeline-item { - margin-bottom: 1; - padding-bottom: 1; - border-bottom: solid $primary-darken-2; -} - -.timeline-date { - color: $text-muted; -} - -.timeline-title { - text-style: bold; -} - -.timeline-details { - color: $text; -} - -/* Priority styling */ -.priority-high { - border-left: solid $error; - padding-left: 1; -} - -.priority-medium { - border-left: solid $warning; - padding-left: 1; -} - -.priority-low { - border-left: solid $primary; - padding-left: 1; -} - -.action-item { - margin-bottom: 1; -} - -.action-text { - padding-left: 1; -} \ No newline at end of file diff --git a/src/tui/search.py b/src/tui/search.py deleted file mode 100644 index 98a74f7..0000000 --- a/src/tui/search.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Search dialog for finding applications.""" - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal -from textual.screen import ModalScreen -from textual.widgets import Button, DataTable, Input, Label - -from src.services.application_service import ApplicationService - - -class SearchDialog(ModalScreen): - """Dialog for searching applications.""" - - def compose(self) -> ComposeResult: - with Container(id="search-dialog"): - yield Label("Search Applications", id="dialog-title") - - with Horizontal(id="search-form"): - yield Input(placeholder="Enter search term...", id="search-input") - yield Button("Search", variant="primary", id="perform-search") - - yield DataTable(id="search-results") - - yield Button("Close", id="close-search") - - def on_mount(self) -> None: - """Set up the results table.""" - table = self.query_one("#search-results", DataTable) - table.add_columns("ID", "Job Title", "Company", "Position", "Status") - table.cursor_type = "row" - table.zebra_stripes = True - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle Enter key in search input.""" - if event.input.id == "search-input": - self.perform_search() - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "perform-search": - self.perform_search() - - elif button_id == "close-search": - self.app.pop_screen() - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Open the selected application.""" - table = self.query_one("#search-results", DataTable) - app_id = table.get_row_at(event.row_key)[0] - - # Close search dialog - self.app.pop_screen() - - # Open application form - from src.tui.tabs.applications.application_form import ApplicationForm - - self.app.push_screen(ApplicationForm(app_id=app_id)) - - def perform_search(self) -> None: - """Search for applications matching the search term.""" - search_term = self.query_one("#search-input", Input).value - - if not search_term: - self.app.sub_title = "Please enter a search term" - return - - try: - self.app.sub_title = f"Searching for '{search_term}'..." - - service = ApplicationService() - results = service.search_applications(search_term) - - table = self.query_one("#search-results", DataTable) - table.clear() - - for app in results: - table.add_row( - str(app["id"]), - app["job_title"], - app.get("company", {}).get("name", ""), - app["position"], - app["status"], - ) - - count = len(results) - self.app.sub_title = f"Found {count} result{'s' if count != 1 else ''}" - - except Exception as e: - self.app.sub_title = f"Search error: {str(e)}" diff --git a/src/tui/tabs/applications/application_detail.py b/src/tui/tabs/applications/application_detail.py deleted file mode 100644 index 099a914..0000000 --- a/src/tui/tabs/applications/application_detail.py +++ /dev/null @@ -1,762 +0,0 @@ -"""Application detail view screen with enhanced UI and features.""" - -from collections.abc import Callable -from datetime import datetime, timedelta -from typing import Any - -from textual.app import ComposeResult -from textual.containers import ( - Container, - Grid, - Horizontal, - ScrollableContainer, - Vertical, -) -from textual.screen import Screen -from textual.widgets import Button, DataTable, Label, MarkdownViewer, Static, TabbedContent, TabPane - -from src.config import ApplicationStatus, InteractionType -from src.services.application_service import ApplicationService -from src.services.change_record_service import ChangeRecordService -from src.services.contact_service import ContactService -from src.services.interaction_service import InteractionService -from src.tui.tabs.applications.interaction_form import InteractionForm -from src.tui.widgets.confirmation_modal import ConfirmationModal - - -class ApplicationDetail(Screen): - """Detailed view of an application showing all relevant information.""" - - def __init__(self, app_id: int, on_updated: Callable | None = None): - """Initialize the application detail screen. - - Args: - app_id: The ID of the application to display - on_updated: Callback function to run when application is updated - """ - super().__init__() - self.app_id = app_id - self.application_data = None - self.contacts = [] - self.interactions = [] - self.on_updated = on_updated - - def compose(self) -> ComposeResult: - """Compose the screen layout.""" - with Container(classes="detail-view"): - with Horizontal(classes="detail-header"): - with Vertical(id="app-titles"): - yield Static("", id="app-job-title", classes="detail-title") - yield Static("", id="app-company", classes="detail-subtitle") - yield Static("", id="app-position-location", classes="detail-subtitle") - - with Vertical(id="app-status-section", classes="status-container"): - yield Static("STATUS", classes="status-label") - yield Static("", id="app-status", classes="status-value") - yield Static("", id="app-applied-date", classes="date-value") - - # Quick action button bar - with Horizontal(classes="quick-actions"): - yield Button("📝 Edit", id="edit-application", variant="default") - yield Button("📊 Status", id="change-status", variant="primary") - yield Button("💬 Add Interaction", id="add-interaction", variant="success") - yield Button("👤 Add Contact", id="add-contact", variant="warning") - - # Main content with tabs - with TabbedContent(classes="detail-tabs"): - with TabPane("Overview", id="tab-overview"): - yield from self._compose_overview() - - with TabPane("Timeline", id="tab-timeline"): - yield from self._compose_timeline() - - with TabPane("Interactions", id="tab-interactions"): - yield from self._compose_interactions() - - with TabPane("Contacts", id="tab-contacts"): - yield from self._compose_contacts() - - with TabPane("Job Details", id="tab-job-details"): - yield from self._compose_job_details() - - # Footer with actions - with Horizontal(classes="action-bar"): - yield Button("⬅️ Back", id="back-button", variant="default") - yield Button("📋 Copy Link", id="copy-link") - yield Button("🗑️ Delete", id="delete-application", variant="error") - - def _compose_overview(self) -> ComposeResult: - """Compose the overview tab layout.""" - with ScrollableContainer(classes="tab-content"): - # Key application details in a grid - with Grid(classes="info-grid"): - yield Label("Applied Date:", classes="field-label") - yield Static("", id="overview-applied-date", classes="field-value") - - yield Label("Position:", classes="field-label") - yield Static("", id="overview-position", classes="field-value") - - yield Label("Salary:", classes="field-label") - yield Static("", id="overview-salary", classes="field-value") - - yield Label("Location:", classes="field-label") - yield Static("", id="overview-location", classes="field-value") - - yield Label("Link:", classes="field-label") - yield Static("", id="overview-link", classes="field-value") - - yield Label("Last Updated:", classes="field-label") - yield Static("", id="overview-updated", classes="field-value") - - # Status history - yield Label("Status History:", classes="section-label") - yield DataTable(id="status-history-table", classes="detail-table") - - # Notes section - yield Label("Notes:", classes="section-label") - with Container(classes="notes-box"): - yield Static("", id="notes-content") - - # Upcoming actions - yield Label("Upcoming Actions:", classes="section-label") - with Container(id="upcoming-actions"): - # Will be populated dynamically - pass - - def _compose_timeline(self) -> ComposeResult: - """Compose the timeline tab layout.""" - with ScrollableContainer(classes="tab-content"): - yield DataTable(id="timeline-table", classes="detail-table") - - def _compose_interactions(self) -> ComposeResult: - """Compose the interactions tab layout.""" - with Vertical(classes="tab-content"): - with Horizontal(id="interactions-header"): - yield Label("Application Interactions", classes="tab-subtitle") - yield Button("+ New Interaction", id="new-interaction", variant="success") - - with ScrollableContainer(): - yield DataTable(id="interactions-table", classes="detail-table") - - def _compose_contacts(self) -> ComposeResult: - """Compose the contacts tab layout.""" - with Vertical(classes="tab-content"): - with Horizontal(id="contacts-header"): - yield Label("Associated Contacts", classes="tab-subtitle") - yield Button("+ Add Contact", id="new-contact", variant="success") - - with ScrollableContainer(): - yield DataTable(id="contacts-table", classes="detail-table") - - def _compose_job_details(self) -> ComposeResult: - """Compose the job details tab layout.""" - with ScrollableContainer(classes="tab-content"): - yield Label("Job Description:", classes="section-label") - yield MarkdownViewer(id="job-description-md") - - def on_mount(self) -> None: - """Set up tables and load data.""" - # Set up status history table - status_history = self.query_one("#status-history-table", DataTable) - status_history.add_columns("Date", "Status", "Notes") - status_history.zebra_stripes = True - - # Set up timeline table - timeline_table = self.query_one("#timeline-table", DataTable) - timeline_table.add_columns("Date", "Event Type", "Details") - timeline_table.cursor_type = "row" - timeline_table.zebra_stripes = True - - # Set up interactions table - interactions_table = self.query_one("#interactions-table", DataTable) - interactions_table.add_columns("ID", "Date", "Type", "Details", "Actions") - interactions_table.cursor_type = "row" - interactions_table.zebra_stripes = True - - # Set up contacts table - contacts_table = self.query_one("#contacts-table", DataTable) - contacts_table.add_columns("ID", "Name", "Title", "Email", "Phone", "Actions") - contacts_table.cursor_type = "row" - contacts_table.zebra_stripes = True - - # Load application data - self.load_application_data() - - # Set overview tab as active by default - self.query_one(TabbedContent).active = "tab-overview" - - def load_application_data(self) -> None: - """Load application data and populate the view.""" - try: - # Load basic application data - app_service = ApplicationService() - self.application_data = app_service.get(self.app_id) - - if not self.application_data: - self.app.sub_title = f"Application {self.app_id} not found" - return - - # Update header fields - self.query_one("#app-job-title", Static).renderable = self.application_data["job_title"] - - # Show position and location together for better space usage - position = self.application_data.get("position", "") - location = self.application_data.get("location", "") - if location: - position_location = f"{position} | {location}" - else: - position_location = position - self.query_one("#app-position-location", Static).renderable = position_location - - # Status with styling - status = self.application_data["status"] - status_widget = self.query_one("#app-status", Static) - status_widget.renderable = status - status_widget.classes = f"status-value status-{status}" - - # Format and display applied date - applied_date_str = self.application_data.get("applied_date") - if applied_date_str: - try: - applied_date = datetime.fromisoformat(applied_date_str) - formatted_date = applied_date.strftime("%Y-%m-%d") - self.query_one("#app-applied-date", Static).renderable = f"Applied: {formatted_date}" - self.query_one("#overview-applied-date", Static).renderable = formatted_date - except (ValueError, TypeError): - self.query_one("#app-applied-date", Static).renderable = "Invalid Date" - self.query_one("#overview-applied-date", Static).renderable = "Invalid Date" - else: - self.query_one("#app-applied-date", Static).renderable = "No date" - self.query_one("#overview-applied-date", Static).renderable = "Not specified" - - # Update company info - company_name = self.application_data.get("company", {}).get("name", "Unknown") - self.query_one("#app-company", Static).renderable = company_name - - # Update overview fields (previously missing) - self.query_one("#overview-position", Static).renderable = position or "Not specified" - self.query_one("#overview-location", Static).renderable = location or "Not specified" - self.query_one("#overview-salary", Static).renderable = self.application_data.get("salary", "Not specified") - - # Updated date - updated_date_str = self.application_data.get("updated_at") - if updated_date_str: - try: - updated_date = datetime.fromisoformat(updated_date_str) - formatted_updated = updated_date.strftime("%Y-%m-%d %H:%M") - self.query_one("#overview-updated", Static).renderable = formatted_updated - except (ValueError, TypeError): - self.query_one("#overview-updated", Static).renderable = "Unknown" - else: - self.query_one("#overview-updated", Static).renderable = "Not available" - - # Update link with proper formatting - link = self.application_data.get("link", "") - if link: - self.query_one("#overview-link", Static).renderable = link - else: - self.query_one("#overview-link", Static).renderable = "No link provided" - - # Update job description content with markdown support - description = self.application_data.get("description", "No description available.") - markdown_viewer = self.query_one("#job-description-md", MarkdownViewer) - markdown_viewer.document = description - - # Update notes content - notes_content = self.application_data.get("notes", "No notes available.") - self.query_one("#notes-content", Static).renderable = notes_content - - # Load all data for tabs - self.load_timeline() - self.load_interactions() - self.load_contacts() - self.load_status_history() - self.generate_upcoming_actions() - - self.app.sub_title = f"Application details: {self.application_data['job_title']} at {company_name}" - - except Exception as e: - self.app.sub_title = f"Error loading application details: {str(e)}" - - def load_status_history(self) -> None: - """Load status history from change records.""" - try: - change_service = ChangeRecordService() - changes = change_service.get_change_records(self.app_id) - - # Filter only status changes - status_changes = [change for change in changes if change["change_type"] == "STATUS_CHANGE"] - - # Update status history table - status_table = self.query_one("#status-history-table", DataTable) - status_table.clear() - - if not status_changes: - # If no status changes, add the current status as the initial one - applied_date_str = self.application_data.get("applied_date", "") - date_display = applied_date_str[:10] if applied_date_str else "Unknown" - - status_table.add_row( - date_display, - self.application_data.get("status", "APPLIED"), - "Initial application status" - ) - return - - # Sort by timestamp descending - for change in sorted(status_changes, key=lambda x: x["timestamp"], reverse=True): - status_table.add_row( - change["timestamp"][:10], # Just the date portion - change["new_value"], - change.get("notes", "") - ) - - except Exception as e: - self.app.sub_title = f"Error loading status history: {str(e)}" - status_table = self.query_one("#status-history-table", DataTable) - status_table.clear() - status_table.add_row("Error", f"Could not load status history: {str(e)}", "") - - def generate_upcoming_actions(self) -> None: - """Generate and display upcoming recommended actions.""" - try: - upcoming_actions = self.query_one("#upcoming-actions", Container) - upcoming_actions.remove_children() - - status = self.application_data.get("status", "") - applied_date_str = self.application_data.get("applied_date") - - if not applied_date_str: - upcoming_actions.mount(Static("No date information available")) - return - - applied_date = datetime.fromisoformat(applied_date_str) - today = datetime.now() - days_since_applied = (today - applied_date).days - - actions = [] - - # Status-based actions - if status == "APPLIED" and days_since_applied > 7: - actions.append(("Follow up on application", "high")) - elif status == "PHONE_SCREEN": - actions.append(("Prepare for phone screen", "medium")) - elif status == "INTERVIEW" or status == "TECHNICAL_INTERVIEW": - actions.append(("Prepare for interview", "high")) - actions.append(("Research the company", "medium")) - elif status == "OFFER": - actions.append(("Review offer details", "high")) - actions.append(("Prepare negotiation points", "high")) - - # Generic actions based on time - if days_since_applied > 14 and status in ["APPLIED", "PHONE_SCREEN"]: - actions.append(("Check application status", "medium")) - - # Follow-up actions based on interactions - last_interaction = None - for interaction in self.interactions: - interaction_date = datetime.fromisoformat(interaction["date"]) - if not last_interaction or interaction_date > datetime.fromisoformat(last_interaction["date"]): - last_interaction = interaction - - if last_interaction: - last_date = datetime.fromisoformat(last_interaction["date"]) - days_since_interaction = (today - last_date).days - if days_since_interaction > 7 and status in ["INTERVIEW", "TECHNICAL_INTERVIEW", "PHONE_SCREEN"]: - actions.append((f"Follow up on {last_interaction['type'].lower()}", "high")) - - # Add actions to container - if not actions: - upcoming_actions.mount(Static("No pending actions")) - return - - for action, priority in actions: - action_container = Horizontal(classes=f"action-item priority-{priority}") - action_container.mount(Static(f"• {action}", classes="action-text")) - upcoming_actions.mount(action_container) - - except Exception as e: - self.app.sub_title = f"Error generating actions: {str(e)}" - upcoming_actions = self.query_one("#upcoming-actions", Container) - upcoming_actions.remove_children() - upcoming_actions.mount(Static(f"Error: {str(e)}")) - - def load_timeline(self) -> None: - """Load and display timeline events.""" - try: - # Get change records - change_service = ChangeRecordService() - changes = change_service.get_change_records(self.app_id) - - # Get interactions for the timeline - interaction_service = InteractionService() - self.interactions = interaction_service.get_interactions(self.app_id) - - # Combine all events into a timeline - timeline_events = [] - - # Add change records - for change in changes: - timeline_events.append( - { - "date": datetime.fromisoformat(change["timestamp"]), - "type": change["change_type"], - "details": change["notes"] or self._format_change(change), - "icon": self._get_event_icon(change["change_type"]) - } - ) - - # Add interactions - for interaction in self.interactions: - timeline_events.append( - { - "date": datetime.fromisoformat(interaction["date"]), - "type": "INTERACTION", - "details": f"{interaction['type']}: {interaction['notes'][:50] + '...' if interaction['notes'] and len(interaction['notes']) > 50 else interaction['notes'] or ''}", - "icon": "💬" - } - ) - - # Add application creation as first event - creation_date_str = self.application_data.get("created_at", "") - if creation_date_str: - creation_date = datetime.fromisoformat(creation_date_str) - timeline_events.append({ - "date": creation_date, - "type": "APPLICATION_CREATED", - "details": f"Application created for {self.application_data['job_title']}", - "icon": "📝" - }) - - # Sort by date descending - timeline_events.sort(key=lambda x: x["date"], reverse=True) - - # Update timeline table - timeline_table = self.query_one("#timeline-table", DataTable) - timeline_table.clear() - - if not timeline_events: - timeline_table.add_row("No events", "", "") - return - - for event in timeline_events: - timeline_table.add_row( - event["date"].strftime("%Y-%m-%d %H:%M"), - f"{event['icon']} {event['type'].replace('_', ' ')}", - event["details"], - ) - - except Exception as e: - self.app.sub_title = f"Error loading timeline: {str(e)}" - timeline_table = self.query_one("#timeline-table", DataTable) - timeline_table.clear() - timeline_table.add_row("Error", f"Could not load timeline: {str(e)}", "") - - def _get_event_icon(self, event_type: str) -> str: - """Get an icon for a timeline event type.""" - icons = { - "STATUS_CHANGE": "🔄", - "INTERACTION_ADDED": "💬", - "CONTACT_ADDED": "👤", - "APPLICATION_UPDATED": "📝", - "NOTE_ADDED": "📝", - "DOCUMENT_ADDED": "📄" - } - return icons.get(event_type, "📌") - - def load_interactions(self) -> None: - """Load and display interactions.""" - try: - interaction_service = InteractionService() - self.interactions = interaction_service.get_interactions(self.app_id) - - interactions_table = self.query_one("#interactions-table", DataTable) - interactions_table.clear() - - if not self.interactions: - interactions_table.add_row("", "No interactions recorded", "", "", "") - return - - for interaction in self.interactions: - # Format contacts if available - contacts_str = "" - if interaction.get("contacts"): - contacts_str = ", ".join([c["name"] for c in interaction["contacts"]]) - - # Format date for better readability - date_str = datetime.fromisoformat(interaction["date"]).strftime("%Y-%m-%d") - - # Truncate notes if too long - notes = interaction["notes"] or "" - if len(notes) > 50: - notes = notes[:47] + "..." - - interactions_table.add_row( - str(interaction["id"]), # Store ID for editing/deleting - date_str, - interaction["type"], - notes, - "✏️ Edit | 🗑️ Delete", # Actions - ) - - except Exception as e: - self.app.sub_title = f"Error loading interactions: {str(e)}" - interactions_table = self.query_one("#interactions-table", DataTable) - interactions_table.clear() - interactions_table.add_row("", f"Error: {str(e)}", "", "", "") - - def load_contacts(self) -> None: - """Load and display associated contacts.""" - try: - contact_service = ContactService() - - # In a real implementation, we would have a method to get contacts for an application - # For now, we'll try to get contacts from the company as a proxy - contacts_table = self.query_one("#contacts-table", DataTable) - contacts_table.clear() - - # Get contacts from company - company_id = None - if self.application_data and self.application_data.get("company"): - company_id = self.application_data["company"].get("id") - - if company_id: - self.contacts = contact_service.get_contacts(company_id=company_id) - - if self.contacts: - for contact in self.contacts: - contacts_table.add_row( - str(contact["id"]), - contact["name"], - contact.get("title", ""), - contact.get("email", ""), - contact.get("phone", ""), - "✏️ Edit | 🗑️ Delete" # Actions - ) - return - - # If no contacts found or could not retrieve them - contacts_table.add_row( - "", - "No contacts associated", - "", - "", - "", - "" - ) - - except Exception as e: - self.app.sub_title = f"Error loading contacts: {str(e)}" - contacts_table = self.query_one("#contacts-table", DataTable) - contacts_table.clear() - contacts_table.add_row( - "", - f"Error: {str(e)}", - "", - "", - "", - "" - ) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - # Navigation buttons - if button_id == "back-button": - self.app.pop_screen() - # Call the on_updated callback if provided - if self.on_updated: - self.on_updated() - - # Action buttons - elif button_id == "edit-application": - from src.tui.tabs.applications.application_form import ApplicationForm - self.app.push_screen(ApplicationForm(app_id=self.app_id, on_saved=self.load_application_data)) - - elif button_id == "change-status": - from src.tui.tabs.applications.status_transition import StatusTransitionDialog - self.app.push_screen( - StatusTransitionDialog( - self.app_id, self.application_data["status"], on_updated=self.load_application_data - ) - ) - - elif button_id in ["add-interaction", "new-interaction"]: - self.app.push_screen(InteractionForm(application_id=self.app_id, on_saved=self.load_application_data)) - - elif button_id in ["add-contact", "new-contact"]: - self._show_contact_selection() - - elif button_id == "copy-link": - self._copy_application_link() - - elif button_id == "delete-application": - self._confirm_delete_application() - - def _copy_application_link(self) -> None: - """Copy the application link to clipboard (or show appropriate message).""" - link = self.application_data.get("link", "") - if link: - # In a TUI environment, direct clipboard access isn't possible - # Instead, show the link for manual copying - self.app.sub_title = f"Link to copy: {link}" - else: - self.app.sub_title = "No link available for this application" - - def _show_contact_selection(self) -> None: - """Show dialog to select a contact to associate with this application.""" - # This would be implemented to open a contact selector - # For now, we'll just show a more detailed message - - if not self.application_data.get("company", {}).get("id"): - self.app.sub_title = "This application must be associated with a company first" - return - - # In a complete implementation, we would: - # 1. Show a dialog with existing contacts from the company - # 2. Allow selection or creation of a new contact - # 3. Associate the selected contact with this application - - self.app.sub_title = "Contact selection feature will be implemented in a future update" - - def _confirm_delete_application(self) -> None: - """Confirm and delete the application.""" - def delete_confirmed(): - try: - service = ApplicationService() - success = service.delete(self.app_id) - if success: - self.app.sub_title = f"Application {self.app_id} deleted" - self.app.pop_screen() - if self.on_updated: - self.on_updated() - else: - self.app.sub_title = "Failed to delete application" - except Exception as e: - self.app.sub_title = f"Error deleting application: {str(e)}" - - job_title = self.application_data.get("job_title", "Unknown") - company_name = self.application_data.get("company", {}).get("name", "Unknown") - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message=( - f"Are you sure you want to delete this application?\n\n" - f"Title: {job_title}\n" - f"Company: {company_name}\n\n" - "This action cannot be undone." - ), - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=delete_confirmed, - dangerous=True - ) - ) - - def _edit_interaction(self, interaction_id: int) -> None: - """Edit an interaction.""" - self.app.push_screen( - InteractionForm( - interaction_id=interaction_id, - application_id=self.app_id, - on_saved=self.load_application_data - ) - ) - - def _delete_interaction(self, interaction_id: int) -> None: - """Delete an interaction with confirmation.""" - def confirm_delete(): - try: - service = InteractionService() - success = service.delete(interaction_id) - if success: - self.app.sub_title = "Interaction deleted successfully" - self.load_application_data() # Refresh all data - else: - self.app.sub_title = "Failed to delete interaction" - except Exception as e: - self.app.sub_title = f"Error deleting interaction: {str(e)}" - - # Find interaction details for confirmation message - interaction_details = "this interaction" - for interaction in self.interactions: - if interaction["id"] == interaction_id: - interaction_details = f"{interaction['type']} on {interaction['date'][:10]}" - break - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message=f"Are you sure you want to delete {interaction_details}?", - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=confirm_delete, - dangerous=True - ) - ) - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle row selection in data tables.""" - table_id = event.data_table.id - row = event.data_table.get_row(event.row_key) - - if table_id == "interactions-table": - # Skip if this is the "No interactions" placeholder row - if not row[0] or row[1] == "No interactions recorded" or row[1].startswith("Error:"): - return - - # Get the column with actions (last column) - actions_col = len(row) - 1 - - # Show a menu of actions - def handle_edit(): - self._edit_interaction(int(row[0])) - - def handle_delete(): - self._delete_interaction(int(row[0])) - - self.app.push_screen( - ConfirmationModal( - title=f"Interaction: {row[2]}", - message=f"Date: {row[1]}\nDetails: {row[3]}", - confirm_text="Edit", - cancel_text="Delete", - on_confirm=handle_edit, - on_cancel=handle_delete - ) - ) - - elif table_id == "contacts-table": - # Skip placeholder rows - if not row[0] or row[1] == "No contacts associated" or row[1].startswith("Error:"): - return - - # In a complete implementation, we could: - # 1. Show contact details - # 2. Provide options to edit/remove the contact association - - self.app.sub_title = f"Selected contact: {row[1]}" - - elif table_id == "timeline-table": - # Nothing to do for timeline events currently - pass - - def _format_change(self, change: dict[str, Any]) -> str: - """Format change record details for display.""" - if change["change_type"] == "STATUS_CHANGE": - return f"Status changed from {change.get('old_value', 'unknown')} to {change.get('new_value', 'unknown')}" - elif change["change_type"] == "APPLICATION_UPDATED": - return "Application details were updated" - elif change["change_type"] == "CONTACT_ADDED": - return f"Contact {change.get('new_value', '')} was added" - elif change["change_type"] == "INTERACTION_ADDED": - return f"Added {change.get('new_value', '')} interaction" - else: - if change.get("old_value") and change.get("new_value"): - return f"Changed from {change['old_value']} to {change['new_value']}" - elif change.get("new_value"): - return f"Set to {change['new_value']}" - else: - return "Change recorded" diff --git a/src/tui/tabs/applications/application_form.py b/src/tui/tabs/applications/application_form.py deleted file mode 100644 index ee8b081..0000000 --- a/src/tui/tabs/applications/application_form.py +++ /dev/null @@ -1,292 +0,0 @@ -from collections.abc import Callable -from datetime import datetime - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, Input, Label, Select, Static, TextArea - -from src.config import ApplicationStatus -from src.services.application_service import ApplicationService -from src.services.company_service import CompanyService - - -class ApplicationForm(Screen): - """Form for creating or editing a job application with multiple pages.""" - - def __init__(self, app_id=None, readonly=False, on_saved: Callable | None = None): - """Initialize the application form. - - Args: - app_id: ID of the application to edit (None for new application) - readonly: Whether the form should be read-only - on_saved: Callback function to run when the application is saved - """ - super().__init__() - self.app_id = app_id - self.readonly = readonly - self.on_saved = on_saved - self.companies = [] - self.current_page = 1 - self.total_pages = 3 - self.application_data = {} - - def compose(self) -> ComposeResult: - """Compose the form layout.""" - with Container(id="app-form"): - yield Label( - "View Application" if self.readonly else "Edit Application" if self.app_id else "New Application", - id="form-title", - ) - - # Page indicator - yield Static(f"Page {self.current_page} of {self.total_pages}", id="page-indicator") - - # Page 1: Essential Information - with Container(id="page-1", classes="form-page"): - with Vertical(id="essential-fields"): - yield Label("Job Title *", classes="field-label") - yield Input(id="job-title", disabled=self.readonly) - - yield Label("Company *", classes="field-label") - with Horizontal(id="company-field"): - yield Select([], id="company-select", disabled=self.readonly) - if not self.readonly: - yield Button("+ New Company", id="new-company", variant="primary") - - yield Label("Position *", classes="field-label") - yield Input(id="position", disabled=self.readonly) - - yield Label("Status *", classes="field-label") - yield Select( - [(status.value, status.value) for status in ApplicationStatus], - id="status", - disabled=self.readonly, - ) - - yield Label("Applied Date *", classes="field-label") - yield Input( - id="applied-date", - placeholder="YYYY-MM-DD", - disabled=self.readonly, - value=datetime.now().strftime("%Y-%m-%d") if not self.app_id and not self.readonly else "", - ) - - # Page 2: Additional Details - with Container(id="page-2", classes="form-page hidden"): - with Vertical(id="details-fields"): - yield Label("Location", classes="field-label") - yield Input( - id="location", - placeholder="City, State or Remote", - disabled=self.readonly, - ) - - yield Label("Salary", classes="field-label") - yield Input( - id="salary", - placeholder="e.g. $100,000/year", - disabled=self.readonly, - ) - - yield Label("Link", classes="field-label") - yield Input(id="link", placeholder="https://...", disabled=self.readonly) - - # Page 3: Content - with Container(id="page-3", classes="form-page hidden"): - with Vertical(id="content-fields"): - yield Label("Job Description", classes="field-label") - yield TextArea(id="description", disabled=self.readonly) - - yield Label("Notes", classes="field-label") - yield TextArea(id="notes", disabled=self.readonly) - - yield Label("* Required fields", id="required-fields-note") - - # Page navigation buttons - with Horizontal(id="page-navigation"): - yield Button("← Previous", id="prev-page", disabled=True) - yield Button("Next →", id="next-page") - - with Horizontal(id="form-actions"): - if not self.readonly: - yield Button("Save", variant="primary", id="save-app") - yield Button("Close", id="close-form") - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "new-company": - from src.tui.tabs.companies.company_form import CompanyForm - - self.app.push_screen(CompanyForm(on_saved=self.load_companies)) - - elif button_id == "next-page": - self.navigate_to_page(self.current_page + 1) - - elif button_id == "prev-page": - self.navigate_to_page(self.current_page - 1) - - elif button_id == "save-app": - self.save_application() - - elif button_id == "close-form": - self.app.pop_screen() - - def store_current_field_values(self) -> None: - """Store the current form field values in application_data.""" - try: - # Page 1: Essential Information - if self.current_page == 1: - self.application_data["job_title"] = self.query_one("#job-title", Input).value - self.application_data["company_id"] = self.query_one("#company-select", Select).value - self.application_data["position"] = self.query_one("#position", Input).value - self.application_data["status"] = self.query_one("#status", Select).value - self.application_data["applied_date"] = self.query_one("#applied-date", Input).value - - # Page 2: Additional Details - elif self.current_page == 2: - self.application_data["location"] = self.query_one("#location", Input).value - self.application_data["salary"] = self.query_one("#salary", Input).value - self.application_data["link"] = self.query_one("#link", Input).value - - # Page 3: Content - elif self.current_page == 3: - self.application_data["description"] = self.query_one("#description", TextArea).text - self.application_data["notes"] = self.query_one("#notes", TextArea).text - except Exception as e: - self.app.sub_title = f"Error storing field values: {str(e)}" - - def navigate_to_page(self, page_number): - """Navigate to the specified page of the form.""" - if page_number < 1 or page_number > self.total_pages: - return - - # Store current field values if not in readonly mode - if not self.readonly: - self.store_current_field_values() - - # Hide all pages - for i in range(1, self.total_pages + 1): - page = self.query_one(f"#page-{i}", Container) - if i == page_number: - page.remove_class("hidden") - else: - page.add_class("hidden") - - # Update current page - self.current_page = page_number - - # Update page indicator - self.query_one("#page-indicator", Static).update(f"Page {self.current_page} of {self.total_pages}") - - # Update button states - prev_button = self.query_one("#prev-page", Button) - next_button = self.query_one("#next-page", Button) - - prev_button.disabled = page_number == 1 - next_button.disabled = page_number == self.total_pages - - def on_mount(self) -> None: - """Load data when the form is mounted.""" - self.load_companies() - if self.app_id: - self.load_application() - - def load_companies(self) -> None: - """Load companies for the dropdown.""" - try: - service = CompanyService() - self.companies = service.get_all() - - company_select = self.query_one("#company-select", Select) - company_select.set_options([(company["name"], str(company["id"])) for company in self.companies]) - - except Exception as e: - self.app.sub_title = f"Error loading companies: {str(e)}" - - def load_application(self) -> None: - """Load application data for editing.""" - try: - service = ApplicationService() - app_data = service.get(int(self.app_id)) - - if not app_data: - self.app.sub_title = f"Application {self.app_id} not found" - return - - # Populate form fields - self.query_one("#job-title", Input).value = app_data["job_title"] - self.query_one("#position", Input).value = app_data["position"] - - if app_data.get("location"): - self.query_one("#location", Input).value = app_data["location"] - - if app_data.get("salary"): - self.query_one("#salary", Input).value = app_data["salary"] - - self.query_one("#status", Select).value = app_data["status"] - - # Format the date - applied_date = datetime.fromisoformat(app_data["applied_date"]).strftime("%Y-%m-%d") - self.query_one("#applied-date", Input).value = applied_date - - if app_data.get("link"): - self.query_one("#link", Input).value = app_data["link"] - - if app_data.get("description"): - self.query_one("#description", TextArea).text = app_data["description"] - - if app_data.get("notes"): - self.query_one("#notes", TextArea).text = app_data["notes"] - - # Set company if available - if app_data.get("company"): - self.query_one("#company-select", Select).value = str(app_data["company"]["id"]) - - except Exception as e: - self.app.sub_title = f"Error loading application: {str(e)}" - - def save_application(self) -> None: - """Save the application data.""" - try: - # Make sure we store values from the current page - self.store_current_field_values() - - # Validate required fields - required_fields = { - "job_title": "Job title is required", - "company_id": "Company is required", - "position": "Position is required", - "status": "Status is required", - "applied_date": "Applied date is required", - } - - for field, message in required_fields.items(): - if not self.application_data.get(field): - self.app.sub_title = message - # Go back to first page where most required fields are - self.navigate_to_page(1) - return - - service = ApplicationService() - - if self.app_id: - # Update existing application - service.update(int(self.app_id), self.application_data) - self.app.sub_title = "Application updated successfully" - else: - # Create new application - service.create(self.application_data) - self.app.sub_title = "Application created successfully" - - # Call the on_saved callback if provided - if self.on_saved: - self.on_saved() - - # Return to the previous screen - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving application: {str(e)}" diff --git a/src/tui/tabs/applications/applications.py b/src/tui/tabs/applications/applications.py deleted file mode 100644 index 80858cf..0000000 --- a/src/tui/tabs/applications/applications.py +++ /dev/null @@ -1,335 +0,0 @@ -from datetime import datetime - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Button, DataTable, Input, Select, Static - -from src.config import ApplicationStatus -from src.services.application_service import ApplicationService -from src.tui.tabs.applications.application_form import ApplicationForm -from src.tui.widgets.confirmation_modal import ConfirmationModal -from src.tui.widgets.list_view import ListView - - -class ApplicationsList(ListView): - """Applications listing and management screen.""" - - BINDINGS = [ - ("a", "add_application", "Add Application"), - ("d", "delete_application", "Delete"), - ("e", "edit_application", "Edit"), - ("v", "view_application", "View"), - ("s", "change_status", "Change Status"), - ] - - def __init__(self) -> None: - """Initialize the applications listing.""" - columns = ["ID", "Job Title", "Company", "Position", "Status", "Applied Date"] - super().__init__( - service=ApplicationService(), - columns=columns, - title="Applications", - ) - self.current_status = None - - def compose(self) -> ComposeResult: - """Compose the applications screen layout.""" - with Container(classes="list-view-container"): - # Header section - with Container(classes="list-header"): - # Filter bar - with Horizontal(classes="filter-bar"): - with Vertical(classes="filter-section"): - yield Static("Status:", classes="filter-label") - yield Select( - self._get_status_options(), - value="All", - id="status-filter", - classes="filter-dropdown", - ) - - # Search section - with Horizontal(classes="search-section"): - yield Input(placeholder="Search applications...", id="app-search") - yield Button("🔍", id="search-button") - - # Quick action buttons - with Horizontal(classes="action-section"): - yield Button("New Application", variant="primary", id="new-app") - - # Table section - with Container(classes="table-container"): - yield DataTable(id="applications-table") - - # Footer section - with Horizontal(classes="list-footer"): - with Horizontal(classes="action-buttons"): - yield Button("View", id="view-app", disabled=True) - yield Button("Edit", id="edit-app", disabled=True) - yield Button("Delete", id="delete-app", variant="error", disabled=True) - - def _get_status_options(self): - """Get options for the status filter dropdown.""" - options = [(status.value, status.value) for status in ApplicationStatus] - options.insert(0, ("All", "All")) - return options - - def on_mount(self) -> None: - """Set up the screen when mounted.""" - table = self.query_one("#applications-table", DataTable) - table.add_columns( - "ID", - "Job Title", - "Company", - "Position", - "Status", - "Applied Date", - ) - table.cursor_type = "row" - table.zebra_stripes = True - table.can_focus = True - - # Enable sorting - table.sort_column_click = True - - self.load_applications() - - def load_applications(self, status: str = None) -> None: - """Load applications from the database with improved styling.""" - self.update_status("Loading applications...") - self.current_status = status - - try: - service = ApplicationService() - - # Get applications with proper filtering - if status == "All" or status is None: - applications = service.get_applications( - sort_by=self.sort_column.lower().replace(" ", "_"), - sort_desc=not self.sort_ascending, - limit=50, - ) - else: - applications = service.get_applications( - status=status, - sort_by=self.sort_column.lower().replace(" ", "_"), - sort_desc=not self.sort_ascending, - limit=50, - ) - - # Get reference to the data table - table = self.query_one("#applications-table", DataTable) - table.clear() - - # Convert to list for sorting - apps_list = list(applications) - - # Check if we have any applications - if not apps_list: - # Handle empty state - table.add_column_span = len(table.columns) - self.update_status("No applications found") - - # Make sure action buttons are disabled - view_btn = self.query_one("#view-app", Button) - edit_btn = self.query_one("#edit-app", Button) - delete_btn = self.query_one("#delete-app", Button) - view_btn.disabled = True - edit_btn.disabled = True - delete_btn.disabled = True - - return - - # Add rows with styled status values - for app in apps_list: - # Format applied date for better readability - try: - applied_date = datetime.fromisoformat(app["applied_date"]) - formatted_date = applied_date.strftime("%Y-%m-%d") - except (ValueError, TypeError): - formatted_date = app["applied_date"] - - # Add the row with all values - table.add_row( - str(app["id"]), - app["job_title"], - app.get("company", {}).get("name", ""), - app["position"], - app["status"], - formatted_date, - ) - - # Update status message with count - count = len(apps_list) - status_message = f"Loaded {count} application{'s' if count != 1 else ''}" - self.update_status(status_message) - - except Exception as e: - error_message = f"Error loading applications: {str(e)}" - self.update_status(error_message) - # Log the error for debugging - import logging - - logging.error(error_message, exc_info=True) - - def on_select_changed(self, event: Select.Changed) -> None: - """Handle status filter changes.""" - if event.select.id == "status-filter": - status = event.value if event.value != "All" else None - self.load_applications(status) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - table = self.query_one("#applications-table", DataTable) - - if button_id == "new-app": - self.app.push_screen(ApplicationForm(on_saved=self.load_applications)) - - elif button_id == "view-app" and table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ApplicationForm(app_id=app_id, readonly=True)) - - elif button_id == "edit-app" and table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ApplicationForm(app_id=app_id, on_saved=self.load_applications)) - - elif button_id == "delete-app" and table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_application(app_id) - - elif button_id == "search-button": - search_term = self.query_one("#app-search", Input).value - self.search_applications(search_term) - - def search_applications(self, search_term: str) -> None: - """Search applications by keyword.""" - if not search_term: - self.load_applications(self.current_status) - return - - self.update_status(f"Searching for '{search_term}'...") - - try: - service = ApplicationService() - applications = service.search_applications(search_term) - - # Apply status filter if active - if self.current_status: - applications = [app for app in applications if app["status"] == self.current_status] - - table = self.query_one("#applications-table", DataTable) - table.clear() - - # Add results to table - for app in applications: - # Format applied date - try: - applied_date = datetime.fromisoformat(app["applied_date"]) - formatted_date = applied_date.strftime("%Y-%m-%d") - except (ValueError, TypeError): - formatted_date = app["applied_date"] - - table.add_row( - str(app["id"]), - app["job_title"], - app.get("company", {}).get("name", ""), - app["position"], - app["status"], - formatted_date, - ) - - count = len(applications) - self.update_status(f"Found {count} application{'s' if count != 1 else ''} matching '{search_term}'") - - except Exception as e: - self.update_status(f"Search error: {str(e)}") - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - """Enable buttons when a row is highlighted.""" - view_btn = self.query_one("#view-app", Button) - edit_btn = self.query_one("#edit-app", Button) - delete_btn = self.query_one("#delete-app", Button) - - view_btn.disabled = False - edit_btn.disabled = False - delete_btn.disabled = False - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Open the application detail view when a row is selected.""" - table = self.query_one("#applications-table", DataTable) - # Use get_row with the RowKey provided by the event - app_id = table.get_row(event.row_key)[0] - - # Open application detail view - from src.tui.tabs.applications.application_detail import ApplicationDetail - - self.app.push_screen(ApplicationDetail(int(app_id))) - - def update_status(self, message: str) -> None: - """Update status message in the footer.""" - self.app.sub_title = message - - def _confirm_delete_application(self, app_id: str) -> None: - """Show a confirmation dialog for deleting an application.""" - - def do_delete(): - service = ApplicationService() - success = service.delete(int(app_id)) - - if success: - self.app.sub_title = f"Successfully deleted application #{app_id}" - self.load_applications(self.current_status) - else: - self.app.sub_title = f"Application #{app_id} not found" - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message=f"Are you sure you want to delete application #{app_id}?", - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=do_delete, - dangerous=True, - ) - ) - - # Action methods for key bindings - def action_add_application(self) -> None: - """Open the application creation form.""" - self.app.push_screen(ApplicationForm(on_saved=self.load_applications)) - - def action_delete_application(self) -> None: - """Delete the selected application.""" - table = self.query_one("#applications-table", DataTable) - if table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_application(app_id) - - def action_edit_application(self) -> None: - """Edit the selected application.""" - table = self.query_one("#applications-table", DataTable) - if table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ApplicationForm(app_id=app_id, on_saved=self.load_applications)) - - def action_view_application(self) -> None: - """View the selected application.""" - table = self.query_one("#applications-table", DataTable) - if table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ApplicationForm(app_id=app_id, readonly=True)) - - def action_change_status(self) -> None: - """Change the status of the selected application.""" - table = self.query_one("#applications-table", DataTable) - if table.cursor_row is not None: - app_id = table.get_row_at(table.cursor_row)[0] - status = table.get_row_at(table.cursor_row)[4] - from src.tui.tabs.applications.status_transition import StatusTransitionDialog - - self.app.push_screen(StatusTransitionDialog(app_id, status, self.load_applications)) - - def action_refresh_applications(self) -> None: - """Refresh the applications list.""" - self.load_applications(self.current_status) diff --git a/src/tui/tabs/applications/interaction_form.py b/src/tui/tabs/applications/interaction_form.py deleted file mode 100644 index 3a2afec..0000000 --- a/src/tui/tabs/applications/interaction_form.py +++ /dev/null @@ -1,166 +0,0 @@ -from collections.abc import Callable -from datetime import datetime, timedelta - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import ModalScreen -from textual.widgets import Button, Input, Label, Select, TextArea - -from src.config import InteractionType -from src.services.interaction_service import InteractionService - - -class InteractionForm(ModalScreen): - """Form for creating or editing an interaction.""" - - def __init__(self, interaction_id: int = None, application_id: int = None, on_saved: Callable | None = None): - """Initialize the form. - - Args: - interaction_id: ID of interaction to edit (None for new interaction) - application_id: ID of application to associate with (required for new) - on_saved: Callback function to run when interaction is saved - """ - super().__init__() - self.interaction_id = interaction_id - self.application_id = application_id - self.on_saved = on_saved - - def compose(self) -> ComposeResult: - with Container(id="interaction-form"): - yield Label( - "Edit Interaction" if self.interaction_id else "New Interaction", - id="form-title", - ) - - with Vertical(id="form-fields"): - yield Label("Interaction Type:", classes="field-label") - yield Select( - [(itype.value, itype.value) for itype in InteractionType], - id="interaction-type", - ) - - yield Label("Date:", classes="field-label") - yield Input( - placeholder="YYYY-MM-DD", - id="interaction-date", - value=datetime.now().strftime("%Y-%m-%d"), - ) - - yield Label("Notes:", classes="field-label") - yield TextArea(id="interaction-notes") - - yield Label("Quick Date:", classes="field-label") - with Horizontal(): - yield Button("Today", id="date-today") - yield Button("Yesterday", id="date-yesterday") - yield Button("-2 Days", id="date-two-days-ago") - yield Button("-1 Week", id="date-week-ago") - - with Horizontal(id="form-actions"): - yield Button("Cancel", id="cancel-interaction") - yield Button("Save", id="save-interaction", variant="success") - - def on_mount(self) -> None: - """Load interaction data if editing.""" - if self.interaction_id: - self.load_interaction() - - def load_interaction(self) -> None: - """Load interaction data for editing.""" - try: - service = InteractionService() - interaction = service.get(self.interaction_id) - if not interaction: - self.app.sub_title = f"Interaction {self.interaction_id} not found" - return - - self.query_one("#interaction-type", Select).value = interaction["type"] - - # Format the date - date = datetime.fromisoformat(interaction["date"]).strftime("%Y-%m-%d") - self.query_one("#interaction-date", Input).value = date - - if interaction.get("notes"): - self.query_one("#interaction-notes", TextArea).text = interaction["notes"] - - # Store the application ID if it exists - self.application_id = interaction.get("application_id") - - except Exception as e: - self.app.sub_title = f"Error loading interaction: {str(e)}" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "cancel-interaction": - self.app.pop_screen() - - elif button_id == "save-interaction": - self.save_interaction() - - # Quick date buttons - elif button_id.startswith("date-"): - date = datetime.now() - - if button_id == "date-today": - # Today, keep as is - pass - elif button_id == "date-yesterday": - date -= timedelta(days=1) - elif button_id == "date-two-days-ago": - date -= timedelta(days=2) - elif button_id == "date-week-ago": - date -= timedelta(days=7) - - # Update the date input - self.query_one("#interaction-date", Input).value = date.strftime("%Y-%m-%d") - - def save_interaction(self) -> None: - """Save the interaction.""" - try: - interaction_type = self.query_one("#interaction-type", Select).value - date = self.query_one("#interaction-date", Input).value - notes = self.query_one("#interaction-notes", TextArea).text - - # Validate required fields - if not interaction_type: - self.app.sub_title = "Interaction type is required" - return - - if not date: - self.app.sub_title = "Date is required" - return - - if not self.application_id: - self.app.sub_title = "Application ID is required" - return - - # Prepare data - interaction_data = { - "type": interaction_type, - "date": date, - "notes": notes or None, - "application_id": self.application_id, - } - - # Save interaction - service = InteractionService() - - if self.interaction_id: - service.update(self.interaction_id, interaction_data) - self.app.sub_title = "Interaction updated successfully" - else: - service.create(interaction_data) - self.app.sub_title = "Interaction created successfully" - - # Call the on_saved callback if provided - if self.on_saved: - self.on_saved() - - # Close the form - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving interaction: {str(e)}" diff --git a/src/tui/tabs/applications/status_transition.py b/src/tui/tabs/applications/status_transition.py deleted file mode 100644 index e5ae914..0000000 --- a/src/tui/tabs/applications/status_transition.py +++ /dev/null @@ -1,103 +0,0 @@ -from collections.abc import Callable -from datetime import datetime - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import ModalScreen -from textual.widgets import Button, Label, Select, TextArea - -from src.config import ApplicationStatus -from src.services.application_service import ApplicationService - - -class StatusTransitionDialog(ModalScreen): - """Modal dialog for changing application status.""" - - def __init__(self, application_id: int, current_status: str, on_updated: Callable | None = None): - """Initialize status transition dialog. - - Args: - application_id: ID of the application to modify - current_status: Current status of the application - on_updated: Callback function to run when status is updated - """ - super().__init__() - self.application_id = application_id - self.current_status = current_status - self.on_updated = on_updated - - def compose(self) -> ComposeResult: - with Container(id="transition-dialog"): - yield Label( - f"Change Status for Application #{self.application_id}", - id="dialog-title", - ) - - with Vertical(id="transition-form"): - yield Label("Current Status:", classes="field-label") - yield Label(self.current_status, id="current-status") - - yield Label("New Status:", classes="field-label") - yield Select( - [(status.value, status.value) for status in ApplicationStatus], - id="new-status", - value=self.current_status, - ) - - yield Label("Add Note:", classes="field-label") - yield TextArea( - id="status-note", - tooltip="Optional note about this status change", - ) - - with Horizontal(id="dialog-buttons"): - yield Button("Cancel", id="cancel-transition") - yield Button("Save", id="save-transition", variant="success") - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "cancel-transition": - self.app.pop_screen() - return - - elif button_id == "save-transition": - self.save_status_change() - - def save_status_change(self) -> None: - """Save the status change.""" - try: - new_status = self.query_one("#new-status", Select).value - note = self.query_one("#status-note", TextArea).text - - if new_status == self.current_status: - self.app.sub_title = "Status unchanged" - self.app.pop_screen() - return - - # Update application status - service = ApplicationService() - service.update(self.application_id, {"status": new_status}) - - # Create interaction record for the status change - if note: - service.add_interaction( - { - "application_id": self.application_id, - "type": "NOTE", - "notes": f"Status changed from {self.current_status} to {new_status}. Note: {note}", - "date": datetime.now().isoformat(), - } - ) - - self.app.sub_title = f"Status updated to {new_status}" - - # Call the on_updated callback if provided - if self.on_updated: - self.on_updated() - - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error updating status: {str(e)}" diff --git a/src/tui/tabs/companies/companies.py b/src/tui/tabs/companies/companies.py deleted file mode 100644 index 9277bef..0000000 --- a/src/tui/tabs/companies/companies.py +++ /dev/null @@ -1,334 +0,0 @@ -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Button, DataTable, Input, Select, Static - -from src.config import CompanyType -from src.services.company_service import CompanyService -from src.tui.tabs.companies.company_detail import CompanyDetailScreen -from src.tui.tabs.companies.company_form import CompanyForm -from src.tui.widgets.confirmation_modal import ConfirmationModal -from src.tui.widgets.list_view import ListView - - -class CompaniesList(ListView): - """Companies listing and management screen.""" - - BINDINGS = [ - ("a", "add_company", "Add Company"), - ("d", "delete_company", "Delete"), - ("e", "edit_company", "Edit"), - ("v", "view_company", "View"), - ] - - def __init__(self): - """Initialize the companies listing.""" - columns = ["ID", "Name", "Industry", "Type", "Website", "Size"] - super().__init__( - service=CompanyService(), - columns=columns, - title="Companies", - ) - self.sort_column = "Name" - self.sort_ascending = True - self.company_type_filter = None - - def compose(self) -> ComposeResult: - """Compose the companies screen layout.""" - with Container(classes="list-view-container"): - # Header section - with Container(classes="list-header"): - # Filter bar - with Horizontal(classes="filter-bar"): - with Vertical(classes="filter-section"): - yield Static("Type:", classes="filter-label") - yield Select( - self._get_company_type_options(), - value="All", - id="type-filter", - classes="filter-dropdown", - ) - - # Search section - with Horizontal(classes="search-section"): - yield Input( - placeholder="Search companies...", - id="company-search", - classes="search-box", - ) - yield Button("🔍", id="search-button") - - # Quick action buttons - with Horizontal(classes="action-section"): - yield Button("New Company", variant="primary", id="new-company") - - # Table section - with Container(classes="table-container"): - yield DataTable(id="companies-table") - - # Footer section - with Horizontal(classes="list-footer"): - with Horizontal(classes="action-buttons"): - yield Button("View", id="view-company", disabled=True) - yield Button("Edit", id="edit-company", disabled=True) - yield Button("Delete", id="delete-company", variant="error", disabled=True) - - def _get_company_type_options(self): - """Get options for the company type filter dropdown.""" - options = [(ct.value, ct.value) for ct in CompanyType] - options.insert(0, ("All", "All")) - return options - - def on_mount(self) -> None: - """Set up the screen when mounted.""" - table = self.query_one("#companies-table", DataTable) - table.add_columns( - "ID", - "Name", - "Industry", - "Type", - "Website", - "Size", - ) - table.cursor_type = "row" - table.can_focus = True - table.zebra_stripes = True - - # Enable sorting - table.sort_column_click = True - - self.load_companies() - - def load_companies(self, company_type: str = None) -> None: - """Load companies from the database.""" - self.update_status("Loading companies...") - self.company_type_filter = company_type - - try: - service = CompanyService() - companies = service.get_all() - - # Apply type filter if specified - if company_type and company_type != "All": - companies = [c for c in companies if c.get("type") == company_type] - - table = self.query_one("#companies-table", DataTable) - table.clear() - - # Convert to list for sorting - companies_list = list(companies) - - # Apply current sort settings - self._sort_companies(companies_list) - - if not companies_list: - # Handle empty state - table.add_column_span = len(table.columns) - self.update_status("No companies found") - - # Disable action buttons - self._disable_action_buttons() - return - - for company in companies_list: - table.add_row( - str(company["id"]), - company["name"], - company.get("industry", ""), - company.get("type", ""), - company.get("website", ""), - company.get("size", ""), - ) - - self.update_status(f"Loaded {len(companies)} companies") - except Exception as e: - self.update_status(f"Error loading companies: {str(e)}") - - def on_select_changed(self, event: Select.Changed) -> None: - """Handle type filter changes.""" - if event.select.id == "type-filter": - company_type = event.value if event.value != "All" else None - self.load_companies(company_type) - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle search input submission.""" - if event.input.id == "company-search": - self.search_companies(event.input.value) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - table = self.query_one("#companies-table", DataTable) - - if button_id == "new-company": - self.app.push_screen(CompanyForm(on_saved=self.load_companies)) - - elif button_id == "view-company" and table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(CompanyDetailScreen(int(company_id))) - - elif button_id == "edit-company" and table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(CompanyForm(company_id=company_id, on_saved=self.load_companies)) - - elif button_id == "delete-company" and table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_company(company_id) - - elif button_id == "search-button": - search_term = self.query_one("#company-search", Input).value - self.search_companies(search_term) - - def search_companies(self, search_term: str) -> None: - """Search companies by name or industry.""" - if not search_term: - self.load_companies(self.company_type_filter) - return - - self.update_status(f"Searching for '{search_term}'...") - - try: - service = CompanyService() - all_companies = service.get_all() - - # Simple case-insensitive search - search_term = search_term.lower() - filtered_companies = [ - c - for c in all_companies - if search_term in c["name"].lower() - or (c.get("industry") and search_term in c.get("industry", "").lower()) - or (c.get("notes") and search_term in c.get("notes", "").lower()) - ] - - # Apply type filter if active - if self.company_type_filter and self.company_type_filter != "All": - filtered_companies = [c for c in filtered_companies if c.get("type") == self.company_type_filter] - - table = self.query_one("#companies-table", DataTable) - table.clear() - - if not filtered_companies: - # Handle empty search results - self.update_status(f"No companies found matching '{search_term}'") - self._disable_action_buttons() - return - - for company in filtered_companies: - table.add_row( - str(company["id"]), - company["name"], - company.get("industry", ""), - company.get("type", ""), - company.get("website", ""), - company.get("size", ""), - ) - - self.update_status(f"Found {len(filtered_companies)} companies matching '{search_term}'") - - except Exception as e: - self.update_status(f"Search error: {str(e)}") - - def update_status(self, message: str) -> None: - """Update status message in the footer.""" - self.app.sub_title = message - - def _disable_action_buttons(self) -> None: - """Disable all action buttons.""" - view_btn = self.query_one("#view-company", Button) - edit_btn = self.query_one("#edit-company", Button) - delete_btn = self.query_one("#delete-company", Button) - - view_btn.disabled = True - edit_btn.disabled = True - delete_btn.disabled = True - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - """Enable buttons when a row is highlighted.""" - view_btn = self.query_one("#view-company", Button) - edit_btn = self.query_one("#edit-company", Button) - delete_btn = self.query_one("#delete-company", Button) - - view_btn.disabled = False - edit_btn.disabled = False - delete_btn.disabled = False - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Open the company detail view when a row is selected.""" - table = self.query_one("#companies-table", DataTable) - company_id = table.get_row(event.row_key)[0] - self.app.push_screen(CompanyDetailScreen(int(company_id), on_updated=self.load_companies)) - - def _sort_companies(self, companies): - """Sort companies based on current sort settings.""" - # Define sort keys for each column - sort_keys = { - "ID": lambda c: int(c["id"]), - "Name": lambda c: c["name"].lower(), - "Industry": lambda c: (c.get("industry") or "").lower(), - "Type": lambda c: c.get("type", ""), - "Website": lambda c: (c.get("website") or "").lower(), - "Size": lambda c: c.get("size", ""), - } - - # Get sort key function - sort_key = sort_keys.get(self.sort_column, lambda c: c["name"].lower()) - - # Sort companies - companies.sort(key=sort_key, reverse=not self.sort_ascending) - - def _confirm_delete_company(self, company_id: str) -> None: - """Show confirmation dialog for company deletion.""" - - def do_delete(): - try: - service = CompanyService() - success = service.delete(int(company_id)) - - if success: - self.app.sub_title = f"Successfully deleted company #{company_id}" - self.load_companies(self.company_type_filter) - else: - self.app.sub_title = f"Company #{company_id} not found" - except ValueError as e: - self.app.sub_title = str(e) - except Exception as e: - self.app.sub_title = f"Error deleting company: {str(e)}" - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message=( - f"Are you sure you want to delete company #{company_id}?\n\n" - "This will permanently remove the company and all its relationships." - ), - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=do_delete, - dangerous=True, - ) - ) - - def action_add_company(self) -> None: - """Open the company creation form.""" - self.app.push_screen(CompanyForm(on_saved=self.load_companies)) - - def action_delete_company(self) -> None: - """Delete the selected company.""" - table = self.query_one("#companies-table", DataTable) - if table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_company(company_id) - - def action_edit_company(self) -> None: - """Edit the selected company.""" - table = self.query_one("#companies-table", DataTable) - if table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(CompanyForm(company_id=company_id, on_saved=self.load_companies)) - - def action_view_company(self) -> None: - """View the selected company.""" - table = self.query_one("#companies-table", DataTable) - if table.cursor_row is not None: - company_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(CompanyDetailScreen(int(company_id))) diff --git a/src/tui/tabs/companies/company_detail.py b/src/tui/tabs/companies/company_detail.py deleted file mode 100644 index a53c200..0000000 --- a/src/tui/tabs/companies/company_detail.py +++ /dev/null @@ -1,251 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Grid, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, DataTable, Label, Static, TabbedContent, TabPane - -from src.services.application_service import ApplicationService -from src.services.company_service import CompanyService -from src.tui.tabs.companies.relationship_form import CompanyRelationshipForm -from src.tui.widgets.confirmation_modal import ConfirmationModal - - -class CompanyDetailScreen(Screen): - """Screen for viewing details about a company.""" - - def __init__(self, company_id: int, on_updated: Callable | None = None): - """Initialize company detail screen. - - Args: - company_id: ID of company to display - on_updated: Optional callback when company is updated - """ - super().__init__() - self.company_id = company_id - self.company_data = None - self.on_updated = on_updated - - def compose(self) -> ComposeResult: - with Container(id="company-detail"): - with Horizontal(id="company-header"): - with Vertical(id="company-identity"): - yield Static("", id="company-name", classes="company-title") - yield Static("", id="company-type", classes="company-type") - - with Vertical(id="header-actions"): - yield Button("Edit Company", id="edit-company") - yield Button("Add Relationship", id="add-relationship", variant="primary") - - with TabbedContent(id="company-tabs"): - with TabPane("Overview", id="tab-overview"): - with Grid(id="company-info-grid", classes="info-grid"): - yield Label("Industry:", classes="field-label") - yield Static("", id="company-industry", classes="field-value") - - yield Label("Website:", classes="field-label") - yield Static("", id="company-website", classes="field-value") - - yield Label("Size:", classes="field-label") - yield Static("", id="company-size", classes="field-value") - - yield Label("Notes:", classes="section-label") - with Container(id="notes-container", classes="notes-box"): - yield Static("", id="company-notes") - - with TabPane("Relationships", id="tab-relationships"): - yield Label("Company Relationships", classes="section-label") - yield DataTable(id="relationships-table") - - with Container(id="relationship-viz", classes="viz-container"): - yield Static("Relationship Visualization:", classes="section-label") - yield Static( - "[Visualization would go here in a graphical UI]", - classes="placeholder", - ) - - with TabPane("Applications", id="tab-applications"): - yield Label("Job Applications with this Company", classes="section-label") - yield DataTable(id="applications-table") - - with Horizontal(id="detail-actions"): - yield Button("Back", id="back-button", variant="default") - - def on_mount(self) -> None: - """Load company data when the screen is mounted.""" - # Set up tables - relationships_table = self.query_one("#relationships-table", DataTable) - relationships_table.add_columns("Company", "Type", "Relationship", "Direction", "Actions") - relationships_table.cursor_type = "row" - - applications_table = self.query_one("#applications-table", DataTable) - applications_table.add_columns("ID", "Job Title", "Position", "Status", "Applied Date") - applications_table.cursor_type = "row" - - # Load company data - self.load_company_data() - - def load_company_data(self) -> None: - """Load all company data and populate the UI.""" - try: - # Load company details - service = CompanyService() - self.company_data = service.get(self.company_id) - - if not self.company_data: - self.app.sub_title = f"Company {self.company_id} not found" - return - - # Update header - self.query_one("#company-name", Static).update(self.company_data.get("name", "Unknown")) - - # Make sure company type is a string - company_type = self.company_data.get("type", "") - if company_type is None: - company_type = "DIRECT_EMPLOYER" - self.query_one("#company-type", Static).update(f"Type: {company_type}") - - # Update overview fields - ensure we always use strings for display - industry = self.company_data.get("industry", "") - if industry is None or industry == "": - industry = "Not specified" - self.query_one("#company-industry", Static).update(industry) - - website = self.company_data.get("website", "") - if website is None or website == "": - website = "No website provided" - self.query_one("#company-website", Static).update(website) - - size = self.company_data.get("size", "") - if size is None or size == "": - size = "Not specified" - self.query_one("#company-size", Static).update(size) - - # Update notes - notes = self.company_data.get("notes", "") - if notes is None or notes == "": - notes = "No notes available." - self.query_one("#company-notes", Static).update(notes) - - # Load relationships - self.load_relationships() - - # Load applications - self.load_applications() - - self.app.sub_title = f"Viewing company: {self.company_data.get('name', 'Unknown')}" - - except Exception as e: - self.app.sub_title = f"Error loading company data: {str(e)}" - - def load_relationships(self) -> None: - """Load company relationships.""" - try: - service = CompanyService() - relationships = service.get_related_companies(self.company_id) - - table = self.query_one("#relationships-table", DataTable) - table.clear() - - if not relationships: - table.add_row("No relationships found", "", "", "", "") - return - - for rel in relationships: - direction = "→" if rel["direction"] == "outgoing" else "←" - table.add_row( - rel["company_name"], - rel.get("company_type", ""), - rel["relationship_type"], - direction, - "Edit | Delete", - ) - - except Exception as e: - self.app.sub_title = f"Error loading relationships: {str(e)}" - - def load_applications(self) -> None: - """Load job applications for this company.""" - try: - # Now properly fetch applications from the service - app_service = ApplicationService() - applications = app_service.get_applications_by_company(self.company_id) - - table = self.query_one("#applications-table", DataTable) - table.clear() - - if not applications: - table.add_row("No applications found", "", "", "", "") - return - - for app in applications: - table.add_row( - str(app["id"]), - app["job_title"], - app["position"], - app["status"], - app["applied_date"], - ) - except Exception as e: - self.app.sub_title = f"Error loading applications: {str(e)}" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "back-button": - self.app.pop_screen() - # Call the on_updated callback if provided - if self.on_updated: - self.on_updated() - - elif button_id == "edit-company": - from src.tui.tabs.companies.company_form import CompanyForm - - self.app.push_screen(CompanyForm(company_id=str(self.company_id), on_saved=self.load_company_data)) - - elif button_id == "add-relationship": - self.app.push_screen( - CompanyRelationshipForm(source_company_id=self.company_id, on_saved=self.load_relationships) - ) - - def _confirm_delete_relationship(self, relationship_id: str) -> None: - """Show confirmation dialog for relationship deletion.""" - - def do_delete(): - try: - # This would need a delete_relationship method in the company service - # For now it's just a placeholder - self.app.sub_title = "Relationship deletion not implemented yet" - self.load_relationships() - except Exception as e: - self.app.sub_title = f"Error deleting relationship: {str(e)}" - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message="Are you sure you want to delete this relationship?", - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=do_delete, - dangerous=True, - ) - ) - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle row selection in tables.""" - table_id = event.data_table.id - - if table_id == "relationships-table": - # Handle relationship selection - placeholder for future implementation - # For full implementation, we would get the relationship ID and show actions - pass - - elif table_id == "applications-table": - # Open application details - from src.tui.tabs.applications.application_detail import ApplicationDetail - - row = event.data_table.get_row(event.row_key) - if row[0] != "No applications found": - app_id = int(row[0]) - self.app.push_screen(ApplicationDetail(app_id, on_updated=self.load_applications)) diff --git a/src/tui/tabs/companies/company_form.py b/src/tui/tabs/companies/company_form.py deleted file mode 100644 index f405c59..0000000 --- a/src/tui/tabs/companies/company_form.py +++ /dev/null @@ -1,167 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, Input, Label, Select, TextArea - -from src.config import CompanyType -from src.services.company_service import CompanyService - - -class CompanyForm(Screen): - """Form for creating a new company.""" - - def __init__( - self, - company_id: str = None, - readonly: bool = False, - on_saved: Callable | None = None, - ): - """Initialize the form. - - Args: - company_id: If provided, edit the existing company - readonly: If True, display in read-only mode - on_saved: Callback function to run after saving - """ - super().__init__() - self.company_id = company_id - self.readonly = readonly - self.on_saved = on_saved - - def compose(self) -> ComposeResult: - """Compose the form layout.""" - with Container(id="app-form"): - yield Label( - "New Company" if not self.company_id else "Edit Company", - id="form-title", - ) - - with Container(id="company-fields", classes="form-page"): - with Vertical(id="company-fields"): - yield Label("Company Name *", classes="field-label") - yield Input(id="company-name", disabled=self.readonly) - - yield Label("Website", classes="field-label") - yield Input(id="website", disabled=self.readonly) - - # Additional information - yield Label("Company Type", classes="field-label") - yield Select( - [(ct.value, ct.value) for ct in CompanyType], - id="company-type", - disabled=self.readonly, - value=CompanyType.DIRECT_EMPLOYER.value, - ) - - yield Label("Industry", classes="field-label") - yield Input(id="industry", disabled=self.readonly) - - yield Label("Size", classes="field-label") - yield Input( - id="size", - placeholder="e.g. 1-50, 51-200, 201-500, etc.", - disabled=self.readonly, - ) - - yield Label("Notes", classes="field-label") - yield TextArea(id="notes", disabled=self.readonly) - - with Horizontal(id="form-actions"): - if not self.readonly: - yield Button("Save", variant="primary", id="save-company") - yield Button("Close", id="close-form") - - def on_mount(self) -> None: - """Load data when mounted.""" - if self.company_id: - self.load_company() - - def load_company(self) -> None: - """Load company data for editing.""" - try: - service = CompanyService() - company_data = service.get(int(self.company_id)) - - if not company_data: - self.app.sub_title = f"Company {self.company_id} not found" - return - - # Populate form fields - self.query_one("#company-name", Input).value = company_data["name"] - - if company_data.get("website"): - self.query_one("#website", Input).value = company_data["website"] - - if company_data.get("industry"): - self.query_one("#industry", Input).value = company_data["industry"] - - if company_data.get("size"): - self.query_one("#size", Input).value = company_data["size"] - - if company_data.get("type"): - self.query_one("#company-type", Select).value = company_data["type"] - - if company_data.get("notes"): - self.query_one("#notes", TextArea).text = company_data["notes"] - - except Exception as e: - self.app.sub_title = f"Error loading company: {str(e)}" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "save-company": - self.save_company() - - elif button_id == "close-form": - self.app.pop_screen() - - def save_company(self) -> None: - """Save the company data.""" - try: - # Get values from form - name = self.query_one("#company-name", Input).value - website = self.query_one("#website", Input).value - industry = self.query_one("#industry", Input).value - size = self.query_one("#size", Input).value - company_type = self.query_one("#company-type", Select).value - notes = self.query_one("#notes", TextArea).text - - # Validate required fields - if not name: - self.app.sub_title = "Company name is required" - return - - # Prepare data - company_data = { - "name": name, - "website": website or None, - "industry": industry or None, - "size": size or None, - "type": company_type, - "notes": notes or None, - } - - service = CompanyService() - - if self.company_id: - # Update existing company - service.update(int(self.company_id), company_data) - self.app.sub_title = "Company updated successfully" - else: - # Create new company - service.create(company_data) - self.app.sub_title = "Company created successfully" - - # Call the on_saved callback if provided - if self.on_saved: - self.on_saved() - - # Return to the previous screen - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving company: {str(e)}" diff --git a/src/tui/tabs/companies/relationship_form.py b/src/tui/tabs/companies/relationship_form.py deleted file mode 100644 index a4af15e..0000000 --- a/src/tui/tabs/companies/relationship_form.py +++ /dev/null @@ -1,181 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import ModalScreen -from textual.widgets import Button, Input, Label, Select, TextArea - -from src.config import COMPANY_RELATIONSHIP_TYPES -from src.services.company_service import CompanyService - - -class CompanyRelationshipForm(ModalScreen): - """Modal form for creating or editing company relationships.""" - - def __init__( - self, - source_company_id: int, - relationship_id: int = None, - on_saved: Callable | None = None, - ): - """Initialize the form. - - Args: - source_company_id: ID of the source company - relationship_id: If provided, edit the existing relationship - on_saved: Optional callback after save - """ - super().__init__() - self.source_company_id = source_company_id - self.relationship_id = relationship_id - self.on_saved = on_saved - self.companies = [] - - def compose(self) -> ComposeResult: - with Container(id="relationship-form-container", classes="modal-container"): - with Container(id="relationship-form", classes="modal-content"): - yield Label( - "Add Company Relationship" if not self.relationship_id else "Edit Relationship", - id="form-title", - classes="modal-title", - ) - - with Vertical(id="form-fields"): - # Source company (read-only) - yield Label("From Company:", classes="field-label") - yield Input(id="source-company", disabled=True) - - # Relationship type - yield Label("Relationship Type:", classes="field-label") - yield Select( - [(rt, rt) for rt in COMPANY_RELATIONSHIP_TYPES], - id="relationship-type", - ) - - # Target company - yield Label("To Company:", classes="field-label") - yield Select([], id="target-company") - - yield Label("Notes:", classes="field-label") - yield TextArea(id="relationship-notes") - - with Horizontal(id="form-actions", classes="modal-actions"): - yield Button("Save", variant="primary", id="save-relationship") - yield Button("Cancel", id="cancel") - - def on_mount(self) -> None: - """Load companies and set up fields.""" - self.load_companies() - - # Load relationship data if editing - if self.relationship_id: - self.load_relationship() - - def load_companies(self) -> None: - """Load companies for dropdown.""" - try: - service = CompanyService() - - # Get current company details for display - source_company = service.get(self.source_company_id) - if source_company: - self.query_one("#source-company", Input).value = source_company["name"] - - # Get all companies for target selection - self.companies = service.get_all() - - # Remove the source company from options - target_companies = [c for c in self.companies if c["id"] != self.source_company_id] - - # Update target company dropdown - target_select = self.query_one("#target-company", Select) - target_select.clear() - target_options = [(company["name"], str(company["id"])) for company in target_companies] - target_select.set_options(target_options) - - except Exception as e: - self.app.sub_title = f"Error loading companies: {str(e)}" - - def load_relationship(self) -> None: - """Load relationship data if editing.""" - try: - service = CompanyService() - relationship = service.get_relationship(self.relationship_id) - - if not relationship: - self.app.sub_title = f"Relationship {self.relationship_id} not found" - return - - # Set relationship type - relationship_type = relationship.get("relationship_type") - if relationship_type: - self.query_one("#relationship-type", Select).value = relationship_type - - # Set target company - target_id = relationship.get("target_id") - if target_id: - target_select = self.query_one("#target-company", Select) - target_select.value = str(target_id) - - # Set notes - notes = relationship.get("notes") - if notes: - self.query_one("#relationship-notes", TextArea).text = notes - - self.app.sub_title = "Loaded relationship data for editing" - - except Exception as e: - self.app.sub_title = f"Error loading relationship: {str(e)}" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "cancel": - self.app.pop_screen() - - elif button_id == "save-relationship": - self.save_relationship() - - def save_relationship(self) -> None: - """Save the relationship.""" - try: - service = CompanyService() - - relationship_type = self.query_one("#relationship-type", Select).value - target_company_id_str = self.query_one("#target-company", Select).value - - # Validate inputs - if not relationship_type: - self.app.sub_title = "Please select a relationship type" - return - - if not target_company_id_str: - self.app.sub_title = "Please select a target company" - return - - target_company_id = int(target_company_id_str) - notes = self.query_one("#relationship-notes", TextArea).text - - # Create or update the relationship - if self.relationship_id: - # Update logic would go here when implemented - self.app.sub_title = "Relationship update not yet implemented" - else: - service.create_relationship( - source_id=self.source_company_id, - target_id=target_company_id, - relationship_type=relationship_type, - notes=notes, - ) - self.app.sub_title = "Relationship created successfully" - - # Run callback if provided - if self.on_saved: - self.on_saved() - - # Close the form - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving relationship: {str(e)}" diff --git a/src/tui/tabs/contacts/__init__.py b/src/tui/tabs/contacts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tui/tabs/contacts/contact_detail.py b/src/tui/tabs/contacts/contact_detail.py deleted file mode 100644 index e2dbd5f..0000000 --- a/src/tui/tabs/contacts/contact_detail.py +++ /dev/null @@ -1,181 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Grid, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, DataTable, Label, Static - -from src.services.contact_service import ContactService - - -class ContactDetailScreen(Screen): - """Screen for viewing details about a contact.""" - - def __init__(self, contact_id: int, on_updated: Callable | None = None): - """Initialize the contact detail screen. - - Args: - contact_id: ID of the contact to display - on_updated: Callback function to run when contact is updated - """ - super().__init__() - self.contact_id = contact_id - self.contact_data = None - self.on_updated = on_updated - - def compose(self) -> ComposeResult: - with Container(id="contact-detail"): - with Horizontal(id="contact-header"): - with Vertical(id="contact-identity"): - yield Static("", id="contact-name", classes="contact-title") - yield Static("", id="contact-title", classes="contact-subtitle") - - with Vertical(id="header-actions"): - yield Button("Edit Contact", id="edit-contact") - - # Contact information grid - with Grid(id="contact-info-grid", classes="info-grid"): - yield Label("Company:", classes="field-label") - yield Static("", id="contact-company", classes="field-value") - - yield Label("Email:", classes="field-label") - yield Static("", id="contact-email", classes="field-value") - - yield Label("Phone:", classes="field-label") - yield Static("", id="contact-phone", classes="field-value") - - yield Label("Notes:", classes="section-label") - with Container(id="notes-container", classes="notes-box"): - yield Static("", id="contact-notes") - - # Related applications - yield Label("Associated Applications", classes="section-label") - yield DataTable(id="contact-details-applications-table") - - # Related interactions - yield Label("Recent Interactions", classes="section-label") - yield DataTable(id="contact-details-interactions-table") - - with Horizontal(id="contact-detail-actions"): - yield Button("Back", id="back-button", variant="default") - yield Button("Add to Application", id="add-to-application", variant="primary") - - def on_mount(self) -> None: - """Load contact data when the screen is mounted.""" - # Set up tables - applications_table = self.query_one("#contact-details-applications-table", DataTable) - applications_table.add_columns("ID", "Job Title", "Position", "Status", "Applied Date") - applications_table.cursor_type = "row" - - interactions_table = self.query_one("#contact-details-interactions-table", DataTable) - interactions_table.add_columns("Date", "Type", "Application", "Details") - interactions_table.cursor_type = "row" - - # Load contact data - self.load_contact_data() - - def load_contact_data(self) -> None: - """Load all contact data and populate the UI.""" - try: - # Load contact details - service = ContactService() - self.contact_data = service.get(self.contact_id) - - if not self.contact_data: - self.app.sub_title = f"Contact {self.contact_id} not found" - return - - # Update header - self.query_one("#contact-name", Static).update(self.contact_data.get("name", "Unknown")) - - # Make sure title is a string - title = self.contact_data.get("title", "") - if title is None: - title = "No title" - self.query_one("#contact-title", Static).update(title) - - # Update fields - ensure we always use strings for display - company = self.contact_data.get("company", {}) - company_name = company.get("name", "") if company else "" - if not company_name: - company_name = "Not associated with a company" - self.query_one("#contact-company", Static).update(company_name) - - email = self.contact_data.get("email", "") - if email is None or email == "": - email = "No email provided" - self.query_one("#contact-email", Static).update(email) - - phone = self.contact_data.get("phone", "") - if phone is None or phone == "": - phone = "No phone provided" - self.query_one("#contact-phone", Static).update(phone) - - # Update notes - notes = self.contact_data.get("notes", "") - if notes is None or notes == "": - notes = "No notes available." - self.query_one("#contact-notes", Static).update(notes) - - # Load applications (placeholder - would need to implement in service) - self.load_applications() - - # Load interactions (placeholder - would need to implement in service) - self.load_interactions() - - self.app.sub_title = f"Viewing contact: {self.contact_data.get('name', 'Unknown')}" - - except Exception as e: - self.app.sub_title = f"Error loading contact data: {str(e)}" - - def load_applications(self) -> None: - """Load applications associated with this contact.""" - table = self.query_one("#contact-details-applications-table", DataTable) - table.clear() - - if not self.contact_data or not self.contact_data.get("applications"): - table.add_row("No applications found", "", "", "", "") - return - - applications = self.contact_data.get("applications", []) - - if not applications: - table.add_row("No applications found", "", "", "", "") - return - - for app in applications: - table.add_row( - str(app["id"]), - app["job_title"], - app["position"], - app["status"], - app.get("applied_date", ""), - ) - - def load_interactions(self) -> None: - """Load interactions associated with this contact.""" - table = self.query_one("#contact-details-interactions-table", DataTable) - table.clear() - - # This would require an actual method in the service to get interactions by contact - # For now, display placeholder - table.add_row("No interactions found", "", "", "") - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "back-button": - self.app.pop_screen() - # Call the on_updated callback if provided - if self.on_updated: - self.on_updated() - - elif button_id == "edit-contact": - from src.tui.tabs.contacts.contact_form import ContactForm - - self.app.push_screen(ContactForm(contact_id=str(self.contact_id), on_saved=self.load_contact_data)) - - elif button_id == "add-to-application": - # In the future, this would open a dialog to add the contact to an application - self.app.sub_title = "Adding to application not implemented yet" diff --git a/src/tui/tabs/contacts/contact_form.py b/src/tui/tabs/contacts/contact_form.py deleted file mode 100644 index 931a4ad..0000000 --- a/src/tui/tabs/contacts/contact_form.py +++ /dev/null @@ -1,203 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import Button, Input, Label, Select, TextArea - -from src.services.company_service import CompanyService -from src.services.contact_service import ContactService - - -class ContactForm(Screen): - """Form for creating or editing a contact.""" - - def __init__( - self, - contact_id: str = None, - readonly: bool = False, - on_saved: Callable | None = None, - ): - """Initialize the form. - - Args: - contact_id: If provided, edit the existing contact - readonly: If True, display in read-only mode - on_saved: Callback function to run after saving - """ - super().__init__() - self.contact_id = contact_id - self.readonly = readonly - self.on_saved = on_saved - self.companies = [] - - def compose(self) -> ComposeResult: - """Compose the form layout.""" - with Container(id="app-form"): - yield Label( - "New Contact" if not self.contact_id else "Edit Contact", - id="form-title", - ) - - with Container(id="contact-fields", classes="form-page"): - with Vertical(id="contact-fields"): - yield Label("Name *", classes="field-label") - yield Input(id="contact-name", disabled=self.readonly) - - yield Label("Title", classes="field-label") - yield Input(id="contact-title", disabled=self.readonly) - - yield Label("Company", classes="field-label") - with Horizontal(id="company-field"): - yield Select([], id="company-select", disabled=self.readonly) - if not self.readonly: - yield Button("+ New", id="new-company", variant="primary") - - # Contact information - yield Label("Email", classes="field-label") - yield Input( - id="contact-email", - placeholder="email@example.com", - disabled=self.readonly, - ) - - yield Label("Phone", classes="field-label") - yield Input( - id="contact-phone", - placeholder="(123) 456-7890", - disabled=self.readonly, - ) - - yield Label("Notes", classes="field-label") - yield TextArea(id="contact-notes", disabled=self.readonly) - - with Horizontal(id="form-actions"): - if not self.readonly: - yield Button("Save", variant="primary", id="save-contact") - yield Button("Close", id="close-form") - - def on_mount(self) -> None: - """Load data when mounted.""" - self.load_companies() - - if self.contact_id: - self.load_contact() - - def load_companies(self) -> None: - """Load companies for the dropdown.""" - try: - service = CompanyService() - self.companies = service.get_all() - - company_values = [(company["name"], str(company["id"])) for company in self.companies] - company_values.append(("No Company", "")) - - company_select = self.query_one("#company-select", Select) - company_select.set_options(company_values) - - except Exception as e: - self.app.sub_title = f"Error loading companies: {str(e)}" - - def load_contact(self) -> None: - """Load contact data for editing.""" - try: - service = ContactService() - contact_data = service.get(int(self.contact_id)) - - if not contact_data: - self.app.sub_title = f"Contact {self.contact_id} not found" - return - - # Populate form fields - self.query_one("#contact-name", Input).value = contact_data["name"] - - if contact_data.get("title"): - self.query_one("#contact-title", Input).value = contact_data["title"] - - if contact_data.get("email"): - self.query_one("#contact-email", Input).value = contact_data["email"] - - if contact_data.get("phone"): - self.query_one("#contact-phone", Input).value = contact_data["phone"] - - if contact_data.get("notes"): - self.query_one("#contact-notes", TextArea).text = contact_data["notes"] - - # Set company if available - company_select = self.query_one("#company-select", Select) - if contact_data.get("company") and contact_data["company"].get("id"): - # Convert company ID to string to match options format - company_id_str = str(contact_data["company"]["id"]) - - # Check if this value exists in the options - valid_options = [option[1] for option in company_select.options] - if company_id_str in valid_options: - company_select.value = company_id_str - else: - # If company ID is not in options, just leave as default - self.app.sub_title = f"Warning: Company ID {company_id_str} not found in options" - - except Exception as e: - self.app.sub_title = f"Error loading contact: {str(e)}" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "save-contact": - self.save_contact() - - elif button_id == "close-form": - self.app.pop_screen() - - elif button_id == "new-company": - from src.tui.tabs.companies.company_form import CompanyForm - - self.app.push_screen(CompanyForm(on_saved=self.load_companies)) - - def save_contact(self) -> None: - """Save the contact data.""" - try: - # Get values from form - name = self.query_one("#contact-name", Input).value - title = self.query_one("#contact-title", Input).value - company_id = self.query_one("#company-select", Select).value or None - email = self.query_one("#contact-email", Input).value - phone = self.query_one("#contact-phone", Input).value - notes = self.query_one("#contact-notes", TextArea).text - - # Validate required fields - if not name: - self.app.sub_title = "Contact name is required" - return - - # Prepare data - contact_data = { - "name": name, - "title": title or None, - "company_id": int(company_id) if company_id else None, - "email": email or None, - "phone": phone or None, - "notes": notes or None, - } - - service = ContactService() - - if self.contact_id: - # Update existing contact - service.update(int(self.contact_id), contact_data) - self.app.sub_title = "Contact updated successfully" - else: - # Create new contact - service.create(contact_data) - self.app.sub_title = "Contact created successfully" - - # Call the on_saved callback if provided - if self.on_saved: - self.on_saved() - - # Return to the previous screen - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving contact: {str(e)}" diff --git a/src/tui/tabs/contacts/contacts.py b/src/tui/tabs/contacts/contacts.py deleted file mode 100644 index b43b678..0000000 --- a/src/tui/tabs/contacts/contacts.py +++ /dev/null @@ -1,345 +0,0 @@ -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Button, DataTable, Input, Select, Static - -from src.services.company_service import CompanyService -from src.services.contact_service import ContactService -from src.tui.tabs.contacts.contact_detail import ContactDetailScreen -from src.tui.tabs.contacts.contact_form import ContactForm -from src.tui.widgets.confirmation_modal import ConfirmationModal -from src.tui.widgets.list_view import ListView - - -class ContactsList(ListView): - """Contacts listing and management screen.""" - - BINDINGS = [ - ("a", "add_contact", "Add Contact"), - ("d", "delete_contact", "Delete"), - ("e", "edit_contact", "Edit"), - ("v", "view_contact", "View"), - ] - - def __init__(self) -> None: - """Initialize the contacts listing.""" - columns = ["ID", "Name", "Title", "Company", "Email", "Phone"] - super().__init__( - service=ContactService(), - columns=columns, - title="Contacts", - ) - self.sort_column = "Name" - self.sort_ascending = True - self.companies = [] - self.company_filter = None - - def compose(self) -> ComposeResult: - """Compose the contacts screen layout.""" - with Container(classes="list-view-container"): - # Header section - with Container(classes="list-header"): - # Filter bar - with Horizontal(classes="filter-bar"): - with Vertical(classes="filter-section"): - yield Static("Company:", classes="filter-label") - yield Select(id="company-filter", options=[], classes="filter-dropdown") - - # Search section - with Horizontal(classes="search-section"): - yield Input( - placeholder="Search contacts...", - id="contact-search", - classes="search-box", - ) - yield Button("🔍", id="search-button") - - # Quick action buttons - with Horizontal(classes="action-section"): - yield Button("New Contact", variant="primary", id="new-contact") - - # Table section - with Container(classes="table-container"): - yield DataTable(id="contacts-table") - - # Footer section - with Horizontal(classes="list-footer"): - with Horizontal(classes="action-buttons"): - yield Button("View", id="view-contact", disabled=True) - yield Button("Edit", id="edit-contact", disabled=True) - yield Button("Delete", id="delete-contact", variant="error", disabled=True) - - def on_mount(self) -> None: - """Set up the screen when mounted.""" - table = self.query_one("#contacts-table", DataTable) - table.add_columns( - "ID", - "Name", - "Title", - "Company", - "Email", - "Phone", - ) - table.cursor_type = "row" - table.can_focus = True - table.zebra_stripes = True - - # Enable sorting - table.sort_column_click = True - - # Load companies for filtering first, before trying to load contacts - try: - self.load_companies() - # Contacts will be loaded after companies are loaded - except Exception as e: - self.update_status(f"Error during mount: {str(e)}") - - def load_companies(self) -> None: - """Load companies for filtering.""" - try: - service = CompanyService() - self.companies = service.get_all() - - # Create options list - company_values = [(company["name"], str(company["id"])) for company in self.companies] - company_values.insert(0, ("All Companies", "All")) - company_values.append(("No Company", "None")) - - company_select = self.query_one("#company-filter", Select) - company_select.set_options(company_values) - # Set default value after options are added - company_select.value = "All" - - # Now load contacts after company filter is set up - self.load_contacts() - - except Exception as e: - self.update_status(f"Error loading companies: {str(e)}") - - def load_contacts(self, company_id: str = None) -> None: - """Load contacts from the database.""" - self.update_status("Loading contacts...") - self.company_filter = company_id - - try: - service = ContactService() - - filter_company_id = ( - None if not company_id or company_id == "All" or company_id == "None" else int(company_id) - ) - - contacts = service.get_contacts(company_id=filter_company_id) - - table = self.query_one("#contacts-table", DataTable) - table.clear() - - # Convert to list for sorting - contacts_list = list(contacts) - - if not contacts_list: - # Handle empty state - table.add_column_span = len(table.columns) - self.update_status("No contacts found") - - # Disable action buttons - self._disable_action_buttons() - return - - # Apply current sort settings - self._sort_contacts(contacts_list) - - for contact in contacts_list: - table.add_row( - str(contact["id"]), - contact["name"], - contact.get("title", ""), - contact.get("company", {}).get("name", ""), - contact.get("email", ""), - contact.get("phone", ""), - ) - - self.update_status(f"Loaded {len(contacts)} contacts") - except Exception as e: - self.update_status(f"Error loading contacts: {str(e)}") - - def on_select_changed(self, event: Select.Changed) -> None: - """Handle company filter changes.""" - if event.select.id == "company-filter": - company_id = event.value - self.load_contacts(company_id) - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle search input submission.""" - if event.input.id == "contact-search": - self.search_contacts(event.input.value) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - table = self.query_one("#contacts-table", DataTable) - - if button_id == "new-contact": - self.app.push_screen(ContactForm(on_saved=self.load_contacts)) - - elif button_id == "view-contact" and table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ContactDetailScreen(int(contact_id))) - - elif button_id == "edit-contact" and table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ContactForm(contact_id=contact_id, on_saved=self.load_contacts)) - - elif button_id == "delete-contact" and table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_contact(contact_id) - - elif button_id == "search-button": - search_term = self.query_one("#contact-search", Input).value - self.search_contacts(search_term) - - def search_contacts(self, search_term: str) -> None: - """Search contacts by name, email, or company.""" - if not search_term: - self.load_contacts(self.company_filter) - return - - self.update_status(f"Searching for '{search_term}'...") - - try: - service = ContactService() - results = service.search_contacts(search_term) - - # Apply company filter if active - if self.company_filter and self.company_filter != "All": - if self.company_filter == "None": - # Filter for contacts without company - results = [c for c in results if not c.get("company")] - else: - filter_id = int(self.company_filter) - results = [c for c in results if c.get("company", {}).get("id") == filter_id] - - table = self.query_one("#contacts-table", DataTable) - table.clear() - - if not results: - # Handle empty search results - self.update_status(f"No contacts found matching '{search_term}'") - self._disable_action_buttons() - return - - for contact in results: - table.add_row( - str(contact["id"]), - contact["name"], - contact.get("title", ""), - contact.get("company", {}).get("name", ""), - contact.get("email", ""), - contact.get("phone", ""), - ) - - self.update_status(f"Found {len(results)} contacts matching '{search_term}'") - - except Exception as e: - self.update_status(f"Search error: {str(e)}") - - def update_status(self, message: str) -> None: - """Update status message in the footer.""" - self.app.sub_title = message - - def _disable_action_buttons(self) -> None: - """Disable all action buttons.""" - view_btn = self.query_one("#view-contact", Button) - edit_btn = self.query_one("#edit-contact", Button) - delete_btn = self.query_one("#delete-contact", Button) - - view_btn.disabled = True - edit_btn.disabled = True - delete_btn.disabled = True - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - """Enable buttons when a row is highlighted.""" - view_btn = self.query_one("#view-contact", Button) - edit_btn = self.query_one("#edit-contact", Button) - delete_btn = self.query_one("#delete-contact", Button) - - view_btn.disabled = False - edit_btn.disabled = False - delete_btn.disabled = False - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Open the contact detail view when a row is selected.""" - table = self.query_one("#contacts-table", DataTable) - contact_id = int(table.get_row(event.row_key)[0]) - self.app.push_screen(ContactDetailScreen(contact_id, on_updated=self.load_contacts)) - - def _sort_contacts(self, contacts): - """Sort contacts based on current sort settings.""" - # Define sort keys for each column - sort_keys = { - "ID": lambda c: int(c["id"]), - "Name": lambda c: c["name"].lower(), - "Title": lambda c: (c.get("title") or "").lower(), - "Company": lambda c: c.get("company", {}).get("name", "").lower(), - "Email": lambda c: (c.get("email") or "").lower(), - "Phone": lambda c: (c.get("phone") or "").lower(), - } - - # Get sort key function - sort_key = sort_keys.get(self.sort_column, lambda c: c["name"].lower()) - - # Sort contacts - contacts.sort(key=sort_key, reverse=not self.sort_ascending) - - def _confirm_delete_contact(self, contact_id: str) -> None: - """Show confirmation dialog for contact deletion.""" - - def do_delete(): - try: - service = ContactService() - success = service.delete(int(contact_id)) - - if success: - self.app.sub_title = f"Successfully deleted contact #{contact_id}" - self.load_contacts(self.company_filter) - else: - self.app.sub_title = f"Contact #{contact_id} not found" - except Exception as e: - self.app.sub_title = f"Error deleting contact: {str(e)}" - - self.app.push_screen( - ConfirmationModal( - title="Confirm Deletion", - message=( - f"Are you sure you want to delete contact #{contact_id}?\n\n" - "This will permanently remove the contact from the database." - ), - confirm_text="Delete", - cancel_text="Cancel", - on_confirm=do_delete, - dangerous=True, - ) - ) - - def action_add_contact(self) -> None: - """Open the contact creation form.""" - self.app.push_screen(ContactForm(on_saved=self.load_contacts)) - - def action_delete_contact(self) -> None: - """Delete the selected contact.""" - table = self.query_one("#contacts-table", DataTable) - if table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self._confirm_delete_contact(contact_id) - - def action_edit_contact(self) -> None: - """Edit the selected contact.""" - table = self.query_one("#contacts-table", DataTable) - if table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ContactForm(contact_id=contact_id, on_saved=self.load_contacts)) - - def action_view_contact(self) -> None: - """View the selected contact.""" - table = self.query_one("#contacts-table", DataTable) - if table.cursor_row is not None: - contact_id = table.get_row_at(table.cursor_row)[0] - self.app.push_screen(ContactDetailScreen(int(contact_id))) diff --git a/src/tui/tabs/dashboard/__init__.py b/src/tui/tabs/dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tui/tabs/dashboard/dashboard.py b/src/tui/tabs/dashboard/dashboard.py deleted file mode 100644 index cac0a32..0000000 --- a/src/tui/tabs/dashboard/dashboard.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Dashboard screen for the Job Tracker TUI.""" - -from textual.app import ComposeResult -from textual.containers import Container, Grid, Horizontal, Vertical -from textual.widgets import Button, Static, TabbedContent - -from src.services.application_service import ApplicationService -from src.tui.widgets.application_list import ApplicationList -from src.tui.widgets.stats_card import StatsCard - - -class Dashboard(Static): - """Main dashboard view.""" - - def compose(self) -> ComposeResult: - """Compose the dashboard layout.""" - with Container(id="dashboard-container"): - # Header with welcome and summary - with Container(id="dashboard-header", classes="content-box"): - yield Static("Your Job Search Dashboard", id="dashboard-title") - - with Horizontal(id="quick-actions"): - yield Button("➕ New Application", variant="primary", id="new-app") - - # Stats summary cards - with Container(id="stats-section", classes="content-box"): - yield Static("Overview", classes="section-heading") - - with Grid(id="stats-grid"): - yield StatsCard("Total Applications", "0", _id="total-apps") - yield StatsCard("Applied", "0", _id="applied-apps") - yield StatsCard("Interviews", "0", _id="interview-apps") - yield StatsCard("Offers", "0", _id="offer-apps") - - # Main content in two columns - with Horizontal(id="dashboard-content"): - # Left column - Recent applications and activity - with Vertical(id="left-column"): - with Container(classes="content-box-full"): - yield Static("Recent Applications", classes="section-heading") - yield ApplicationList(title="", _id="recent-apps-list") - yield Button("View All Applications", id="view-all-apps") - - # Right column - Reminders and progress - with Vertical(id="right-column"): - with Container(classes="content-box-full"): - yield Static("Recent Activity", classes="section-heading") - with Container(id="activity-feed"): - # Activity items will be added dynamically - pass - - def on_mount(self) -> None: - """Load dashboard data when mounted.""" - - self.update_status("Fetching dashboard data...") - - try: - service = ApplicationService() - stats = service.get_dashboard_stats() - - # Update stats cards - total_card = self.query_one("#total-apps", StatsCard) - applied_card = self.query_one("#applied-apps", StatsCard) - interview_card = self.query_one("#interview-apps", StatsCard) - offer_card = self.query_one("#offer-apps", StatsCard) - - total_card.update_value(str(stats["total_applications"])) - - # Find status counts - interview_count = 0 - for status_count in stats["applications_by_status"]: - if status_count["status"] == "APPLIED": - applied_card.update_value(str(status_count["count"])) - elif status_count["status"] in [ - "INTERVIEW", - "PHONE_SCREEN", - "TECHNICAL_INTERVIEW", - ]: - interview_count += status_count["count"] - elif status_count["status"] == "OFFER": - offer_card.update_value(str(status_count["count"])) - - interview_card.update_value(str(interview_count)) - - # Update recent applications list - app_list = self.query_one(ApplicationList) - app_list.update_applications(stats["recent_applications"]) - - self.update_status("Dashboard updated") - except Exception as e: - self.update_status(f"Error: {str(e)}") - - def update_status(self, message: str) -> None: - """Update status message in the footer.""" - self.app.sub_title = message - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - if event.button.id == "new-app": - from src.tui.tabs.applications.application_form import ApplicationForm - - self.app.push_screen(ApplicationForm()) - elif event.button.id == "view-all-apps": - self.app.query_one(TabbedContent).active = "applications" diff --git a/src/tui/tabs/settings/__init__.py b/src/tui/tabs/settings/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tui/tabs/settings/settings.py b/src/tui/tabs/settings/settings.py deleted file mode 100644 index 08fe631..0000000 --- a/src/tui/tabs/settings/settings.py +++ /dev/null @@ -1,216 +0,0 @@ -import os - -from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Container, Grid, Horizontal -from textual.screen import Screen -from textual.widgets import Button, Footer, Header, Input, Label, Select, Static, Switch - -from src.config import LOG_DIR, LOG_LEVEL -from src.db.database import change_database -from src.db.settings import Settings -from src.tui.widgets.file_dialog import FileDialog - - -class SettingsScreen(Screen): - """Screen for application settings.""" - - BINDINGS = [Binding("escape", "cancel", "Back"), Binding("f1", "help", "Help")] - - def __init__(self): - super().__init__() - self.settings = Settings() - - def compose(self) -> ComposeResult: - """Compose the settings screen.""" - with Container(): - yield Header(show_clock=True) - - yield Static("Settings", id="settings-title") - - with Container(id="settings-content"): - # Database Settings - yield Static("Database Settings", classes="settings-section-title") - - with Grid(id="db-settings-grid"): - yield Label("Database Path:") - - with Horizontal(): - yield Input(id="db-path", classes="path-input") - yield Button("Browse...", id="browse-db") - - yield Label("Current Status:") - yield Static("", id="db-status") - - yield Label("") - yield Button("Apply Database Changes", id="apply-db-changes") - - # General Settings - yield Static("General Settings", classes="settings-section-title") - - with Grid(id="general-settings-grid"): - yield Label("Check for updates:") - yield Switch(id="updates-switch") - - yield Label("Save window size:") - yield Switch(id="window-size-switch") - - yield Static("Logging Settings", classes="settings-section-title") - - with Grid(id="logging-settings-grid"): - yield Label("Log Level:") - yield Select( - [ - ("DEBUG", "DEBUG"), - ("INFO", "INFO"), - ("WARNING", "WARNING"), - ("ERROR", "ERROR"), - ("CRITICAL", "CRITICAL"), - ], - id="log-level-select", - value=LOG_LEVEL, - ) - - yield Label("Log Directory:") - yield Static(str(LOG_DIR), id="log-dir-display") - - yield Label("") - yield Button("View Logs", id="view-logs") - - # Action buttons - with Horizontal(id="settings-actions"): - yield Button("Save Settings", variant="primary", id="save-settings") - yield Button("Cancel", id="cancel-settings") - - yield Footer() - - def on_mount(self) -> None: - """Load settings when the screen is mounted.""" - # Load database path - db_path = self.settings.get("database_path") - self.query_one("#db-path", Input).value = db_path - - # Set database status - if self.settings.database_exists(): - self.query_one("#db-status", Static).update("Database file exists") - self.query_one("#db-status").styles.color = "green" - else: - self.query_one("#db-status", Static).update("Database file does not exist") - self.query_one("#db-status").styles.color = "red" - - self.query_one("#updates-switch", Switch).value = self.settings.get("check_updates", True) - self.query_one("#window-size-switch", Switch).value = self.settings.get("save_window_size", True) - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "browse-db": - self.select_database_path() - - elif button_id == "apply-db-changes": - self.apply_database_changes() - - elif button_id == "view-logs": - self.view_logs() - - elif button_id == "save-settings": - self.save_all_settings() - - elif button_id == "cancel-settings": - self.app.pop_screen() - - def select_database_path(self) -> None: - """Open file dialog to select database path.""" - - def on_file_selected(path: str) -> None: - if path: - self.query_one("#db-path", Input).value = path - - # Get initial directory from current path - current_path = self.query_one("#db-path", Input).value - initial_dir = os.path.dirname(os.path.expanduser(current_path)) - - # Use FileDialog to select path - self.app.push_screen( - FileDialog( - title="Select Database Location", - path=initial_dir, - file_filter=lambda path: path.suffix == ".db" or path.is_dir(), - mode="save", - callback=on_file_selected, - ) - ) - - def apply_database_changes(self) -> None: - """Apply database path changes.""" - new_db_path = self.query_one("#db-path", Input).value - - # Expand user path if needed - if new_db_path.startswith("~"): - new_db_path = os.path.expanduser(new_db_path) - - try: - # Check if directory exists or can be created - db_dir = os.path.dirname(new_db_path) - os.makedirs(db_dir, exist_ok=True) - - # Change database path - if change_database(new_db_path): - self.app.sub_title = f"Database changed to {new_db_path}" - - # Update status - if os.path.exists(new_db_path): - self.query_one("#db-status", Static).update("Database file exists") - self.query_one("#db-status").styles.color = "green" - else: - self.query_one("#db-status", Static).update("New database created") - self.query_one("#db-status").styles.color = "green" - else: - self.app.sub_title = "Failed to change database" - self.query_one("#db-status", Static).update("Error changing database") - self.query_one("#db-status").styles.color = "red" - - except Exception as e: - self.app.sub_title = f"Error: {str(e)}" - self.query_one("#db-status", Static).update(f"Error: {str(e)}") - self.query_one("#db-status").styles.color = "red" - - def view_logs(self) -> None: - """Open log directory or file.""" - import webbrowser - - # Use platform-specific file browser - try: - log_dir = str(LOG_DIR) - if os.path.exists(log_dir): - webbrowser.open(f"file://{log_dir}") - self.app.sub_title = f"Opening log directory: {log_dir}" - else: - self.app.sub_title = "Log directory does not exist yet" - except Exception as e: - self.app.sub_title = f"Error opening logs: {str(e)}" - - def save_all_settings(self) -> None: - """Save all settings.""" - try: - # Get values from form - db_path = self.query_one("#db-path", Input).value - - check_updates = self.query_one("#updates-switch", Switch).value - save_window_size = self.query_one("#window-size-switch", Switch).value - - # Save all settings - self.settings.set("database_path", db_path) - self.settings.set("check_updates", check_updates) - self.settings.set("save_window_size", save_window_size) - - self.app.sub_title = "Settings saved" - self.app.pop_screen() - - except Exception as e: - self.app.sub_title = f"Error saving settings: {str(e)}" - - def action_cancel(self) -> None: - """Handle escape key to go back.""" - self.app.pop_screen() diff --git a/src/tui/widgets/__init__.py b/src/tui/widgets/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tui/widgets/application_list.py b/src/tui/widgets/application_list.py deleted file mode 100644 index dcc148b..0000000 --- a/src/tui/widgets/application_list.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Custom widget for displaying a list of applications.""" - -from typing import Any - -from textual.app import ComposeResult -from textual.widgets import DataTable, Static - - -class ApplicationList(Static): - """A widget displaying a list of applications.""" - - def __init__(self, title: str, _id: str = None): - """Initialize the application list. - - Args: - title: The title to display - _id: Optional widget ID - """ - super().__init__(id=_id) - self.title = title - - def compose(self) -> ComposeResult: - """Compose the widget.""" - yield DataTable(id=f"{self.id}-table" if self.id else None) - - def on_mount(self) -> None: - """Set up the table when mounted.""" - table = self.query_one(DataTable) - table.add_columns("Job Title", "Company", "Status", "Applied Date") - table.cursor_type = "row" - - def update_applications(self, applications: list[dict[str, Any]]) -> None: - """Update the applications displayed in the list.""" - table = self.query_one(DataTable) - table.clear() - - if not applications: - table.add_row("No applications found", "", "", "") - return - - for app in applications: - company_name = app.get("company", {}).get("name", "") - table.add_row(app["job_title"], company_name, app["status"], app["applied_date"]) diff --git a/src/tui/widgets/confirmation_modal.py b/src/tui/widgets/confirmation_modal.py deleted file mode 100644 index 9de885a..0000000 --- a/src/tui/widgets/confirmation_modal.py +++ /dev/null @@ -1,69 +0,0 @@ -from collections.abc import Callable - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal -from textual.screen import ModalScreen -from textual.widgets import Button, Label, Static - - -class ConfirmationModal(ModalScreen): - """Reusable confirmation dialog.""" - - def __init__( - self, - title: str, - message: str, - confirm_text: str = "Confirm", - cancel_text: str = "Cancel", - on_confirm: Callable | None = None, - on_cancel: Callable | None = None, - dangerous: bool = False, - ): - """Initialize the confirmation dialog. - - Args: - title: The title of the dialog - message: The message to display - confirm_text: Text for the confirm button - cancel_text: Text for the cancel button - on_confirm: Callback to run when confirmed - on_cancel: Callback to run when canceled - dangerous: Whether this is a dangerous action (changes button styling) - """ - super().__init__() - self.title_text = title - self.message = message - self.confirm_text = confirm_text - self.cancel_text = cancel_text - self.on_confirm = on_confirm - self.on_cancel = on_cancel - self.dangerous = dangerous - - def compose(self) -> ComposeResult: - """Compose the dialog layout.""" - with Container(id="confirmation-modal"): - yield Static(self.title_text, id="modal-title") - yield Label(self.message, id="modal-message") - - with Horizontal(id="modal-buttons"): - yield Button(self.cancel_text, id="cancel-button", variant="primary") - - # Use different styling for dangerous actions - if self.dangerous: - yield Button(self.confirm_text, id="confirm-button", variant="error") - else: - yield Button(self.confirm_text, id="confirm-button", variant="default") - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "cancel-button": - if self.on_cancel: - self.on_cancel() - self.app.pop_screen() - - elif button_id == "confirm-button": - if self.on_confirm: - self.on_confirm() - self.app.pop_screen() diff --git a/src/tui/widgets/file_dialog.py b/src/tui/widgets/file_dialog.py deleted file mode 100644 index bb46715..0000000 --- a/src/tui/widgets/file_dialog.py +++ /dev/null @@ -1,221 +0,0 @@ -import os -from collections.abc import Callable -from pathlib import Path - -from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Container, Horizontal -from textual.screen import ModalScreen -from textual.widgets import Button, DataTable, DirectoryTree, Input, Label - - -class FileDialog(ModalScreen): - """File dialog for selecting files or directories.""" - - BINDINGS = [Binding("escape", "cancel", "Cancel")] - - def __init__( - self, - title: str = "Select File", - path: str = "~", - file_filter: Callable[[Path], bool] = None, - mode: str = "open", # "open", "save", "directory" - callback: Callable[[str], None] = None, - ): - """Initialize file dialog. - - Args: - title: Dialog title - path: Initial path - file_filter: Function to filter files (returns True to include) - mode: Dialog mode ('open', 'save', or 'directory') - callback: Function to call with selected path - """ - super().__init__() - self.title_text = title - self.initial_path = os.path.expanduser(path) - self.file_filter = file_filter or (lambda p: True) - self.mode = mode - self.callback = callback - self.current_path = self.initial_path - - def compose(self) -> ComposeResult: - """Compose the file dialog.""" - with Container(id="file-dialog"): - yield Label(self.title_text, id="dialog-title") - - with Horizontal(id="file-path"): - yield Label("Location:", classes="field-label") - yield Input(id="path-input", value=self.initial_path) - yield Button("↑", id="parent-dir") - - # When saving, we need a filename input - if self.mode == "save": - with Horizontal(id="file-name"): - yield Label("Filename:", classes="field-label") - yield Input(id="name-input") - - # File listing area - if self.mode == "directory": - # Show directory tree - yield DirectoryTree(self.initial_path, id="directory-tree") - else: - # Show file table - yield DataTable(id="file-table") - - with Horizontal(id="dialog-buttons"): - yield Button("Cancel", id="cancel-dialog") - if self.mode == "open": - yield Button("Open", variant="primary", id="confirm-dialog") - elif self.mode == "save": - yield Button("Save", variant="primary", id="confirm-dialog") - elif self.mode == "directory": - yield Button("Select Directory", variant="primary", id="confirm-dialog") - - def on_mount(self) -> None: - """Load files when mounted.""" - if self.mode != "directory": - # Set up file table - table = self.query_one("#file-table", DataTable) - table.add_columns("Name", "Type", "Size") - table.cursor_type = "row" - - # Load files - self.load_files(self.initial_path) - - def load_files(self, path: str) -> None: - """Load files from the given path.""" - try: - path = os.path.expanduser(path) - if not os.path.isdir(path): - path = os.path.dirname(path) or "." - - self.current_path = path - self.query_one("#path-input", Input).value = path - - table = self.query_one("#file-table", DataTable) - table.clear() - - # Add parent directory option - parent = os.path.dirname(path) - if parent != path: - table.add_row("..", "Directory", "") - - # Add directories first - for item in sorted(Path(path).iterdir()): - if item.is_dir(): - table.add_row(f"{item.name}/", "Directory", "") - - # Then add files - for item in sorted(Path(path).iterdir()): - if item.is_file() and self.file_filter(item): - size_str = self._format_size(item.stat().st_size) - file_type = item.suffix[1:].upper() if item.suffix else "File" - table.add_row(item.name, file_type, size_str) - - except Exception as e: - self.app.sub_title = f"Error loading files: {str(e)}" - - def _format_size(self, size_bytes: int) -> str: - """Format file size in human-readable form.""" - for unit in ["B", "KB", "MB", "GB"]: - if size_bytes < 1024.0 or unit == "GB": - break - size_bytes /= 1024.0 - return f"{size_bytes:.2f} {unit}" - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle row selection in file table.""" - table = self.query_one("#file-table", DataTable) - row = table.get_row_at(event.row_key) - item_name = row[0] - item_type = row[1] - - if item_type == "Directory": - # Handle directory navigation - if item_name == "..": - path = os.path.dirname(self.current_path) - else: - path = os.path.join(self.current_path, item_name.rstrip("/")) - - self.load_files(path) - - elif self.mode == "open": - # For open mode, select file and return immediately - full_path = os.path.join(self.current_path, item_name) - if self.callback: - self.callback(full_path) - self.app.pop_screen() - - elif self.mode == "save": - # For save mode, just fill in the filename - self.query_one("#name-input", Input).value = item_name - - def on_directory_tree_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None: - """Handle directory selection in directory tree.""" - if self.mode == "directory": - self.current_path = str(event.path) - self.query_one("#path-input", Input).value = self.current_path - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "cancel-dialog": - self.app.pop_screen() - - elif button_id == "parent-dir": - # Navigate to parent directory - parent = os.path.dirname(self.current_path) - - if self.mode == "directory": - tree = self.query_one("#directory-tree", DirectoryTree) - tree.path = parent - self.current_path = parent - self.query_one("#path-input", Input).value = parent - else: - self.load_files(parent) - - elif button_id == "confirm-dialog": - # Return selected path based on mode - result_path = None - - if self.mode == "open": - table = self.query_one("#file-table", DataTable) - if table.cursor_row is not None: - row = table.get_row_at(table.cursor_row) - item_name = row[0] - item_type = row[1] - - if item_type != "Directory": - result_path = os.path.join(self.current_path, item_name) - - elif self.mode == "save": - filename = self.query_one("#name-input", Input).value - if filename: - result_path = os.path.join(self.current_path, filename) - - elif self.mode == "directory": - result_path = self.current_path - - # Call callback with result - if result_path and self.callback: - self.callback(result_path) - - self.app.pop_screen() - - def action_cancel(self) -> None: - """Handle escape key.""" - self.app.pop_screen() - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle path input submission.""" - if event.input.id == "path-input": - path = event.input.value - if os.path.isdir(path): - if self.mode == "directory": - tree = self.query_one("#directory-tree", DirectoryTree) - tree.path = path - self.current_path = path - else: - self.load_files(path) diff --git a/src/tui/widgets/list_view.py b/src/tui/widgets/list_view.py deleted file mode 100644 index 117d1e2..0000000 --- a/src/tui/widgets/list_view.py +++ /dev/null @@ -1,134 +0,0 @@ -from typing import Any - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal -from textual.widgets import Button, DataTable, Input, Static - -from src.services.base_service import BaseService - - -class ListView(Static): - """Base class for list views like ApplicationsList, CompaniesList, etc.""" - - def __init__( - self, - service: BaseService, - columns: list[str], - title: str = "Items", - _id: str | None = None, - ): - """Initialize the list view. - - Args: - service: Service class to use for data operations - columns: List of columns to display - title: Title of the list view - _id: Optional widget ID - """ - super().__init__(id=_id) - self.service = service - self.columns = columns - self.title = title - self.sort_column = columns[0] if columns else "id" - self.sort_ascending = True - - def compose(self) -> ComposeResult: - """Compose the list view layout.""" - with Container(classes="list-view-container"): - # Header section - with Container(classes="list-header"): - yield Static(self.title, classes="list-title") - - # Quick action buttons - with Horizontal(classes="action-section"): - yield Button( - f"New {self.service.entity_name.capitalize()}", - variant="primary", - id=f"new-{self.service.entity_name}", - ) - - # Search section - yield Input( - placeholder=f"Search {self.title.lower()}...", - id="search-input", - classes="search-box", - ) - yield Button("🔍", id="search-button") - - # Table section - with Container(classes="table-container"): - yield DataTable(id=f"{self.service.entity_name.lower()}-table") - - # Footer section - with Horizontal(classes="list-footer"): - with Horizontal(classes="action-buttons"): - yield Button("View", id="view-item", disabled=True) - yield Button("Edit", id="edit-item", disabled=True) - yield Button("Delete", id="delete-item", variant="error", disabled=True) - - def on_mount(self) -> None: - """Set up the screen when mounted.""" - table = self.query_one(DataTable) - - # Only add columns if they don't already exist to prevent duplication - if not table.columns: - table.add_columns(*self.columns) - - table.cursor_type = "row" - table.can_focus = True - table.zebra_stripes = True - - # Enable sorting - table.sort_column_click = True - - self.load_data() - - def load_data(self) -> None: - """Load data from the service.""" - self.update_status(f"Loading {self.title.lower()}...") - - try: - items = self.service.get_all( - sort_by=self.sort_column.lower().replace(" ", "_"), - sort_desc=not self.sort_ascending, - limit=50, - ) - - table = self.query_one(DataTable) - table.clear() - - if not items: - self.update_status(f"No {self.title.lower()} found") - self._disable_actions() - return - - for item in items: - # This method needs to be implemented by subclasses to format rows - row_data = self._format_row(item) - table.add_row(*row_data) - - self.update_status(f"Loaded {len(items)} {self.title.lower()}") - - except Exception as e: - self.update_status(f"Error loading data: {str(e)}") - - def _format_row(self, item: dict[str, Any]) -> list[str]: - """Format an item for display in the table. - - Subclasses should implement this method to format items for their specific needs. - """ - raise NotImplementedError("Subclasses must implement _format_row") - - def update_status(self, message: str) -> None: - """Update status message in the footer.""" - self.app.sub_title = message - - def _disable_actions(self) -> None: - """Disable action buttons.""" - view_btn = self.query_one("#view-item", Button) - edit_btn = self.query_one("#edit-item", Button) - delete_btn = self.query_one("#delete-item", Button) - - view_btn.disabled = True - edit_btn.disabled = True - delete_btn.disabled = True diff --git a/src/tui/widgets/stats_card.py b/src/tui/widgets/stats_card.py deleted file mode 100644 index e703cbc..0000000 --- a/src/tui/widgets/stats_card.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Custom widget for displaying statistics.""" - -from textual.app import ComposeResult -from textual.widgets import Static - - -class StatsCard(Static): - """A card displaying a statistic with a label and value.""" - - def __init__(self, title: str, value: str, _id: str = None): - """Initialize the stats card. - - Args: - title: The label to display - value: The value to display - _id: Optional widget ID - """ - super().__init__(id=_id) - self.title = title - self.value = value - - def compose(self) -> ComposeResult: - """Compose the widget.""" - yield Static(self.title, classes="stats-title") - yield Static(self.value, classes="stats-value") - - def update_value(self, value: str) -> None: - """Update the displayed value.""" - self.query_one(".stats-value", Static).update(value) diff --git a/src/utils/decorators.py b/src/utils/decorators.py index 331ab97..cce6171 100644 --- a/src/utils/decorators.py +++ b/src/utils/decorators.py @@ -1,24 +1,39 @@ import functools import traceback +from collections.abc import Callable +from typing import Any, TypeVar, cast from src.db.database import get_session from src.utils.logging import get_logger logger = get_logger(__name__) +F = TypeVar("F", bound=Callable[..., Any]) -def db_operation(func): - """Decorator for database operations with consistent error handling.""" + +def db_operation(func: Callable[..., F]) -> Callable[..., F]: + """Decorator to handle database session management for service methods. + + This decorator creates a database session, passes it to the decorated function, + and handles commit/rollback as appropriate. + + Args: + func: The function to decorate. + + Returns: + The decorated function. + """ @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: session = get_session() try: result = func(*args, **kwargs, session=session) + session.commit() return result except Exception as e: + logger.error(f"Error in {func.__name__}: {e}", exc_info=True) session.rollback() - logger.error(f"Database error in {func.__name__}: {e}", exc_info=True) raise finally: session.close() @@ -26,11 +41,11 @@ def wrapper(*args, **kwargs): return wrapper -def error_handler(func): +def error_handler(func: F) -> F: """Decorator to handle and log errors in UI operations.""" @functools.wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: try: return func(self, *args, **kwargs) except Exception as e: @@ -49,4 +64,4 @@ def wrapper(self, *args, **kwargs): # Re-raise if needed (can be commented out to prevent crashes) # raise - return wrapper + return cast(F, wrapper) diff --git a/src/utils/enums.py b/src/utils/enums.py new file mode 100644 index 0000000..f00b159 --- /dev/null +++ b/src/utils/enums.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class ChangeType(str, Enum): + """Types of changes that can be recorded.""" + + STATUS_CHANGE = "STATUS_CHANGE" + NOTE_ADDED = "NOTE_ADDED" + CONTACT_ADDED = "CONTACT_ADDED" + CONTACT_REMOVED = "CONTACT_REMOVED" + APPLICATION_UPDATED = "APPLICATION_UPDATED" + DOCUMENT_ADDED = "DOCUMENT_ADDED" + INTERACTION_ADDED = "INTERACTION_ADDED" + INTERACTION_UPDATED = "INTERACTION_UPDATED" + INTERACTION_DELETED = "INTERACTION_DELETED" diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py new file mode 100644 index 0000000..a65cd73 --- /dev/null +++ b/test/e2e/conftest.py @@ -0,0 +1,20 @@ +import sys +import pytest +from PyQt6.QtWidgets import QApplication +from src.gui.main_window import MainWindow + + +@pytest.fixture(scope="session") +def app(): + """Create a QApplication instance for testing.""" + app = QApplication(sys.argv) + yield app + app.quit() + + +@pytest.fixture +def main_window(app): + """Create and return a MainWindow instance for testing.""" + window = MainWindow() + window.show() + return window \ No newline at end of file diff --git a/test/e2e/dialogs/test_search_dialog.py b/test/e2e/dialogs/test_search_dialog.py new file mode 100644 index 0000000..3d3e8d8 --- /dev/null +++ b/test/e2e/dialogs/test_search_dialog.py @@ -0,0 +1,25 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.search import SearchDialog + + +class TestSearchDialog: + """Test suite for the Search dialog functionality.""" + + def test_dialog_initialization(self, main_window): + """Test that the Search dialog initializes correctly.""" + dialog = SearchDialog(main_window) + assert dialog.windowTitle() == "Search Applications" + assert dialog.isVisible() + + def test_dialog_accept(self, main_window): + """Test that the dialog can be accepted.""" + dialog = SearchDialog(main_window) + dialog.accept() + assert not dialog.isVisible() + + def test_dialog_reject(self, main_window): + """Test that the dialog can be rejected.""" + dialog = SearchDialog(main_window) + dialog.reject() + assert not dialog.isVisible() \ No newline at end of file diff --git a/test/e2e/dialogs/test_settings_dialog.py b/test/e2e/dialogs/test_settings_dialog.py new file mode 100644 index 0000000..55c6513 --- /dev/null +++ b/test/e2e/dialogs/test_settings_dialog.py @@ -0,0 +1,25 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.dialogs.settings import SettingsDialog + + +class TestSettingsDialog: + """Test suite for the Settings dialog functionality.""" + + def test_dialog_initialization(self, main_window): + """Test that the Settings dialog initializes correctly.""" + dialog = SettingsDialog(main_window) + assert dialog.windowTitle() == "Settings" + assert dialog.isVisible() + + def test_dialog_accept(self, main_window): + """Test that the dialog can be accepted.""" + dialog = SettingsDialog(main_window) + dialog.accept() + assert not dialog.isVisible() + + def test_dialog_reject(self, main_window): + """Test that the dialog can be rejected.""" + dialog = SettingsDialog(main_window) + dialog.reject() + assert not dialog.isVisible() \ No newline at end of file diff --git a/test/e2e/tabs/test_applications_tab.py b/test/e2e/tabs/test_applications_tab.py new file mode 100644 index 0000000..7d33933 --- /dev/null +++ b/test/e2e/tabs/test_applications_tab.py @@ -0,0 +1,24 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.tabs.applications import ApplicationsTab + + +class TestApplicationsTab: + """Test suite for the Applications tab functionality.""" + + def test_tab_initialization(self, main_window): + """Test that the Applications tab initializes correctly.""" + tab = main_window.applications_tab + assert isinstance(tab, ApplicationsTab) + assert tab.isVisible() + + def test_tab_switching(self, main_window): + """Test switching to the Applications tab.""" + main_window.tabs.setCurrentIndex(1) + assert isinstance(main_window.tabs.currentWidget(), ApplicationsTab) + + def test_refresh_data(self, main_window): + """Test that the tab can refresh its data.""" + tab = main_window.applications_tab + if hasattr(tab, "refresh_data"): + tab.refresh_data() # Should not raise any exceptions \ No newline at end of file diff --git a/test/e2e/tabs/test_companies_tab.py b/test/e2e/tabs/test_companies_tab.py new file mode 100644 index 0000000..7a0dcc5 --- /dev/null +++ b/test/e2e/tabs/test_companies_tab.py @@ -0,0 +1,24 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.tabs.companies import CompaniesTab + + +class TestCompaniesTab: + """Test suite for the Companies tab functionality.""" + + def test_tab_initialization(self, main_window): + """Test that the Companies tab initializes correctly.""" + tab = main_window.companies_tab + assert isinstance(tab, CompaniesTab) + assert tab.isVisible() + + def test_tab_switching(self, main_window): + """Test switching to the Companies tab.""" + main_window.tabs.setCurrentIndex(2) + assert isinstance(main_window.tabs.currentWidget(), CompaniesTab) + + def test_refresh_data(self, main_window): + """Test that the tab can refresh its data.""" + tab = main_window.companies_tab + if hasattr(tab, "refresh_data"): + tab.refresh_data() # Should not raise any exceptions \ No newline at end of file diff --git a/test/e2e/tabs/test_contacts_tab.py b/test/e2e/tabs/test_contacts_tab.py new file mode 100644 index 0000000..865f0b3 --- /dev/null +++ b/test/e2e/tabs/test_contacts_tab.py @@ -0,0 +1,24 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.tabs.contacts import ContactsTab + + +class TestContactsTab: + """Test suite for the Contacts tab functionality.""" + + def test_tab_initialization(self, main_window): + """Test that the Contacts tab initializes correctly.""" + tab = main_window.contacts_tab + assert isinstance(tab, ContactsTab) + assert tab.isVisible() + + def test_tab_switching(self, main_window): + """Test switching to the Contacts tab.""" + main_window.tabs.setCurrentIndex(3) + assert isinstance(main_window.tabs.currentWidget(), ContactsTab) + + def test_refresh_data(self, main_window): + """Test that the tab can refresh its data.""" + tab = main_window.contacts_tab + if hasattr(tab, "refresh_data"): + tab.refresh_data() # Should not raise any exceptions \ No newline at end of file diff --git a/test/e2e/tabs/test_dashboard_tab.py b/test/e2e/tabs/test_dashboard_tab.py new file mode 100644 index 0000000..2bad540 --- /dev/null +++ b/test/e2e/tabs/test_dashboard_tab.py @@ -0,0 +1,24 @@ +import pytest +from PyQt6.QtCore import Qt +from src.gui.tabs.dashboard import DashboardTab + + +class TestDashboardTab: + """Test suite for the Dashboard tab functionality.""" + + def test_tab_initialization(self, main_window): + """Test that the Dashboard tab initializes correctly.""" + tab = main_window.dashboard_tab + assert isinstance(tab, DashboardTab) + assert tab.isVisible() + + def test_tab_switching(self, main_window): + """Test switching to the Dashboard tab.""" + main_window.tabs.setCurrentIndex(0) + assert isinstance(main_window.tabs.currentWidget(), DashboardTab) + + def test_refresh_data(self, main_window): + """Test that the tab can refresh its data.""" + tab = main_window.dashboard_tab + if hasattr(tab, "refresh_data"): + tab.refresh_data() # Should not raise any exceptions \ No newline at end of file diff --git a/test/e2e/windows/test_main_window.py b/test/e2e/windows/test_main_window.py new file mode 100644 index 0000000..e871784 --- /dev/null +++ b/test/e2e/windows/test_main_window.py @@ -0,0 +1,51 @@ +import pytest +from PyQt6.QtGui import QAction +from src.gui.main_window import MainWindow + + +class TestMainWindow: + """Test suite for the main window functionality.""" + + def test_window_initialization(self, main_window): + """Test that the main window initializes correctly with all components.""" + assert main_window.windowTitle() == "JobTrackr" + assert main_window.tabs.count() == 4 + assert main_window.tabs.tabText(0) == "Dashboard" + assert main_window.tabs.tabText(1) == "Applications" + assert main_window.tabs.tabText(2) == "Companies" + assert main_window.tabs.tabText(3) == "Contacts" + + def test_menu_bar_actions(self, main_window): + """Test that menu bar actions work correctly.""" + # Test Settings menu + settings_action = main_window.menu_bar.findChild(QAction, "Settings") + assert settings_action is not None + + # Test About menu + about_action = main_window.menu_bar.findChild(QAction, "About") + assert about_action is not None + + def test_status_bar(self, main_window): + """Test status bar functionality.""" + test_message = "Test status message" + main_window.show_status_message(test_message) + assert main_window.statusBar().currentMessage() == test_message + + def test_error_dialog(self, main_window): + """Test error message dialog.""" + title = "Test Error" + message = "This is a test error message" + main_window.show_error_message(title, message) + + def test_window_resize(self, main_window): + """Test window resize functionality.""" + new_width = 1280 + new_height = 800 + main_window.resize(new_width, new_height) + assert main_window.width() == new_width + assert main_window.height() == new_height + + def test_close_event(self, main_window): + """Test window close event.""" + main_window.close() + assert not main_window.isVisible() \ No newline at end of file diff --git a/uv.lock b/uv.lock index 43e2f27..d25114d 100644 --- a/uv.lock +++ b/uv.lock @@ -3,198 +3,155 @@ revision = 2 requires-python = ">=3.13" [[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload_time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload_time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.11.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/e7/fa1a8c00e2c54b05dc8cb5d1439f627f7c267874e3f7bb047146116020f9/aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a", size = 7678653, upload_time = "2025-04-21T09:43:09.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/18/be8b5dd6b9cf1b2172301dbed28e8e5e878ee687c21947a6c81d6ceaa15d/aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811", size = 699833, upload_time = "2025-04-21T09:42:00.298Z" }, - { url = "https://files.pythonhosted.org/packages/0d/84/ecdc68e293110e6f6f6d7b57786a77555a85f70edd2b180fb1fafaff361a/aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804", size = 462774, upload_time = "2025-04-21T09:42:02.015Z" }, - { url = "https://files.pythonhosted.org/packages/d7/85/f07718cca55884dad83cc2433746384d267ee970e91f0dcc75c6d5544079/aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd", size = 454429, upload_time = "2025-04-21T09:42:03.728Z" }, - { url = "https://files.pythonhosted.org/packages/82/02/7f669c3d4d39810db8842c4e572ce4fe3b3a9b82945fdd64affea4c6947e/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c", size = 1670283, upload_time = "2025-04-21T09:42:06.053Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/b82a12f67009b377b6c07a26bdd1b81dab7409fc2902d669dbfa79e5ac02/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118", size = 1717231, upload_time = "2025-04-21T09:42:07.953Z" }, - { url = "https://files.pythonhosted.org/packages/a6/38/d5a1f28c3904a840642b9a12c286ff41fc66dfa28b87e204b1f242dbd5e6/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1", size = 1769621, upload_time = "2025-04-21T09:42:09.855Z" }, - { url = "https://files.pythonhosted.org/packages/53/2d/deb3749ba293e716b5714dda06e257f123c5b8679072346b1eb28b766a0b/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000", size = 1678667, upload_time = "2025-04-21T09:42:11.741Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a8/04b6e11683a54e104b984bd19a9790eb1ae5f50968b601bb202d0406f0ff/aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137", size = 1601592, upload_time = "2025-04-21T09:42:14.137Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c33305ae8370b789423623f0e073d09ac775cd9c831ac0f11338b81c16e0/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93", size = 1621679, upload_time = "2025-04-21T09:42:16.056Z" }, - { url = "https://files.pythonhosted.org/packages/56/45/8e9a27fff0538173d47ba60362823358f7a5f1653c6c30c613469f94150e/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3", size = 1656878, upload_time = "2025-04-21T09:42:18.368Z" }, - { url = "https://files.pythonhosted.org/packages/84/5b/8c5378f10d7a5a46b10cb9161a3aac3eeae6dba54ec0f627fc4ddc4f2e72/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8", size = 1620509, upload_time = "2025-04-21T09:42:20.141Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2f/99dee7bd91c62c5ff0aa3c55f4ae7e1bc99c6affef780d7777c60c5b3735/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2", size = 1680263, upload_time = "2025-04-21T09:42:21.993Z" }, - { url = "https://files.pythonhosted.org/packages/03/0a/378745e4ff88acb83e2d5c884a4fe993a6e9f04600a4560ce0e9b19936e3/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261", size = 1715014, upload_time = "2025-04-21T09:42:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0b/b5524b3bb4b01e91bc4323aad0c2fcaebdf2f1b4d2eb22743948ba364958/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7", size = 1666614, upload_time = "2025-04-21T09:42:25.764Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b7/3d7b036d5a4ed5a4c704e0754afe2eef24a824dfab08e6efbffb0f6dd36a/aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78", size = 411358, upload_time = "2025-04-21T09:42:27.558Z" }, - { url = "https://files.pythonhosted.org/packages/1e/3c/143831b32cd23b5263a995b2a1794e10aa42f8a895aae5074c20fda36c07/aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01", size = 437658, upload_time = "2025-04-21T09:42:29.209Z" }, -] - -[[package]] -name = "aiohttp-jinja2" -version = "1.6" +name = "alembic" +version = "1.15.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, - { name = "jinja2" }, + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload_time = "2023-11-18T15:30:52.559Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/57/e314c31b261d1e8a5a5f1908065b4ff98270a778ce7579bd4254477209a7/alembic-1.15.2.tar.gz", hash = "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", size = 1925573, upload-time = "2025-03-28T13:52:00.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload_time = "2023-11-18T15:30:50.743Z" }, + { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911, upload-time = "2025-03-28T13:52:02.218Z" }, ] [[package]] -name = "aiosignal" -version = "1.3.2" +name = "altgraph" +version = "0.17.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload_time = "2024-12-13T17:10:40.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418, upload-time = "2023-09-25T09:04:52.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload_time = "2024-12-13T17:10:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" }, ] [[package]] -name = "attrs" -version = "25.3.0" +name = "colorama" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload_time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload_time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] -name = "click" -version = "8.1.8" +name = "contourpy" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload_time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload_time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, ] [[package]] -name = "colorama" -version = "0.4.6" +name = "coverage" +version = "7.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload_time = "2025-04-17T22:38:53.099Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182, upload_time = "2025-04-17T22:37:16.837Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838, upload_time = "2025-04-17T22:37:18.352Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980, upload_time = "2025-04-17T22:37:19.857Z" }, - { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463, upload_time = "2025-04-17T22:37:21.328Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985, upload_time = "2025-04-17T22:37:23.55Z" }, - { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188, upload_time = "2025-04-17T22:37:25.221Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874, upload_time = "2025-04-17T22:37:26.791Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897, upload_time = "2025-04-17T22:37:28.958Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799, upload_time = "2025-04-17T22:37:30.889Z" }, - { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804, upload_time = "2025-04-17T22:37:32.489Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404, upload_time = "2025-04-17T22:37:34.59Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572, upload_time = "2025-04-17T22:37:36.337Z" }, - { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601, upload_time = "2025-04-17T22:37:37.923Z" }, - { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232, upload_time = "2025-04-17T22:37:39.669Z" }, - { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187, upload_time = "2025-04-17T22:37:41.662Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a2/e64df5c5aa36ab3dee5a40d254f3e471bb0603c225f81664267281c46a2d/frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", size = 114772, upload_time = "2025-04-17T22:37:43.132Z" }, - { url = "https://files.pythonhosted.org/packages/a0/77/fead27441e749b2d574bb73d693530d59d520d4b9e9679b8e3cb779d37f2/frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", size = 119847, upload_time = "2025-04-17T22:37:45.118Z" }, - { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937, upload_time = "2025-04-17T22:37:46.635Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029, upload_time = "2025-04-17T22:37:48.192Z" }, - { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831, upload_time = "2025-04-17T22:37:50.485Z" }, - { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981, upload_time = "2025-04-17T22:37:52.558Z" }, - { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999, upload_time = "2025-04-17T22:37:54.092Z" }, - { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200, upload_time = "2025-04-17T22:37:55.951Z" }, - { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134, upload_time = "2025-04-17T22:37:57.633Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208, upload_time = "2025-04-17T22:37:59.742Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548, upload_time = "2025-04-17T22:38:01.416Z" }, - { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123, upload_time = "2025-04-17T22:38:03.049Z" }, - { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199, upload_time = "2025-04-17T22:38:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854, upload_time = "2025-04-17T22:38:06.576Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412, upload_time = "2025-04-17T22:38:08.197Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936, upload_time = "2025-04-17T22:38:10.056Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459, upload_time = "2025-04-17T22:38:11.826Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c3/0fc2c97dea550df9afd072a37c1e95421652e3206bbeaa02378b24c2b480/frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", size = 128797, upload_time = "2025-04-17T22:38:14.013Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/79c9320c5656b1965634fe4be9c82b12a3305bdbc58ad9cb941131107b20/frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", size = 134709, upload_time = "2025-04-17T22:38:15.551Z" }, - { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload_time = "2025-04-17T22:38:51.668Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872, upload-time = "2025-03-30T20:36:45.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708, upload-time = "2025-03-30T20:35:47.417Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981, upload-time = "2025-03-30T20:35:49.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495, upload-time = "2025-03-30T20:35:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538, upload-time = "2025-03-30T20:35:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561, upload-time = "2025-03-30T20:35:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633, upload-time = "2025-03-30T20:35:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712, upload-time = "2025-03-30T20:35:57.801Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000, upload-time = "2025-03-30T20:35:59.378Z" }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195, upload-time = "2025-03-30T20:36:01.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998, upload-time = "2025-03-30T20:36:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541, upload-time = "2025-03-30T20:36:04.638Z" }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767, upload-time = "2025-03-30T20:36:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997, upload-time = "2025-03-30T20:36:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708, upload-time = "2025-03-30T20:36:09.781Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046, upload-time = "2025-03-30T20:36:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139, upload-time = "2025-03-30T20:36:13.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307, upload-time = "2025-03-30T20:36:16.074Z" }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload-time = "2025-03-30T20:36:18.033Z" }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload-time = "2025-03-30T20:36:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload-time = "2025-03-30T20:36:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload-time = "2025-03-30T20:36:43.61Z" }, ] [[package]] -name = "greenlet" -version = "3.2.1" +name = "cycler" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/74/907bb43af91782e0366b0960af62a8ce1f9398e4291cac7beaeffbee0c04/greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7", size = 184475, upload_time = "2025-04-22T14:40:18.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/2a/581b3808afec55b2db838742527c40b4ce68b9b64feedff0fd0123f4b19a/greenlet-3.2.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac", size = 269119, upload_time = "2025-04-22T14:25:01.798Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f3/1c4e27fbdc84e13f05afc2baf605e704668ffa26e73a43eca93e1120813e/greenlet-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437", size = 637314, upload_time = "2025-04-22T14:53:46.214Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/9fc43cb0044f425f7252da9847893b6de4e3b20c0a748bce7ab3f063d5bc/greenlet-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a", size = 651421, upload_time = "2025-04-22T14:55:00.852Z" }, - { url = "https://files.pythonhosted.org/packages/8a/65/d47c03cdc62c6680206b7420c4a98363ee997e87a5e9da1e83bd7eeb57a8/greenlet-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c", size = 645789, upload_time = "2025-04-22T15:04:37.702Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/0faf8bee1b106c241780f377b9951dd4564ef0972de1942ef74687aa6bba/greenlet-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982", size = 648262, upload_time = "2025-04-22T14:27:07.55Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a8/73305f713183c2cb08f3ddd32eaa20a6854ba9c37061d682192db9b021c3/greenlet-3.2.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07", size = 606770, upload_time = "2025-04-22T14:25:58.34Z" }, - { url = "https://files.pythonhosted.org/packages/c3/05/7d726e1fb7f8a6ac55ff212a54238a36c57db83446523c763e20cd30b837/greenlet-3.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95", size = 1117960, upload_time = "2025-04-22T14:59:00.373Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9f/2b6cb1bd9f1537e7b08c08705c4a1d7bd4f64489c67d102225c4fd262bda/greenlet-3.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123", size = 1145500, upload_time = "2025-04-22T14:28:12.441Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f6/339c6e707062319546598eb9827d3ca8942a3eccc610d4a54c1da7b62527/greenlet-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495", size = 295994, upload_time = "2025-04-22T14:50:44.796Z" }, - { url = "https://files.pythonhosted.org/packages/f1/72/2a251d74a596af7bb1717e891ad4275a3fd5ac06152319d7ad8c77f876af/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526", size = 629889, upload_time = "2025-04-22T14:53:48.434Z" }, - { url = "https://files.pythonhosted.org/packages/29/2e/d7ed8bf97641bf704b6a43907c0e082cdf44d5bc026eb8e1b79283e7a719/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5", size = 635261, upload_time = "2025-04-22T14:55:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/802aa27848a6fcb5e566f69c64534f572e310f0f12d41e9201a81e741551/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32", size = 632523, upload_time = "2025-04-22T15:04:39.221Z" }, - { url = "https://files.pythonhosted.org/packages/56/09/f7c1c3bab9b4c589ad356503dd71be00935e9c4db4db516ed88fc80f1187/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc", size = 628816, upload_time = "2025-04-22T14:27:08.869Z" }, - { url = "https://files.pythonhosted.org/packages/79/e0/1bb90d30b5450eac2dffeaac6b692857c4bd642c21883b79faa8fa056cf2/greenlet-3.2.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb", size = 593687, upload_time = "2025-04-22T14:25:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/adbe03c8b4c178add20cc716021183ae6b0326d56ba8793d7828c94286f6/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8", size = 1105754, upload_time = "2025-04-22T14:59:02.585Z" }, - { url = "https://files.pythonhosted.org/packages/39/93/84582d7ef38dec009543ccadec6ab41079a6cbc2b8c0566bcd07bf1aaf6c/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d", size = 1125160, upload_time = "2025-04-22T14:28:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/01/e6/f9d759788518a6248684e3afeb3691f3ab0276d769b6217a1533362298c8/greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189", size = 269897, upload_time = "2025-04-22T14:27:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] [[package]] -name = "idna" -version = "3.10" +name = "fonttools" +version = "4.57.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload_time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/2d/a9a0b6e3a0cf6bd502e64fc16d894269011930cabfc89aee20d1635b1441/fonttools-4.57.0.tar.gz", hash = "sha256:727ece10e065be2f9dd239d15dd5d60a66e17eac11aea47d447f9f03fdbc42de", size = 3492448, upload-time = "2025-04-03T11:07:13.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload_time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2f/11439f3af51e4bb75ac9598c29f8601aa501902dcedf034bdc41f47dd799/fonttools-4.57.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:408ce299696012d503b714778d89aa476f032414ae57e57b42e4b92363e0b8ef", size = 2739175, upload-time = "2025-04-03T11:06:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/25/52/677b55a4c0972dc3820c8dba20a29c358197a78229daa2ea219fdb19e5d5/fonttools-4.57.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bbceffc80aa02d9e8b99f2a7491ed8c4a783b2fc4020119dc405ca14fb5c758c", size = 2276583, upload-time = "2025-04-03T11:06:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/64/79/184555f8fa77b827b9460a4acdbbc0b5952bb6915332b84c615c3a236826/fonttools-4.57.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f022601f3ee9e1f6658ed6d184ce27fa5216cee5b82d279e0f0bde5deebece72", size = 4766437, upload-time = "2025-04-03T11:06:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/c25116352f456c0d1287545a7aa24e98987b6d99c5b0456c4bd14321f20f/fonttools-4.57.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dea5893b58d4637ffa925536462ba626f8a1b9ffbe2f5c272cdf2c6ebadb817", size = 4838431, upload-time = "2025-04-03T11:06:25.423Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/398b2a833897297797a44f519c9af911c2136eb7aa27d3f1352c6d1129fa/fonttools-4.57.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dff02c5c8423a657c550b48231d0a48d7e2b2e131088e55983cfe74ccc2c7cc9", size = 4951011, upload-time = "2025-04-03T11:06:27.41Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5d/7cb31c4bc9ffb9a2bbe8b08f8f53bad94aeb158efad75da645b40b62cb73/fonttools-4.57.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:767604f244dc17c68d3e2dbf98e038d11a18abc078f2d0f84b6c24571d9c0b13", size = 5205679, upload-time = "2025-04-03T11:06:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/6934513ec2c4d3d69ca1bc3bd34d5c69dafcbf68c15388dd3bb062daf345/fonttools-4.57.0-cp313-cp313-win32.whl", hash = "sha256:8e2e12d0d862f43d51e5afb8b9751c77e6bec7d2dc00aad80641364e9df5b199", size = 2144833, upload-time = "2025-04-03T11:06:31.737Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0d/2177b7fdd23d017bcfb702fd41e47d4573766b9114da2fddbac20dcc4957/fonttools-4.57.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1d6bc9c23356908db712d282acb3eebd4ae5ec6d8b696aa40342b1d84f8e9e3", size = 2190799, upload-time = "2025-04-03T11:06:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/90/27/45f8957c3132917f91aaa56b700bcfc2396be1253f685bd5c68529b6f610/fonttools-4.57.0-py3-none-any.whl", hash = "sha256:3122c604a675513c68bd24c6a8f9091f1c2376d18e8f5fe5a101746c81b3e98f", size = 1093605, upload-time = "2025-04-03T11:07:11.341Z" }, ] [[package]] -name = "iniconfig" -version = "2.1.0" +name = "greenlet" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/74/907bb43af91782e0366b0960af62a8ce1f9398e4291cac7beaeffbee0c04/greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7", size = 184475, upload-time = "2025-04-22T14:40:18.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/77/2a/581b3808afec55b2db838742527c40b4ce68b9b64feedff0fd0123f4b19a/greenlet-3.2.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac", size = 269119, upload-time = "2025-04-22T14:25:01.798Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f3/1c4e27fbdc84e13f05afc2baf605e704668ffa26e73a43eca93e1120813e/greenlet-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437", size = 637314, upload-time = "2025-04-22T14:53:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/9fc43cb0044f425f7252da9847893b6de4e3b20c0a748bce7ab3f063d5bc/greenlet-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a", size = 651421, upload-time = "2025-04-22T14:55:00.852Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/d47c03cdc62c6680206b7420c4a98363ee997e87a5e9da1e83bd7eeb57a8/greenlet-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c", size = 645789, upload-time = "2025-04-22T15:04:37.702Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/0faf8bee1b106c241780f377b9951dd4564ef0972de1942ef74687aa6bba/greenlet-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982", size = 648262, upload-time = "2025-04-22T14:27:07.55Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a8/73305f713183c2cb08f3ddd32eaa20a6854ba9c37061d682192db9b021c3/greenlet-3.2.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07", size = 606770, upload-time = "2025-04-22T14:25:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/c3/05/7d726e1fb7f8a6ac55ff212a54238a36c57db83446523c763e20cd30b837/greenlet-3.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95", size = 1117960, upload-time = "2025-04-22T14:59:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9f/2b6cb1bd9f1537e7b08c08705c4a1d7bd4f64489c67d102225c4fd262bda/greenlet-3.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123", size = 1145500, upload-time = "2025-04-22T14:28:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f6/339c6e707062319546598eb9827d3ca8942a3eccc610d4a54c1da7b62527/greenlet-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495", size = 295994, upload-time = "2025-04-22T14:50:44.796Z" }, + { url = "https://files.pythonhosted.org/packages/f1/72/2a251d74a596af7bb1717e891ad4275a3fd5ac06152319d7ad8c77f876af/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526", size = 629889, upload-time = "2025-04-22T14:53:48.434Z" }, + { url = "https://files.pythonhosted.org/packages/29/2e/d7ed8bf97641bf704b6a43907c0e082cdf44d5bc026eb8e1b79283e7a719/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5", size = 635261, upload-time = "2025-04-22T14:55:02.258Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/802aa27848a6fcb5e566f69c64534f572e310f0f12d41e9201a81e741551/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32", size = 632523, upload-time = "2025-04-22T15:04:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/56/09/f7c1c3bab9b4c589ad356503dd71be00935e9c4db4db516ed88fc80f1187/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc", size = 628816, upload-time = "2025-04-22T14:27:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/79/e0/1bb90d30b5450eac2dffeaac6b692857c4bd642c21883b79faa8fa056cf2/greenlet-3.2.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb", size = 593687, upload-time = "2025-04-22T14:25:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/adbe03c8b4c178add20cc716021183ae6b0326d56ba8793d7828c94286f6/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8", size = 1105754, upload-time = "2025-04-22T14:59:02.585Z" }, + { url = "https://files.pythonhosted.org/packages/39/93/84582d7ef38dec009543ccadec6ab41079a6cbc2b8c0566bcd07bf1aaf6c/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d", size = 1125160, upload-time = "2025-04-22T14:28:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/01/e6/f9d759788518a6248684e3afeb3691f3ab0276d769b6217a1533362298c8/greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189", size = 269897, upload-time = "2025-04-22T14:27:14.044Z" }, ] [[package]] -name = "jinja2" -version = "3.1.6" +name = "iniconfig" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -202,289 +159,374 @@ name = "jobtrackr" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "plotext" }, + { name = "alembic" }, + { name = "matplotlib" }, + { name = "networkx" }, + { name = "pyqt6" }, { name = "sqlalchemy" }, - { name = "textual" }, ] [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pyinstaller" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-qt" }, { name = "ruff" }, - { name = "textual-dev" }, ] [package.metadata] requires-dist = [ - { name = "plotext", specifier = ">=5.3.2" }, + { name = "alembic", specifier = ">=1.15.2" }, + { name = "matplotlib", specifier = ">=3.10.1" }, + { name = "networkx", specifier = ">=3.4.2" }, + { name = "pyqt6", specifier = ">=6.9.0" }, { name = "sqlalchemy", specifier = ">=2.0.40" }, - { name = "textual", specifier = ">=3.1.1" }, ] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.15.0" }, + { name = "pyinstaller", specifier = ">=6.13.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-qt", specifier = ">=4.4.0" }, { name = "ruff", specifier = ">=0.11.7" }, - { name = "textual-dev", specifier = ">=1.7.0" }, ] [[package]] -name = "linkify-it-py" -version = "2.0.3" +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538, upload-time = "2024-12-24T18:30:51.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156, upload-time = "2024-12-24T18:29:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555, upload-time = "2024-12-24T18:29:46.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071, upload-time = "2024-12-24T18:29:47.333Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053, upload-time = "2024-12-24T18:29:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278, upload-time = "2024-12-24T18:29:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139, upload-time = "2024-12-24T18:29:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517, upload-time = "2024-12-24T18:29:53.941Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952, upload-time = "2024-12-24T18:29:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132, upload-time = "2024-12-24T18:29:57.989Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997, upload-time = "2024-12-24T18:29:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060, upload-time = "2024-12-24T18:30:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471, upload-time = "2024-12-24T18:30:04.574Z" }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793, upload-time = "2024-12-24T18:30:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855, upload-time = "2024-12-24T18:30:07.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430, upload-time = "2024-12-24T18:30:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294, upload-time = "2024-12-24T18:30:09.508Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736, upload-time = "2024-12-24T18:30:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194, upload-time = "2024-12-24T18:30:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942, upload-time = "2024-12-24T18:30:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341, upload-time = "2024-12-24T18:30:22.102Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455, upload-time = "2024-12-24T18:30:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138, upload-time = "2024-12-24T18:30:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857, upload-time = "2024-12-24T18:30:28.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129, upload-time = "2024-12-24T18:30:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538, upload-time = "2024-12-24T18:30:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661, upload-time = "2024-12-24T18:30:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710, upload-time = "2024-12-24T18:30:37.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "uc-micro-py" }, + { name = "altgraph" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload_time = "2024-02-04T14:48:04.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309, upload-time = "2023-09-25T09:10:16.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload_time = "2024-02-04T14:48:02.496Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" }, ] [[package]] -name = "markdown-it-py" -version = "3.0.0" +name = "mako" +version = "1.3.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, -] - -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] -plugins = [ - { name = "mdit-py-plugins" }, + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.4.2" +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload_time = "2024-09-09T20:27:49.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/08/b89867ecea2e305f408fbb417139a8dd941ecf7b23a2e02157c36da546f0/matplotlib-3.10.1.tar.gz", hash = "sha256:e8d2d0e3881b129268585bf4765ad3ee73a4591d77b9a18c214ac7e3a79fb2ba", size = 36743335, upload-time = "2025-02-27T19:19:51.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload_time = "2024-09-09T20:27:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/60/73/6770ff5e5523d00f3bc584acb6031e29ee5c8adc2336b16cd1d003675fe0/matplotlib-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42eee41e1b60fd83ee3292ed83a97a5f2a8239b10c26715d8a6172226988d7b", size = 8176112, upload-time = "2025-02-27T19:19:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/08/97/b0ca5da0ed54a3f6599c3ab568bdda65269bc27c21a2c97868c1625e4554/matplotlib-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4f0647b17b667ae745c13721602b540f7aadb2a32c5b96e924cd4fea5dcb90f1", size = 8046931, upload-time = "2025-02-27T19:19:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/1acbdc3b165d4ce2dcd2b1a6d4ffb46a7220ceee960c922c3d50d8514067/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa3854b5f9473564ef40a41bc922be978fab217776e9ae1545c9b3a5cf2092a3", size = 8453422, upload-time = "2025-02-27T19:19:12.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/2bc4368abf766203e548dc7ab57cf7e9c621f1a3c72b516cc7715347b179/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e496c01441be4c7d5f96d4e40f7fca06e20dcb40e44c8daa2e740e1757ad9e6", size = 8596819, upload-time = "2025-02-27T19:19:15.306Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1b/8b350f8a1746c37ab69dda7d7528d1fc696efb06db6ade9727b7887be16d/matplotlib-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d45d3f5245be5b469843450617dcad9af75ca50568acf59997bed9311131a0b", size = 9402782, upload-time = "2025-02-27T19:19:17.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/f570373d24d93503988ba8d04f213a372fa1ce48381c5eb15da985728498/matplotlib-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:8e8e25b1209161d20dfe93037c8a7f7ca796ec9aa326e6e4588d8c4a5dd1e473", size = 8063812, upload-time = "2025-02-27T19:19:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e0/8c811a925b5a7ad75135f0e5af46408b78af88bbb02a1df775100ef9bfef/matplotlib-3.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:19b06241ad89c3ae9469e07d77efa87041eac65d78df4fcf9cac318028009b01", size = 8214021, upload-time = "2025-02-27T19:19:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/319ec2139f68ba26da9d00fce2ff9f27679fb799a6c8e7358539801fd629/matplotlib-3.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01e63101ebb3014e6e9f80d9cf9ee361a8599ddca2c3e166c563628b39305dbb", size = 8090782, upload-time = "2025-02-27T19:19:28.33Z" }, + { url = "https://files.pythonhosted.org/packages/77/ea/9812124ab9a99df5b2eec1110e9b2edc0b8f77039abf4c56e0a376e84a29/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f06bad951eea6422ac4e8bdebcf3a70c59ea0a03338c5d2b109f57b64eb3972", size = 8478901, upload-time = "2025-02-27T19:19:31.536Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/b05bf463689134789b06dea85828f8ebe506fa1e37593f723b65b86c9582/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3dfb036f34873b46978f55e240cff7a239f6c4409eac62d8145bad3fc6ba5a3", size = 8613864, upload-time = "2025-02-27T19:19:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/c2/04/41ccec4409f3023a7576df3b5c025f1a8c8b81fbfe922ecfd837ac36e081/matplotlib-3.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dc6ab14a7ab3b4d813b88ba957fc05c79493a037f54e246162033591e770de6f", size = 9409487, upload-time = "2025-02-27T19:19:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c2/0d5aae823bdcc42cc99327ecdd4d28585e15ccd5218c453b7bcd827f3421/matplotlib-3.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bc411ebd5889a78dabbc457b3fa153203e22248bfa6eedc6797be5df0164dbf9", size = 8134832, upload-time = "2025-02-27T19:19:39.431Z" }, ] [[package]] -name = "mdurl" -version = "0.1.2" +name = "mypy" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, ] [[package]] -name = "msgpack" +name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/7555686ae7ff5731205df1012ede15dd9d927f6227ea151e901c7406af4f/msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e", size = 167260, upload_time = "2024-09-10T04:25:52.197Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/b0/380f5f639543a4ac413e969109978feb1f3c66e931068f91ab6ab0f8be00/msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf", size = 151142, upload_time = "2024-09-10T04:24:59.656Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ee/be57e9702400a6cb2606883d55b05784fada898dfc7fd12608ab1fdb054e/msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330", size = 84523, upload_time = "2024-09-10T04:25:37.924Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3a/2919f63acca3c119565449681ad08a2f84b2171ddfcff1dba6959db2cceb/msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734", size = 81556, upload_time = "2024-09-10T04:24:28.296Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/a11113d9e5c1498c145a8925768ea2d5fce7cbab15c99cda655aa09947ed/msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e", size = 392105, upload_time = "2024-09-10T04:25:20.153Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7b/2c1d74ca6c94f70a1add74a8393a0138172207dc5de6fc6269483519d048/msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca", size = 399979, upload_time = "2024-09-10T04:25:41.75Z" }, - { url = "https://files.pythonhosted.org/packages/82/8c/cf64ae518c7b8efc763ca1f1348a96f0e37150061e777a8ea5430b413a74/msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915", size = 383816, upload_time = "2024-09-10T04:24:45.826Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/a847ef7a0f5ef3fa94ae20f52a4cacf596a4e4a010197fbcc27744eb9a83/msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d", size = 380973, upload_time = "2024-09-10T04:25:04.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/90/c74cf6e1126faa93185d3b830ee97246ecc4fe12cf9d2d31318ee4246994/msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434", size = 387435, upload_time = "2024-09-10T04:24:17.879Z" }, - { url = "https://files.pythonhosted.org/packages/7a/40/631c238f1f338eb09f4acb0f34ab5862c4e9d7eda11c1b685471a4c5ea37/msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c", size = 399082, upload_time = "2024-09-10T04:25:18.398Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1b/fa8a952be252a1555ed39f97c06778e3aeb9123aa4cccc0fd2acd0b4e315/msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc", size = 69037, upload_time = "2024-09-10T04:24:52.798Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bc/8bd826dd03e022153bfa1766dcdec4976d6c818865ed54223d71f07862b3/msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f", size = 75140, upload_time = "2024-09-10T04:24:31.288Z" }, -] - -[[package]] -name = "multidict" -version = "6.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/2c/e367dfb4c6538614a0c9453e510d75d66099edf1c4e69da1b5ce691a1931/multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", size = 89372, upload_time = "2025-04-10T22:20:17.956Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/4b/86fd786d03915c6f49998cf10cd5fe6b6ac9e9a071cb40885d2e080fb90d/multidict-6.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474", size = 63831, upload_time = "2025-04-10T22:18:48.748Z" }, - { url = "https://files.pythonhosted.org/packages/45/05/9b51fdf7aef2563340a93be0a663acba2c428c4daeaf3960d92d53a4a930/multidict-6.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd", size = 37888, upload_time = "2025-04-10T22:18:50.021Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/53fc25394386c911822419b522181227ca450cf57fea76e6188772a1bd91/multidict-6.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b", size = 36852, upload_time = "2025-04-10T22:18:51.246Z" }, - { url = "https://files.pythonhosted.org/packages/8a/68/7b99c751e822467c94a235b810a2fd4047d4ecb91caef6b5c60116991c4b/multidict-6.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3", size = 223644, upload_time = "2025-04-10T22:18:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/80/1b/d458d791e4dd0f7e92596667784fbf99e5c8ba040affe1ca04f06b93ae92/multidict-6.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac", size = 230446, upload_time = "2025-04-10T22:18:54.509Z" }, - { url = "https://files.pythonhosted.org/packages/e2/46/9793378d988905491a7806d8987862dc5a0bae8a622dd896c4008c7b226b/multidict-6.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790", size = 231070, upload_time = "2025-04-10T22:18:56.019Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b8/b127d3e1f8dd2a5bf286b47b24567ae6363017292dc6dec44656e6246498/multidict-6.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb", size = 229956, upload_time = "2025-04-10T22:18:59.146Z" }, - { url = "https://files.pythonhosted.org/packages/0c/93/f70a4c35b103fcfe1443059a2bb7f66e5c35f2aea7804105ff214f566009/multidict-6.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0", size = 222599, upload_time = "2025-04-10T22:19:00.657Z" }, - { url = "https://files.pythonhosted.org/packages/63/8c/e28e0eb2fe34921d6aa32bfc4ac75b09570b4d6818cc95d25499fe08dc1d/multidict-6.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9", size = 216136, upload_time = "2025-04-10T22:19:02.244Z" }, - { url = "https://files.pythonhosted.org/packages/72/f5/fbc81f866585b05f89f99d108be5d6ad170e3b6c4d0723d1a2f6ba5fa918/multidict-6.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8", size = 228139, upload_time = "2025-04-10T22:19:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ba/7d196bad6b85af2307d81f6979c36ed9665f49626f66d883d6c64d156f78/multidict-6.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1", size = 226251, upload_time = "2025-04-10T22:19:06.117Z" }, - { url = "https://files.pythonhosted.org/packages/cc/e2/fae46a370dce79d08b672422a33df721ec8b80105e0ea8d87215ff6b090d/multidict-6.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817", size = 221868, upload_time = "2025-04-10T22:19:07.981Z" }, - { url = "https://files.pythonhosted.org/packages/26/20/bbc9a3dec19d5492f54a167f08546656e7aef75d181d3d82541463450e88/multidict-6.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d", size = 233106, upload_time = "2025-04-10T22:19:09.5Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8d/f30ae8f5ff7a2461177f4d8eb0d8f69f27fb6cfe276b54ec4fd5a282d918/multidict-6.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9", size = 230163, upload_time = "2025-04-10T22:19:11Z" }, - { url = "https://files.pythonhosted.org/packages/15/e9/2833f3c218d3c2179f3093f766940ded6b81a49d2e2f9c46ab240d23dfec/multidict-6.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8", size = 225906, upload_time = "2025-04-10T22:19:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/6edab296ac369fd286b845fa5dd4c409e63bc4655ed8c9510fcb477e9ae9/multidict-6.4.3-cp313-cp313-win32.whl", hash = "sha256:f901a5aace8e8c25d78960dcc24c870c8d356660d3b49b93a78bf38eb682aac3", size = 35238, upload_time = "2025-04-10T22:19:14.41Z" }, - { url = "https://files.pythonhosted.org/packages/23/57/2c0167a1bffa30d9a1383c3dab99d8caae985defc8636934b5668830d2ef/multidict-6.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:1c152c49e42277bc9a2f7b78bd5fa10b13e88d1b0328221e7aef89d5c60a99a5", size = 38799, upload_time = "2025-04-10T22:19:15.869Z" }, - { url = "https://files.pythonhosted.org/packages/c9/13/2ead63b9ab0d2b3080819268acb297bd66e238070aa8d42af12b08cbee1c/multidict-6.4.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6", size = 68642, upload_time = "2025-04-10T22:19:17.527Z" }, - { url = "https://files.pythonhosted.org/packages/85/45/f1a751e1eede30c23951e2ae274ce8fad738e8a3d5714be73e0a41b27b16/multidict-6.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c", size = 40028, upload_time = "2025-04-10T22:19:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/a7/29/fcc53e886a2cc5595cc4560df333cb9630257bda65003a7eb4e4e0d8f9c1/multidict-6.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756", size = 39424, upload_time = "2025-04-10T22:19:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f0/056c81119d8b88703971f937b371795cab1407cd3c751482de5bfe1a04a9/multidict-6.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375", size = 226178, upload_time = "2025-04-10T22:19:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/a3/79/3b7e5fea0aa80583d3a69c9d98b7913dfd4fbc341fb10bb2fb48d35a9c21/multidict-6.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be", size = 222617, upload_time = "2025-04-10T22:19:23.773Z" }, - { url = "https://files.pythonhosted.org/packages/06/db/3ed012b163e376fc461e1d6a67de69b408339bc31dc83d39ae9ec3bf9578/multidict-6.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea", size = 227919, upload_time = "2025-04-10T22:19:25.35Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/0433c104bca380989bc04d3b841fc83e95ce0c89f680e9ea4251118b52b6/multidict-6.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8", size = 226097, upload_time = "2025-04-10T22:19:27.183Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/910db2618175724dd254b7ae635b6cd8d2947a8b76b0376de7b96d814dab/multidict-6.4.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02", size = 220706, upload_time = "2025-04-10T22:19:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d1/af/aa176c6f5f1d901aac957d5258d5e22897fe13948d1e69063ae3d5d0ca01/multidict-6.4.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124", size = 211728, upload_time = "2025-04-10T22:19:30.481Z" }, - { url = "https://files.pythonhosted.org/packages/e7/42/d51cc5fc1527c3717d7f85137d6c79bb7a93cd214c26f1fc57523774dbb5/multidict-6.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44", size = 226276, upload_time = "2025-04-10T22:19:32.454Z" }, - { url = "https://files.pythonhosted.org/packages/28/6b/d836dea45e0b8432343ba4acf9a8ecaa245da4c0960fb7ab45088a5e568a/multidict-6.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b", size = 212069, upload_time = "2025-04-10T22:19:34.17Z" }, - { url = "https://files.pythonhosted.org/packages/55/34/0ee1a7adb3560e18ee9289c6e5f7db54edc312b13e5c8263e88ea373d12c/multidict-6.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504", size = 217858, upload_time = "2025-04-10T22:19:35.879Z" }, - { url = "https://files.pythonhosted.org/packages/04/08/586d652c2f5acefe0cf4e658eedb4d71d4ba6dfd4f189bd81b400fc1bc6b/multidict-6.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf", size = 226988, upload_time = "2025-04-10T22:19:37.434Z" }, - { url = "https://files.pythonhosted.org/packages/82/e3/cc59c7e2bc49d7f906fb4ffb6d9c3a3cf21b9f2dd9c96d05bef89c2b1fd1/multidict-6.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4", size = 220435, upload_time = "2025-04-10T22:19:39.005Z" }, - { url = "https://files.pythonhosted.org/packages/e0/32/5c3a556118aca9981d883f38c4b1bfae646f3627157f70f4068e5a648955/multidict-6.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4", size = 221494, upload_time = "2025-04-10T22:19:41.447Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3b/1599631f59024b75c4d6e3069f4502409970a336647502aaf6b62fb7ac98/multidict-6.4.3-cp313-cp313t-win32.whl", hash = "sha256:0ecdc12ea44bab2807d6b4a7e5eef25109ab1c82a8240d86d3c1fc9f3b72efd5", size = 41775, upload_time = "2025-04-10T22:19:43.707Z" }, - { url = "https://files.pythonhosted.org/packages/e8/4e/09301668d675d02ca8e8e1a3e6be046619e30403f5ada2ed5b080ae28d02/multidict-6.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7146a8742ea71b5d7d955bffcef58a9e6e04efba704b52a460134fefd10a8208", size = 45946, upload_time = "2025-04-10T22:19:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload_time = "2025-04-10T22:20:16.445Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] -name = "mypy" -version = "1.15.0" +name = "networkx" +version = "3.4.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload_time = "2025-02-05T03:50:34.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload_time = "2025-02-05T03:48:55.789Z" }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload_time = "2025-02-05T03:48:44.581Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload_time = "2025-02-05T03:49:25.514Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload_time = "2025-02-05T03:49:57.623Z" }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload_time = "2025-02-05T03:48:52.361Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload_time = "2025-02-05T03:49:11.395Z" }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload_time = "2025-02-05T03:50:08.348Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, ] [[package]] -name = "mypy-extensions" -version = "1.1.0" +name = "numpy" +version = "2.2.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920, upload-time = "2025-04-19T23:27:42.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102, upload-time = "2025-04-19T22:41:16.234Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709, upload-time = "2025-04-19T22:41:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173, upload-time = "2025-04-19T22:41:47.823Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502, upload-time = "2025-04-19T22:41:58.689Z" }, + { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417, upload-time = "2025-04-19T22:42:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807, upload-time = "2025-04-19T22:42:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611, upload-time = "2025-04-19T22:43:09.928Z" }, + { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747, upload-time = "2025-04-19T22:43:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594, upload-time = "2025-04-19T22:47:10.523Z" }, + { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356, upload-time = "2025-04-19T22:47:30.253Z" }, + { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778, upload-time = "2025-04-19T22:44:09.251Z" }, + { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279, upload-time = "2025-04-19T22:44:31.383Z" }, + { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247, upload-time = "2025-04-19T22:44:40.361Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087, upload-time = "2025-04-19T22:44:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964, upload-time = "2025-04-19T22:45:12.451Z" }, + { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214, upload-time = "2025-04-19T22:45:37.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788, upload-time = "2025-04-19T22:46:01.908Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672, upload-time = "2025-04-19T22:46:28.585Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102, upload-time = "2025-04-19T22:46:39.949Z" }, + { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096, upload-time = "2025-04-19T22:47:00.147Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] -name = "platformdirs" -version = "4.3.7" +name = "pefile" +version = "2023.2.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload_time = "2025-03-19T20:36:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload_time = "2025-03-19T20:36:09.038Z" }, + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, ] [[package]] -name = "plotext" -version = "5.3.2" +name = "pillow" +version = "11.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload_time = "2024-09-24T15:13:37.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload_time = "2024-09-24T15:13:36.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098, upload-time = "2025-04-12T17:48:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166, upload-time = "2025-04-12T17:48:25.738Z" }, + { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674, upload-time = "2025-04-12T17:48:27.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005, upload-time = "2025-04-12T17:48:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707, upload-time = "2025-04-12T17:48:31.874Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008, upload-time = "2025-04-12T17:48:34.422Z" }, + { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420, upload-time = "2025-04-12T17:48:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655, upload-time = "2025-04-12T17:48:39.652Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329, upload-time = "2025-04-12T17:48:41.765Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388, upload-time = "2025-04-12T17:48:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950, upload-time = "2025-04-12T17:48:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759, upload-time = "2025-04-12T17:48:47.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284, upload-time = "2025-04-12T17:48:50.189Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826, upload-time = "2025-04-12T17:48:52.346Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329, upload-time = "2025-04-12T17:48:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049, upload-time = "2025-04-12T17:48:56.383Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408, upload-time = "2025-04-12T17:48:58.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863, upload-time = "2025-04-12T17:49:00.709Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938, upload-time = "2025-04-12T17:49:02.946Z" }, + { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774, upload-time = "2025-04-12T17:49:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895, upload-time = "2025-04-12T17:49:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234, upload-time = "2025-04-12T17:49:08.399Z" }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload_time = "2024-04-20T21:34:42.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/b1/2949fe6d3874e961898ca5cfc1bf2cf13bdeea488b302e74a745bc28c8ba/pyinstaller-6.13.0.tar.gz", hash = "sha256:38911feec2c5e215e5159a7e66fdb12400168bd116143b54a8a7a37f08733456", size = 4276427, upload-time = "2025-04-15T23:25:31.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/02/d1a347d35b1b627da1e148159e617576555619ac3bb8bbd5fed661fc7bb5/pyinstaller-6.13.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:aa404f0b02cd57948098055e76ee190b8e65ccf7a2a3f048e5000f668317069f", size = 1001923, upload-time = "2025-04-15T23:24:17.646Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6da39f7aeac65c9ca5afad0fac37887d75fdfd480178a7077c9d30b0704c/pyinstaller-6.13.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:92efcf2f09e78f07b568c5cb7ed48c9940f5dad627af4b49bede6320fab2a06e", size = 718135, upload-time = "2025-04-15T23:24:22.385Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/d21d31f780a489609e7bf6385c0f7635238dc98b37cba8645b53322b7450/pyinstaller-6.13.0-py3-none-manylinux2014_i686.whl", hash = "sha256:9f82f113c463f012faa0e323d952ca30a6f922685d9636e754bd3a256c7ed200", size = 728543, upload-time = "2025-04-15T23:24:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/e1/20/e6ca87bbed6c0163533195707f820f05e10b8da1223fc6972cfe3c3c50c7/pyinstaller-6.13.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:db0e7945ebe276f604eb7c36e536479556ab32853412095e19172a5ec8fca1c5", size = 726868, upload-time = "2025-04-15T23:24:31.779Z" }, + { url = "https://files.pythonhosted.org/packages/20/d5/53b19285f8817ab6c4b07c570208d62606bab0e5a049d50c93710a1d9dc6/pyinstaller-6.13.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:92fe7337c5aa08d42b38d7a79614492cb571489f2cb0a8f91dc9ef9ccbe01ed3", size = 725037, upload-time = "2025-04-15T23:24:36.244Z" }, + { url = "https://files.pythonhosted.org/packages/84/5b/08e0b305ba71e6d7cb247e27d714da7536895b0283132d74d249bf662366/pyinstaller-6.13.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:bc09795f5954135dd4486c1535650958c8218acb954f43860e4b05fb515a21c0", size = 721027, upload-time = "2025-04-15T23:24:40.16Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9c/d8d0a7120103471be8dbe1c5419542aa794b9b9ec2ef628b542f9e6f9ef0/pyinstaller-6.13.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:589937548d34978c568cfdc39f31cf386f45202bc27fdb8facb989c79dfb4c02", size = 723443, upload-time = "2025-04-15T23:24:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/52/c7/8a9d81569dda2352068ecc6ee779d5feff6729569dd1b4ffd1236ecd38fe/pyinstaller-6.13.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b7260832f7501ba1d2ce1834d4cddc0f2b94315282bc89c59333433715015447", size = 719915, upload-time = "2025-04-15T23:24:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e6/cccadb02b90198c7ed4ffb8bc34d420efb72b996f47cbd4738067a602d65/pyinstaller-6.13.0-py3-none-win32.whl", hash = "sha256:80c568848529635aa7ca46d8d525f68486d53e03f68b7bb5eba2c88d742e302c", size = 1294997, upload-time = "2025-04-15T23:25:01.391Z" }, + { url = "https://files.pythonhosted.org/packages/1a/06/15cbe0e25d1e73d5b981fa41ff0bb02b15e924e30b8c61256f4a28c4c837/pyinstaller-6.13.0-py3-none-win_amd64.whl", hash = "sha256:8d4296236b85aae570379488c2da833b28828b17c57c2cc21fccd7e3811fe372", size = 1352714, upload-time = "2025-04-15T23:25:08.061Z" }, + { url = "https://files.pythonhosted.org/packages/83/ef/74379298d46e7caa6aa7ceccc865106d3d4b15ac487ffdda2a35bfb6fe79/pyinstaller-6.13.0-py3-none-win_arm64.whl", hash = "sha256:d9f21d56ca2443aa6a1e255e7ad285c76453893a454105abe1b4d45e92bb9a20", size = 1293589, upload-time = "2025-04-15T23:25:14.523Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2025.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/94/dfc5c7903306211798f990e6794c2eb7b8685ac487b26979e9255790419c/pyinstaller_hooks_contrib-2025.4.tar.gz", hash = "sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446", size = 162628, upload-time = "2025-05-03T20:15:55.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e1/ed48c7074145898e5c5b0072e87be975c5bd6a1d0f08c27a1daa7064fca0/pyinstaller_hooks_contrib-2025.4-py3-none-any.whl", hash = "sha256:6c2d73269b4c484eb40051fc1acee0beb113c2cfb3b37437b8394faae6f0d072", size = 434451, upload-time = "2025-05-03T20:15:54.579Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "pyqt6" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt6-qt6" }, + { name = "pyqt6-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/de/102e8e66149085acf38bbf01df572a2cd53259bcd99b7d8ecef0d6b36172/pyqt6-6.9.0.tar.gz", hash = "sha256:6a8ff8e3cd18311bb7d937f7d741e787040ae7ff47ce751c28a94c5cddc1b4e6", size = 1066831, upload-time = "2025-04-08T09:00:46.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload_time = "2024-04-20T21:34:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/97/e5/f9e2b5326d6103bce4894a969be54ce3be4b0a7a6ff848228e6a61a9993f/PyQt6-6.9.0-cp39-abi3-macosx_10_14_universal2.whl", hash = "sha256:5344240747e81bde1a4e0e98d4e6e2d96ad56a985d8f36b69cd529c1ca9ff760", size = 12257215, upload-time = "2025-04-08T09:00:37.177Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3a/bcc7687c5a11079bbd1606a015514562f2ac8cb01c5e3e4a3b30fcbdad36/PyQt6-6.9.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e344868228c71fc89a0edeb325497df4ff731a89cfa5fe57a9a4e9baecc9512b", size = 8259731, upload-time = "2025-04-08T09:00:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/13ab0b916b5bad07ab04767b412043f5c1ca206bf38a906b1d8d5c520a98/PyQt6-6.9.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1cbc5a282454cf19691be09eadbde019783f1ae0523e269b211b0173b67373f6", size = 8207593, upload-time = "2025-04-08T09:00:42.167Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/955cfd880f2725a218ee7b272c005658e857e9224823d49c32c93517f6d9/PyQt6-6.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:d36482000f0cd7ce84a35863766f88a5e671233d5f1024656b600cd8915b3752", size = 6748279, upload-time = "2025-04-08T09:00:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/9f/38/586ce139b1673a27607f7b85c594878e1bba215abdca3de67732b463f7b2/PyQt6-6.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:0c8b7251608e05b479cfe731f95857e853067459f7cbbcfe90f89de1bcf04280", size = 5478122, upload-time = "2025-04-08T09:00:45.296Z" }, ] [[package]] -name = "propcache" -version = "0.3.1" +name = "pyqt6-qt6" +version = "6.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload_time = "2025-03-26T03:06:12.05Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865, upload_time = "2025-03-26T03:04:53.406Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452, upload_time = "2025-03-26T03:04:54.624Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800, upload_time = "2025-03-26T03:04:55.844Z" }, - { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804, upload_time = "2025-03-26T03:04:57.158Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650, upload_time = "2025-03-26T03:04:58.61Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235, upload_time = "2025-03-26T03:05:00.599Z" }, - { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249, upload_time = "2025-03-26T03:05:02.11Z" }, - { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964, upload_time = "2025-03-26T03:05:03.599Z" }, - { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501, upload_time = "2025-03-26T03:05:05.107Z" }, - { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917, upload_time = "2025-03-26T03:05:06.59Z" }, - { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089, upload_time = "2025-03-26T03:05:08.1Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102, upload_time = "2025-03-26T03:05:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122, upload_time = "2025-03-26T03:05:11.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818, upload_time = "2025-03-26T03:05:12.909Z" }, - { url = "https://files.pythonhosted.org/packages/cc/27/4e0ef21084b53bd35d4dae1634b6d0bad35e9c58ed4f032511acca9d4d26/propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", size = 40112, upload_time = "2025-03-26T03:05:14.289Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2c/a54614d61895ba6dd7ac8f107e2b2a0347259ab29cbf2ecc7b94fa38c4dc/propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", size = 44034, upload_time = "2025-03-26T03:05:15.616Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613, upload_time = "2025-03-26T03:05:16.913Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763, upload_time = "2025-03-26T03:05:18.607Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175, upload_time = "2025-03-26T03:05:19.85Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265, upload_time = "2025-03-26T03:05:21.654Z" }, - { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412, upload_time = "2025-03-26T03:05:23.147Z" }, - { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290, upload_time = "2025-03-26T03:05:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926, upload_time = "2025-03-26T03:05:26.459Z" }, - { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808, upload_time = "2025-03-26T03:05:28.188Z" }, - { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916, upload_time = "2025-03-26T03:05:29.757Z" }, - { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661, upload_time = "2025-03-26T03:05:31.472Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384, upload_time = "2025-03-26T03:05:32.984Z" }, - { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420, upload_time = "2025-03-26T03:05:34.496Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880, upload_time = "2025-03-26T03:05:36.256Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407, upload_time = "2025-03-26T03:05:37.799Z" }, - { url = "https://files.pythonhosted.org/packages/3e/66/2e90547d6b60180fb29e23dc87bd8c116517d4255240ec6d3f7dc23d1926/propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", size = 42573, upload_time = "2025-03-26T03:05:39.193Z" }, - { url = "https://files.pythonhosted.org/packages/cb/8f/50ad8599399d1861b4d2b6b45271f0ef6af1b09b0a2386a46dbaf19c9535/propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", size = 46757, upload_time = "2025-03-26T03:05:40.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload_time = "2025-03-26T03:06:10.5Z" }, + { url = "https://files.pythonhosted.org/packages/e2/11/8c450442bf4702ed810689a045f9c5d9236d709163886f09374fd8d84143/PyQt6_Qt6-6.9.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:b1c4e4a78f0f22fbf88556e3d07c99e5ce93032feae5c1e575958d914612e0f9", size = 66804297, upload-time = "2025-04-08T08:51:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/6e/be/191ba4402c24646f6b98c326ff0ee22e820096c69e67ba5860a687057616/PyQt6_Qt6-6.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d3875119dec6bf5f799facea362aa0ad39bb23aa9654112faa92477abccb5ff", size = 60943708, upload-time = "2025-04-08T08:51:48.156Z" }, + { url = "https://files.pythonhosted.org/packages/0f/70/ec018b6e979b3914c984e5ab7e130918930d5423735ac96c70c328227b9b/PyQt6_Qt6-6.9.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9c0e603c934e4f130c110190fbf2c482ff1221a58317266570678bc02db6b152", size = 81846956, upload-time = "2025-04-08T08:51:54.582Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ed/2d78cd08be415a21dac2e7277967b90b0c05afc4782100f0a037447bb1c6/PyQt6_Qt6-6.9.0-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:cf840e8ae20a0704e0343810cf0e485552db28bf09ea976e58ec0e9b7bb27fcd", size = 80295982, upload-time = "2025-04-08T08:52:00.741Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/6b6168a75c7b6a55b9f6b5c897e6164ec15e94594af11a6f358c49845442/PyQt6_Qt6-6.9.0-py3-none-win_amd64.whl", hash = "sha256:c825a6f5a9875ef04ef6681eda16aa3a9e9ad71847aa78dfafcf388c8007aa0a", size = 73652485, upload-time = "2025-04-08T08:52:07.306Z" }, + { url = "https://files.pythonhosted.org/packages/44/fd/1238931df039e46e128d53974c0cfc9d34da3d54c5662bd589fe7b0a67c2/PyQt6_Qt6-6.9.0-py3-none-win_arm64.whl", hash = "sha256:1188f118d1c570d27fba39707e3d8a48525f979816e73de0da55b9e6fa9ad0a1", size = 49568913, upload-time = "2025-04-08T08:52:12.587Z" }, ] [[package]] -name = "pygments" -version = "2.19.1" +name = "pyqt6-sip" +version = "13.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload_time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/18/0405c54acba0c8e276dd6f0601890e6e735198218d031a6646104870fe22/pyqt6_sip-13.10.0.tar.gz", hash = "sha256:d6daa95a0bd315d9ec523b549e0ce97455f61ded65d5eafecd83ed2aa4ae5350", size = 92464, upload-time = "2025-02-02T17:14:05.839Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload_time = "2025-01-06T17:26:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0c/8d1de48b45b565a46bf4757341f13f9b1853a7d2e6b023700f0af2c213ab/PyQt6_sip-13.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b6e250c2e7c14702a623f2cc1479d7fb8db2b6eee9697cac10d06fe79c281bb", size = 112343, upload-time = "2025-02-02T17:13:49.605Z" }, + { url = "https://files.pythonhosted.org/packages/af/13/e2cc2b667a9f5d44c2d0e18fa6e1066fca3f4521dcb301f4b5374caeb33e/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcb30756568f8cd59290f9ef2ae5ee3e72ff9cdd61a6f80c9e3d3b95ae676be", size = 322527, upload-time = "2025-02-02T17:13:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/20/1a/5c6fcae85edb65cf236c9dc6d23b279b5316e94cdca1abdee6d0a217ddbb/PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:757ac52c92b2ef0b56ecc7cd763b55a62d3c14271d7ea8d03315af85a70090ff", size = 303407, upload-time = "2025-02-02T17:13:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/b9/db/6924ec985be7d746772806b96ab81d24263ef72f0249f0573a82adaed75e/PyQt6_sip-13.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:571900c44a3e38738d696234d94fe2043972b9de0633505451c99e2922cb6a34", size = 53580, upload-time = "2025-02-02T17:13:54.98Z" }, + { url = "https://files.pythonhosted.org/packages/77/c3/9e44729b582ee7f1d45160e8c292723156889f3e38ce6574f88d5ab8fa02/PyQt6_sip-13.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:39cba2cc71cf80a99b4dc8147b43508d4716e128f9fb99f5eb5860a37f082282", size = 45446, upload-time = "2025-02-02T17:13:57.135Z" }, ] [[package]] @@ -497,180 +539,127 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload_time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload_time = "2025-03-02T12:54:52.069Z" }, + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] [[package]] -name = "rich" -version = "14.0.0" +name = "pytest-cov" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, + { name = "coverage" }, + { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload_time = "2025-03-30T14:15:14.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload_time = "2025-03-30T14:15:12.283Z" }, + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] [[package]] -name = "ruff" -version = "0.11.7" +name = "pytest-qt" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload_time = "2025-04-24T18:49:37.007Z" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/2c/6a477108342bbe1f5a81a2c54c86c3efadc35f6ad47c76f00c75764a0f7c/pytest-qt-4.4.0.tar.gz", hash = "sha256:76896142a940a4285339008d6928a36d4be74afec7e634577e842c9cc5c56844", size = 125443, upload-time = "2024-02-07T21:22:15.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload_time = "2025-04-24T18:48:40.459Z" }, - { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload_time = "2025-04-24T18:48:44.742Z" }, - { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload_time = "2025-04-24T18:48:47.918Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload_time = "2025-04-24T18:48:51.707Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload_time = "2025-04-24T18:48:54.243Z" }, - { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload_time = "2025-04-24T18:48:57.639Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload_time = "2025-04-24T18:49:00.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload_time = "2025-04-24T18:49:03.545Z" }, - { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload_time = "2025-04-24T18:49:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload_time = "2025-04-24T18:49:11.41Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload_time = "2025-04-24T18:49:14.184Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload_time = "2025-04-24T18:49:17.559Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload_time = "2025-04-24T18:49:20.247Z" }, - { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload_time = "2025-04-24T18:49:23.765Z" }, - { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload_time = "2025-04-24T18:49:27.377Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload_time = "2025-04-24T18:49:30.938Z" }, - { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload_time = "2025-04-24T18:49:34.392Z" }, + { url = "https://files.pythonhosted.org/packages/4c/51/6cc5b9c1ecdcd78e6cde97e03d05f5a4ace8f720c5ce0f26f9dce474a0da/pytest_qt-4.4.0-py3-none-any.whl", hash = "sha256:001ed2f8641764b394cf286dc8a4203e40eaf9fff75bf0bfe5103f7f8d0c591d", size = 36286, upload-time = "2024-02-07T21:22:13.295Z" }, ] [[package]] -name = "sqlalchemy" -version = "2.0.40" +name = "python-dateutil" +version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, - { name = "typing-extensions" }, + { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299, upload_time = "2025-03-27T17:52:31.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887, upload_time = "2025-03-27T18:40:05.461Z" }, - { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367, upload_time = "2025-03-27T18:40:07.182Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806, upload_time = "2025-03-27T18:51:29.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131, upload_time = "2025-03-27T18:50:31.616Z" }, - { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364, upload_time = "2025-03-27T18:51:31.336Z" }, - { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482, upload_time = "2025-03-27T18:50:33.201Z" }, - { url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704, upload_time = "2025-03-27T18:46:00.193Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564, upload_time = "2025-03-27T18:46:01.442Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894, upload_time = "2025-03-27T18:40:43.796Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] -name = "textual" -version = "3.1.1" +name = "pywin32-ctypes" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, - { name = "platformdirs" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/c9/b36f65d15452bdca2b186526262ce8759ee8089ae76c3cc8e3fe303cc527/textual-3.1.1.tar.gz", hash = "sha256:cfb40a820edf77cae1c11fa15056d9e1a731c7bcbc6ab293aafcc139a4e46b6a", size = 1592628, upload_time = "2025-04-22T11:32:48.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/a7/802690234cdbdf99020c7c55512b5cea8344b6578a40b19f2f863c659867/textual-3.1.1-py3-none-any.whl", hash = "sha256:623fa18be75f8acba6c8d5aca019ff894a9614de8c456574ba53a728c6c44dad", size = 683838, upload_time = "2025-04-22T11:32:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] [[package]] -name = "textual-dev" -version = "1.7.0" +name = "ruff" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "msgpack" }, - { name = "textual" }, - { name = "textual-serve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/d3/ed0b20f6de0af1b7062c402d59d256029c0daa055ad9e04c27471b450cdd/textual_dev-1.7.0.tar.gz", hash = "sha256:bf1a50eaaff4cd6a863535dd53f06dbbd62617c371604f66f56de3908220ccd5", size = 25935, upload_time = "2024-11-18T16:59:47.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload-time = "2025-04-24T18:49:37.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/4b/3c1eb9cbc39f2f28d27e10ef2fe42bfe0cf3c2f8445a454c124948d6169b/textual_dev-1.7.0-py3-none-any.whl", hash = "sha256:a93a846aeb6a06edb7808504d9c301565f7f4bf2e7046d56583ed755af356c8d", size = 27221, upload_time = "2024-11-18T16:59:46.833Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload-time = "2025-04-24T18:48:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload-time = "2025-04-24T18:48:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload-time = "2025-04-24T18:48:47.918Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload-time = "2025-04-24T18:48:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload-time = "2025-04-24T18:48:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload-time = "2025-04-24T18:48:57.639Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload-time = "2025-04-24T18:49:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload-time = "2025-04-24T18:49:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload-time = "2025-04-24T18:49:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload-time = "2025-04-24T18:49:11.41Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload-time = "2025-04-24T18:49:14.184Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload-time = "2025-04-24T18:49:17.559Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload-time = "2025-04-24T18:49:20.247Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload-time = "2025-04-24T18:49:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload-time = "2025-04-24T18:49:27.377Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload-time = "2025-04-24T18:49:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload-time = "2025-04-24T18:49:34.392Z" }, ] [[package]] -name = "textual-serve" -version = "1.1.2" +name = "setuptools" +version = "80.4.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aiohttp-jinja2" }, - { name = "jinja2" }, - { name = "rich" }, - { name = "textual" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/41/09d5695b050d592ff58422be2ca5c9915787f59ff576ca91d9541d315406/textual_serve-1.1.2.tar.gz", hash = "sha256:0ccaf9b9df9c08d4b2d7a0887cad3272243ba87f68192c364f4bed5b683e4bd4", size = 892959, upload_time = "2025-04-16T12:11:41.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/0cc40fe41fd2adb80a2f388987f4f8db3c866c69e33e0b4c8b093fdf700e/setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006", size = 1315008, upload-time = "2025-05-09T20:42:27.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/fb/0006f86960ab8a2f69c9f496db657992000547f94f53a2f483fd611b4bd2/textual_serve-1.1.2-py3-none-any.whl", hash = "sha256:147d56b165dccf2f387203fe58d43ce98ccad34003fe3d38e6d2bc8903861865", size = 447326, upload_time = "2025-04-16T12:11:43.176Z" }, + { url = "https://files.pythonhosted.org/packages/b1/93/dba5ed08c2e31ec7cdc2ce75705a484ef0be1a2fecac8a58272489349de8/setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2", size = 1200812, upload-time = "2025-05-09T20:42:25.325Z" }, ] [[package]] -name = "typing-extensions" -version = "4.13.2" +name = "six" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload_time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload_time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] -name = "uc-micro-py" -version = "1.0.3" +name = "sqlalchemy" +version = "2.0.40" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload_time = "2024-02-09T16:52:01.654Z" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299, upload-time = "2025-03-27T17:52:31.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload_time = "2024-02-09T16:52:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887, upload-time = "2025-03-27T18:40:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367, upload-time = "2025-03-27T18:40:07.182Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806, upload-time = "2025-03-27T18:51:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131, upload-time = "2025-03-27T18:50:31.616Z" }, + { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364, upload-time = "2025-03-27T18:51:31.336Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482, upload-time = "2025-03-27T18:50:33.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/ac/e5e0a807163652a35be878c0ad5cfd8b1d29605edcadfb5df3c512cdf9f3/sqlalchemy-2.0.40-cp313-cp313-win32.whl", hash = "sha256:a669cbe5be3c63f75bcbee0b266779706f1a54bcb1000f302685b87d1b8c1500", size = 2080704, upload-time = "2025-03-27T18:46:00.193Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/f38c61f7f2fd4d10494c1c135ff6a6ddb63508d0b47bccccd93670637309/sqlalchemy-2.0.40-cp313-cp313-win_amd64.whl", hash = "sha256:641ee2e0834812d657862f3a7de95e0048bdcb6c55496f39c6fa3d435f6ac6ad", size = 2104564, upload-time = "2025-03-27T18:46:01.442Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894, upload-time = "2025-03-27T18:40:43.796Z" }, ] [[package]] -name = "yarl" -version = "1.20.0" +name = "typing-extensions" +version = "4.13.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258, upload_time = "2025-04-17T00:45:14.661Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030, upload_time = "2025-04-17T00:43:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894, upload_time = "2025-04-17T00:43:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457, upload_time = "2025-04-17T00:43:19.431Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070, upload_time = "2025-04-17T00:43:21.426Z" }, - { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739, upload_time = "2025-04-17T00:43:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338, upload_time = "2025-04-17T00:43:25.695Z" }, - { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636, upload_time = "2025-04-17T00:43:27.876Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061, upload_time = "2025-04-17T00:43:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150, upload_time = "2025-04-17T00:43:31.742Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207, upload_time = "2025-04-17T00:43:34.099Z" }, - { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277, upload_time = "2025-04-17T00:43:36.202Z" }, - { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990, upload_time = "2025-04-17T00:43:38.551Z" }, - { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684, upload_time = "2025-04-17T00:43:40.481Z" }, - { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599, upload_time = "2025-04-17T00:43:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573, upload_time = "2025-04-17T00:43:44.797Z" }, - { url = "https://files.pythonhosted.org/packages/fd/be/29f5156b7a319e4d2e5b51ce622b4dfb3aa8d8204cd2a8a339340fbfad40/yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", size = 86051, upload_time = "2025-04-17T00:43:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/52/56/05fa52c32c301da77ec0b5f63d2d9605946fe29defacb2a7ebd473c23b81/yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", size = 92742, upload_time = "2025-04-17T00:43:49.193Z" }, - { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575, upload_time = "2025-04-17T00:43:51.533Z" }, - { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121, upload_time = "2025-04-17T00:43:53.506Z" }, - { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815, upload_time = "2025-04-17T00:43:55.41Z" }, - { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231, upload_time = "2025-04-17T00:43:57.825Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221, upload_time = "2025-04-17T00:44:00.526Z" }, - { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400, upload_time = "2025-04-17T00:44:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714, upload_time = "2025-04-17T00:44:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279, upload_time = "2025-04-17T00:44:07.721Z" }, - { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044, upload_time = "2025-04-17T00:44:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236, upload_time = "2025-04-17T00:44:11.734Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034, upload_time = "2025-04-17T00:44:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943, upload_time = "2025-04-17T00:44:16.052Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058, upload_time = "2025-04-17T00:44:18.547Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792, upload_time = "2025-04-17T00:44:20.639Z" }, - { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242, upload_time = "2025-04-17T00:44:22.851Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f7/f0f2500cf0c469beb2050b522c7815c575811627e6d3eb9ec7550ddd0bfe/yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", size = 93816, upload_time = "2025-04-17T00:44:25.491Z" }, - { url = "https://files.pythonhosted.org/packages/3f/93/f73b61353b2a699d489e782c3f5998b59f974ec3156a2050a52dfd7e8946/yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", size = 101093, upload_time = "2025-04-17T00:44:27.418Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124, upload_time = "2025-04-17T00:45:12.199Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ]